From 99f490e7a4af2866d3f7ac11e1aa54054c3a01a4 Mon Sep 17 00:00:00 2001 From: Erik Svilich Date: Sat, 24 Oct 2026 15:58:59 +0000 Subject: [PATCH 01/17] Add an optional platform timer to runtime services --- .../trusted-server-core/src/platform/mod.rs | 3 ++ .../src/platform/test_support.rs | 29 +++++++++++++++- .../trusted-server-core/src/platform/timer.rs | 20 +++++++++++ .../trusted-server-core/src/platform/types.rs | 34 ++++++++++++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 crates/trusted-server-core/src/platform/timer.rs diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 50fd3ca6cf..2f40b03e22 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -15,6 +15,7 @@ //! - [`PlatformTemplateCache`] — shared transformed-template caching //! - [`PlatformValueCache`], a shared cache of small values with lifetimes //! - [`PlatformLoadingCache`], a cache that loads a missing value once per key +//! - [`PlatformTimer`], an optional timer for bounding asynchronous work //! //! ## Platform-Agnostic Components //! @@ -57,6 +58,7 @@ mod template_cache; reason = "test doubles, offered to other crates' tests under the test-utils feature" )] pub mod test_support; +mod timer; mod traits; mod types; mod value_cache; @@ -101,6 +103,7 @@ pub use template_cache::{ pub use traits::{ GeoModule, PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore, }; +pub use timer::PlatformTimer; pub use types::{ ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, StoreName, diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index 20f865f0ed..711175873a 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -12,7 +12,7 @@ use super::{ ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformCacheIntent, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerOptions, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, - PlatformSelectResult, RuntimeServices, StoreId, StoreName, + PlatformSelectResult, PlatformTimer, RuntimeServices, StoreId, StoreName, }; pub struct NoopConfigStore; @@ -61,6 +61,33 @@ impl PlatformSecretStore for NoopSecretStore { } } +/// A timer whose sleeps have always already elapsed, recording each requested +/// duration. Work still pending when its deadline is first raced misses it. +#[derive(Default)] +pub struct ElapsedTimer { + pub requested: Mutex>, +} + +#[async_trait::async_trait(?Send)] +impl PlatformTimer for ElapsedTimer { + async fn sleep(&self, duration: Duration) { + self.requested + .lock() + .expect("should lock requested durations") + .push(duration); + } +} + +/// A timer that never fires. +pub struct StoppedTimer; + +#[async_trait::async_trait(?Send)] +impl PlatformTimer for StoppedTimer { + async fn sleep(&self, _duration: Duration) { + futures::future::pending::<()>().await; + } +} + pub struct HashMapConfigStore { data: HashMap, } diff --git a/crates/trusted-server-core/src/platform/timer.rs b/crates/trusted-server-core/src/platform/timer.rs new file mode 100644 index 0000000000..e08603588d --- /dev/null +++ b/crates/trusted-server-core/src/platform/timer.rs @@ -0,0 +1,20 @@ +use std::time::Duration; + +/// A timer that can wake a pending future after a delay. +/// +/// Optional on [`crate::platform::RuntimeServices`]: its presence is the +/// capability. An adapter provides one only when its runtime can fire a timer +/// while other work of the request is pending. A runtime whose host calls +/// block the guest could not fire one in the middle of a call, so its adapter +/// provides none, and work there is bounded by the transport timeouts alone. +/// +/// Uses `?Send` futures, matching [`crate::platform::PlatformHttpClient`] and +/// [`crate::platform::PlatformKvStore`], because the WebAssembly timers are +/// `!Send`. +#[async_trait::async_trait(?Send)] +pub trait PlatformTimer: Send + Sync { + /// Completes once `duration` has elapsed. + /// + /// Dropping the returned future before it completes cancels the timer. + async fn sleep(&self, duration: Duration); +} diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 8c3b960907..25266a330b 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -7,7 +7,7 @@ use crate::auction::telemetry::{AuctionTelemetrySink, NoopAuctionTelemetrySink}; use super::{ GeoModule, PlatformBackend, PlatformConfigStore, PlatformHttpClient, PlatformKvStore, - PlatformSecretStore, + PlatformSecretStore, PlatformTimer, }; use crate::ec::device::DeviceModule; use crate::ec::identifiers::IdentifierModule; @@ -203,6 +203,9 @@ pub struct RuntimeServices { pub(crate) geo: Arc, /// Auction telemetry sink. pub(crate) auction_telemetry_sink: Arc, + /// Wall-clock timer, when the runtime can fire one while request work is + /// pending. `None` means the platform has no such timer. + pub(crate) timer: Option>, /// Per-request client metadata extracted at the entry point. pub(crate) client_info: ClientInfo, /// Host-computed client signals (TLS JA4, HTTP/2), when the host @@ -350,6 +353,13 @@ impl RuntimeServices { &*self.auction_telemetry_sink } + /// Returns the platform timer, or `None` when the runtime cannot fire a + /// timer while request work is pending. + #[must_use] + pub fn timer(&self) -> Option<&dyn PlatformTimer> { + self.timer.as_deref() + } + /// Returns per-request client metadata (IP address, TLS details). #[must_use] pub fn client_info(&self) -> &ClientInfo { @@ -599,6 +609,15 @@ impl RuntimeServices { ..self } } + + /// Returns a clone of this instance with `timer` as the platform timer. + #[must_use] + pub fn with_timer(self, timer: Arc) -> Self { + Self { + timer: Some(timer), + ..self + } + } } impl fmt::Debug for RuntimeServices { @@ -625,6 +644,7 @@ pub struct RuntimeServicesBuilder { http_client: Option>, geo: Option>, auction_telemetry_sink: Option>, + timer: Option>, client_info: Option, host_signals: Option>, resolved_ec_module: Option>, @@ -648,6 +668,7 @@ impl RuntimeServicesBuilder { http_client: None, geo: None, auction_telemetry_sink: None, + timer: None, client_info: None, host_signals: None, resolved_ec_module: None, @@ -741,6 +762,16 @@ impl RuntimeServicesBuilder { self } + /// Set the platform timer, or `None` when the runtime has none. + /// + /// Only adapters whose runtime can fire a timer while request work is + /// pending provide one. Unset means no timer. + #[must_use] + pub fn timer(mut self, timer: Option>) -> Self { + self.timer = timer; + self + } + /// Set the per-request client metadata. #[must_use] pub fn client_info(mut self, client_info: ClientInfo) -> Self { @@ -856,6 +887,7 @@ impl RuntimeServicesBuilder { auction_telemetry_sink: self .auction_telemetry_sink .unwrap_or_else(|| Arc::new(NoopAuctionTelemetrySink)), + timer: self.timer, client_info: self .client_info .expect("should set client_info before building RuntimeServices"), From 561f0df68f036955fc3ac8f5ef28e6855c952dd3 Mon Sep 17 00:00:00 2001 From: Erik Svilich Date: Sat, 24 Oct 2026 16:08:50 +0000 Subject: [PATCH 02/17] Open the Fastly runtime KV store when it is linked --- .../trusted-server-adapter-fastly/src/app.rs | 44 +++++++++- .../src/streaming_processor.rs | 84 +++++++++++++++++++ fastly.toml | 6 ++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 7955c6cae9..47f4c8edda 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -85,6 +85,7 @@ use std::sync::Arc; use crate::rate_limiter::{FastlyRateLimiter, RATE_COUNTER_NAME}; use edgezero_adapter_fastly::context::FastlyRequestContext; +use edgezero_adapter_fastly::key_value_store::FastlyKvStore; use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; @@ -158,6 +159,23 @@ use crate::platform::{ // AppState // --------------------------------------------------------------------------- +/// The logical id the deployment's KV store is linked to the service under, +/// as `edgezero.toml` declares it. +const DEFAULT_KV_STORE_ID: &str = "trusted_server_kv"; + +/// Opens the KV store linked under [`DEFAULT_KV_STORE_ID`], or +/// [`UnavailableKvStore`] when the service links none, so a module that +/// needs the store passes its work by rather than failing the request. +fn open_default_kv_store() -> Arc { + match FastlyKvStore::open(DEFAULT_KV_STORE_ID) { + Ok(store) => Arc::new(store), + Err(err) => { + log::debug!("The KV store `{DEFAULT_KV_STORE_ID}` is not linked: {err}"); + Arc::new(UnavailableKvStore) + } + } +} + /// The stores a service reads and the key of its app-config blob. /// /// [`Self::logical`] gives the logical stores and key. The entry point puts @@ -398,7 +416,7 @@ pub(crate) fn build_state_with_registrations( Arc::new(trusted_server_core::auction::NoopAuctionTelemetrySink) as Arc }); - let default_kv_store = Arc::new(UnavailableKvStore) as Arc; + let default_kv_store = open_default_kv_store(); let loading_caches = trusted_server_core::integrations::loading_caches_from(&settings, integrations)?; @@ -1762,8 +1780,8 @@ impl Hooks for TrustedServerApp { ids: &[DEFAULT_CONFIG_STORE_ID], }), kv: Some(StoreMetadata { - default: "trusted_server_kv", - ids: &["trusted_server_kv"], + default: DEFAULT_KV_STORE_ID, + ids: &[DEFAULT_KV_STORE_ID], }), secrets: Some(StoreMetadata { default: DEFAULT_SECRET_STORE_ID, @@ -1866,6 +1884,26 @@ mod tests { assert_eq!(stores.secret_store_name.as_ref(), "trusted_server_secrets"); } + #[test] + fn app_state_opens_the_linked_kv_store() { + let state = app_state_for_settings(test_settings()); + + block_on( + state + .default_kv_store + .put_bytes("default-kv-open-test", Bytes::from_static(b"value")), + ) + .expect("should write to the linked KV store"); + let value = block_on(state.default_kv_store.get_bytes("default-kv-open-test")) + .expect("should read from the linked KV store"); + + assert_eq!( + value, + Some(Bytes::from_static(b"value")), + "should read back what was written to the store linked as trusted_server_kv" + ); + } + fn app_state_for_settings(settings: Settings) -> Arc { build_state_from_settings(settings).expect("should build app state from settings") } diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index ec5ba1ac8f..4c751ebdcd 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -22,6 +22,7 @@ use std::cell::{Cell, RefCell}; use std::io::{self, Read, Write}; use std::rc::Rc; +use std::time::Duration; use brotli::Decompressor; use brotli::enc::BrotliEncoderParams; @@ -29,8 +30,10 @@ use brotli::enc::writer::CompressorWriter; use error_stack::{Report, ResultExt as _}; use flate2::read::ZlibDecoder; use flate2::write::{GzEncoder, ZlibEncoder}; +use futures::future::{Either, LocalBoxFuture}; use crate::error::TrustedServerError; +use crate::platform::RuntimeServices; /// Trait for streaming content processors pub trait StreamProcessor { @@ -50,6 +53,87 @@ pub trait StreamProcessor { /// Reset the processor state (useful for reuse) fn reset(&mut self) {} + + /// Start an optional asynchronous finish of this processor's final output. + /// + /// `held` is exactly what the final [`process_chunk`](Self::process_chunk) + /// call (`is_last = true`) returned: the bytes the processor emits when it + /// does no asynchronous work. The driver owns `held` and calls this at most + /// once, right after that final call, with the request's + /// [`RuntimeServices`]. + /// + /// Returning `None` (the default) emits `held` unchanged; no future is + /// created. Returning a finish makes the driver await it and emit its + /// output in place of `held`. The output is infallible: a processor absorbs + /// its own failures and returns the bytes to emit, typically `held` itself. + /// On a runtime with a [`PlatformTimer`](crate::platform::PlatformTimer), + /// the driver drops the finish once the deadline given to + /// [`AsyncFinish::new`] elapses and emits `held` instead. + /// + /// Only drivers that can await call this. A synchronous driver, such as + /// [`StreamingPipeline`], emits `held`. + fn async_finish<'a>( + &'a mut self, + held: &'a [u8], + services: &'a RuntimeServices, + ) -> Option> { + let _ = (held, services); + None + } +} + +/// Asynchronous work a [`StreamProcessor`] completes after its final chunk. +/// +/// Created by [`StreamProcessor::async_finish`]. The future is `?Send`, like +/// the platform services it awaits. +pub struct AsyncFinish<'a> { + deadline: Duration, + output: LocalBoxFuture<'a, Vec>, +} + +impl<'a> AsyncFinish<'a> { + /// Wrap `output`, which resolves to the bytes to emit, with the time the + /// driver may wait for it. + /// + /// `deadline` is measured from when the driver starts awaiting the finish. + /// It is a backstop: a processor should bound its own calls well within it. + #[must_use] + pub fn new(deadline: Duration, output: impl Future> + 'a) -> Self { + Self { + deadline, + output: Box::pin(output), + } + } +} + +/// Run `processor`'s optional asynchronous finish over `held`, its final output. +/// +/// Returns the finish output, or `held` when the processor has no finish. When +/// `services` has a timer, the finish races its deadline; on expiry the finish +/// future is dropped and `held` is returned. Without a timer the finish is +/// awaited to completion. +pub(crate) async fn finish_held( + processor: &mut dyn StreamProcessor, + held: Vec, + services: &RuntimeServices, +) -> Vec { + let deadline = { + let Some(AsyncFinish { deadline, output }) = processor.async_finish(&held, services) else { + return held; + }; + let Some(timer) = services.timer() else { + return output.await; + }; + match futures::future::select(output, timer.sleep(deadline)).await { + Either::Left((output, _)) => return output, + Either::Right(((), _)) => deadline, + } + }; + log::warn!( + "stream processor finish exceeded its {} ms deadline; emitting its held output", + deadline.as_millis() + ); + held } /// Compression type for the stream diff --git a/fastly.toml b/fastly.toml index 020b15ea28..bc8f06a7df 100644 --- a/fastly.toml +++ b/fastly.toml @@ -23,6 +23,12 @@ build = """ [local_server.backends] [local_server.kv_stores] + # The runtime KV store, linked as the logical `trusted_server_kv` that + # edgezero.toml declares. + [[local_server.kv_stores.trusted_server_kv]] + key = "placeholder" + data = "placeholder" + [[local_server.kv_stores.counter_store]] key = "placeholder" data = "placeholder" From 589838405c01e81e135bf6bd8dfb8fe0506bf703 Mon Sep 17 00:00:00 2001 From: Erik Svilich Date: Sat, 24 Oct 2026 16:08:50 +0000 Subject: [PATCH 03/17] End middleware stream processors with an awaited asynchronous finish A stream processor may implement StreamProcessor::async_finish over the bytes its last process_chunk call returned. StreamProcessor::finish ends a processor once; a processor made of others ends each in order, so each is handed what the one before it finished with. Every publisher path that can await now ends its processor this way: the no-hold pipeline, both auction-hold loops, the serve pass on a shared template and the lazy stream. A body read whole takes the same chunk pipeline, and the synchronous stream_publisher_body path is removed. A middleware is also told the response status and whether the origin marked the response as one reader's, read before core writes its own cache policy. --- crates/middleware/51degrees/src/lib.rs | 2 + .../benches/html_processor_bench.rs | 2 + .../trusted-server-core/src/html_processor.rs | 66 +++ .../src/integrations/js_asset_proxy.rs | 2 + crates/trusted-server-core/src/middleware.rs | 12 + .../src/middleware/tests.rs | 2 + crates/trusted-server-core/src/publisher.rs | 523 +++++++++--------- .../src/streaming_processor.rs | 44 +- 8 files changed, 381 insertions(+), 272 deletions(-) diff --git a/crates/middleware/51degrees/src/lib.rs b/crates/middleware/51degrees/src/lib.rs index 4b5a557ef8..bd5bf849f8 100644 --- a/crates/middleware/51degrees/src/lib.rs +++ b/crates/middleware/51degrees/src/lib.rs @@ -658,6 +658,8 @@ mod tests { request_host: "www.example.com", request_scheme: "https", origin_host: "origin.example.com", + status: http::StatusCode::OK, + origin_private: false, platform: test_support::platform(), document_state: state, max_buffered_script_bytes: 1 << 20, diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index e650dc0782..962001fc22 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -18,6 +18,8 @@ fn make_config() -> HtmlProcessorConfig { ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, prepared: std::sync::Arc::new(trusted_server_core::middleware::Prepared::default()), + status: http::StatusCode::OK, + origin_private: false, request_state: trusted_server_core::integrations::IntegrationRequestState::default(), // The benchmark measures URL rewriting, not ad injection, and // `ad_slots_script` is `None` here — matching the previous behaviour, diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 723e55a234..44929b7825 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -7,6 +7,8 @@ use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use futures::future::LocalBoxFuture; +use http::StatusCode; use lol_html::{ EndTagHandler, Settings as RewriterSettings, element, end, html_content::{ContentType, EndTag, TextChunk, TextType}, @@ -22,6 +24,7 @@ use crate::middleware::{ ElementHandler, MatchedAttribute, MiddlewareChain, MiddlewareContext, MiddlewarePlan, Prepared, ResponseHeader, TextHandler, }; +use crate::platform::RuntimeServices; use crate::prefix::{PUBLIC_PREFIX_ATTRIBUTE, PublicPrefix}; use crate::publisher::build_empty_bids_script; use crate::settings::Settings; @@ -32,6 +35,8 @@ mod script_text; use script_text::ScriptTextGuard; +/// The HTML rewriter followed by the middleware's stream processors, each +/// handed what the one before it produced. struct HtmlWithStreamingProcessors { inner: Box, processors: Vec>, @@ -52,6 +57,24 @@ impl StreamProcessor for HtmlWithStreamingProcessors { processor.reset(); } } + + /// Ends the rewriter once and then each processor in order, so a + /// processor is handed its last chunk only after the one before it has + /// finished, its asynchronous finish included. + fn finish<'a>( + &'a mut self, + chunk: &'a [u8], + services: &'a RuntimeServices, + ) -> LocalBoxFuture<'a, Result, io::Error>> { + Box::pin(async move { + let mut output = self.inner.finish(chunk, services).await?; + for processor in &mut self.processors { + let finished = processor.finish(&output, services).await?; + output = finished; + } + Ok(output) + }) + } } /// What the `` seam injects. @@ -125,6 +148,10 @@ pub struct HtmlProcessorConfig { /// What the middleware prepared for this document, see /// [`Middleware::prepare`](crate::middleware::Middleware::prepare). pub prepared: Arc, + /// See [`MiddlewareContext::status`]. + pub status: StatusCode, + /// See [`MiddlewareContext::origin_private`]. + pub origin_private: bool, } impl HtmlProcessorConfig { @@ -151,6 +178,8 @@ impl HtmlProcessorConfig { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, } } @@ -224,6 +253,15 @@ impl HtmlProcessorConfig { self.prepared = prepared; self } + + /// Sets the status of the response the document is the body of, and + /// whether the origin marked that response as one reader's. + #[must_use] + pub fn with_response(mut self, status: StatusCode, origin_private: bool) -> Self { + self.status = status; + self.origin_private = origin_private; + self + } } /// The chunk of text being rewritten, as the last handler to change it left @@ -301,6 +339,8 @@ pub fn create_html_processor_with_middleware( request_host: &config.request_host, request_scheme: &config.request_scheme, origin_host: &config.origin_host, + status: config.status, + origin_private: config.origin_private, platform: config.integrations.platform(), document_state: &document_state, max_buffered_script_bytes: config.max_buffered_body_bytes, @@ -313,6 +353,8 @@ pub fn create_html_processor_with_middleware( request_host: &config.request_host, request_scheme: &config.request_scheme, origin_host: &config.origin_host, + status: config.status, + origin_private: config.origin_private, platform: config.integrations.platform(), request_state: &config.request_state, max_buffered_script_bytes: config.max_buffered_body_bytes, @@ -336,6 +378,10 @@ pub struct ReaderDocument<'a> { /// The host the document names as its own, being the `Host` its source /// sends. pub origin_host: &'a str, + /// See [`MiddlewareContext::status`]. + pub status: StatusCode, + /// See [`MiddlewareContext::origin_private`]. + pub origin_private: bool, /// What the adapter reports about the platform the deployment runs on. pub platform: &'a PlatformIdentity, /// What modules left on this reader's request for their page changes. @@ -372,6 +418,8 @@ pub fn create_serve_processor( request_host: document.request_host, request_scheme: document.request_scheme, origin_host: document.origin_host, + status: document.status, + origin_private: document.origin_private, platform: document.platform, document_state: &document_state, max_buffered_script_bytes: document.max_buffered_script_bytes, @@ -1240,6 +1288,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_owned(), request_host: "test.example.com".to_owned(), @@ -1991,6 +2041,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -2071,6 +2123,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, body_close: BodyCloseInjection::InlineBids, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -2112,6 +2166,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, body_close: BodyCloseInjection::InlineBids, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -2154,6 +2210,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, body_close: BodyCloseInjection::None, origin_host: "origin.test-publisher.example.com".to_string(), request_host: request_host.to_string(), @@ -2210,6 +2268,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, body_close: BodyCloseInjection::InlineBids, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -2244,6 +2304,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), @@ -2280,6 +2342,8 @@ mod tests { ad_bids_state: std::sync::Arc::new(std::sync::Mutex::new(None)), max_buffered_body_bytes: 16 * 1024 * 1024, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, request_state: IntegrationRequestState::default(), } } @@ -2490,6 +2554,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, body_close: BodyCloseInjection::Marker(MARKER.to_string()), origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index 69d9fca9ab..76e9103360 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -687,6 +687,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: std::sync::Arc::new(crate::middleware::Prepared::default()), + status: http::StatusCode::OK, + origin_private: false, body_close: BodyCloseInjection::None, origin_host: "origin.example.com".to_string(), request_host: "publisher.example.com".to_string(), diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index bf7717dc6a..f8555ff0fe 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -144,6 +144,16 @@ pub struct MiddlewareContext<'a> { /// sends, the same for a page served from the store as for one fetched /// for this request. pub origin_host: &'a str, + /// The status of the response the document is the body of. A page from + /// the store of shared templates was stored from a `200`. + pub status: StatusCode, + /// Whether the origin marked its response as one reader's, by setting a + /// cookie or by a `Cache-Control` that forbids shared storage. Read from + /// the origin's own headers, before core writes its cache policy on the + /// response, so a middleware that sends the document elsewhere can leave + /// a reader's own page alone. A page from the store of shared templates + /// was never one reader's. + pub origin_private: bool, /// What the adapter reports about the platform the deployment runs on, /// as the attestation evidence carries it. pub platform: &'a PlatformIdentity, @@ -1103,6 +1113,8 @@ pub mod test_support { request_host: REQUEST_HOST, request_scheme: "https", origin_host: ORIGIN_HOST, + status: http::StatusCode::OK, + origin_private: false, platform, document_state, max_buffered_script_bytes: 16 * 1024 * 1024, diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index 72814b6d26..fc493f74e1 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -67,6 +67,8 @@ fn reader(request_state: &IntegrationRequestState) -> ReaderDocument<'_> { request_host: REQUEST_HOST, request_scheme: "https", origin_host: ORIGIN_HOST, + status: StatusCode::OK, + origin_private: false, platform: test_support::platform(), request_state, max_buffered_script_bytes: 16 * 1024 * 1024, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 8ebe52a4a6..94d633b9e4 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -11,12 +11,12 @@ //! must gate them out before the body enters the rewrite pipeline. //! //! **Note on platform coupling:** The handler boundaries use portable HTTP -//! types: [`handle_publisher_request`] and [`stream_publisher_body`] take and -//! return `http::Request`/`http::Response` over `EdgeBody`, and platform I/O is -//! reached through `RuntimeServices` rather than a platform SDK directly. The -//! streaming processor itself is generic: `process_response_streaming` writes -//! into any [`Write`] (a `Vec` for buffered routes, a streaming writer for -//! the streaming route). It is not a content-rewriting concern. +//! types: [`handle_publisher_request`] and [`stream_publisher_body_async`] +//! take and return `http::Request`/`http::Response` over `EdgeBody`, and +//! platform I/O is reached through `RuntimeServices` rather than a platform +//! SDK directly. The body pipeline itself is generic: it writes into any +//! [`Write`] (a `Vec` for buffered routes) or yields a lazy stream for the +//! streaming route. It is not a content-rewriting concern. use std::borrow::Cow; use std::io::Write; @@ -32,6 +32,7 @@ use error_stack::{Report, ResultExt}; use flate2::read::ZlibDecoder; use flate2::write::{GzEncoder, ZlibEncoder}; use futures::StreamExt as _; +use futures::future::LocalBoxFuture; use http::{HeaderValue, Method, Request, Response, StatusCode, Uri, header}; use sha2::Digest as _; use web_time::Instant; @@ -96,8 +97,8 @@ use crate::settings::{ use crate::source::{SourceLocation, SourceRequest}; use crate::source_cache::{Found, Lookup, SourceAddress, SourceCache, SourceVary}; use crate::streaming_processor::{ - BodyStreamDecoder, BodyStreamEncoder, Compression, GzipDecodeReader, PipelineConfig, - STREAM_CHUNK_SIZE, StreamProcessor, StreamingPipeline, + BodyStreamDecoder, BodyStreamEncoder, Compression, GzipDecodeReader, STREAM_CHUNK_SIZE, + StreamProcessor, }; use crate::streaming_replacer::create_url_replacer; @@ -261,19 +262,43 @@ impl BodyChunkSource { } } -fn process_and_encode_chunk( +fn process_and_encode_chunk( processor: &mut P, encoder: &mut BodyStreamEncoder, chunk: &[u8], - is_last: bool, process_error: &str, ) -> Result, Report> { let processed = processor - .process_chunk(chunk, is_last) + .process_chunk(chunk, false) .change_context(TrustedServerError::Proxy { message: process_error.to_string(), })?; + encode_processed(encoder, processed) +} + +/// Ends `processor`, awaiting any asynchronous finish of a middleware's +/// processor, and encodes what it emits last. +async fn finish_and_encode( + processor: &mut P, + encoder: &mut BodyStreamEncoder, + services: &RuntimeServices, +) -> Result, Report> { + let processed = + processor + .finish(&[], services) + .await + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize processor".to_string(), + })?; + encode_processed(encoder, processed) +} + +/// Encodes processed bytes, or `None` when there is nothing to emit. +fn encode_processed( + encoder: &mut BodyStreamEncoder, + processed: Vec, +) -> Result, Report> { if processed.is_empty() { return Ok(None); } @@ -635,36 +660,6 @@ fn parse_single_module_filename( registry.js_module_id(stem) } -/// Parameters for processing response streaming. -struct ProcessResponseParams<'a> { - content_encoding: &'a str, - origin_host: &'a str, - origin_url: &'a str, - request_host: &'a str, - request_scheme: &'a str, - /// See [`OwnedProcessResponseParams::request_path`]. - request_path: &'a str, - settings: &'a Settings, - content_type: &'a str, - /// See [`OwnedProcessResponseParams::status`]. - status: StatusCode, - integration_registry: &'a IntegrationRegistry, - /// Head script carrying this request's permission state, or [`None`] under a - /// shared-template mode. See [`template_permissions_script`]. - permissions_script: Option<&'a str>, - ad_slots_script: Option<&'a str>, - ad_bids_state: &'a Arc>>, - /// See [`HtmlStreamProcessorParams::request_state`]. - request_state: &'a IntegrationRequestState, - /// See [`HtmlStreamProcessorParams::shared_template_authorized`]. - shared_template_authorized: bool, - /// See [`HtmlStreamProcessorParams::csp_nonce_observed`]. - csp_nonce_observed: Option<&'a Arc>, - prepared: &'a Arc, - /// See [`OwnedProcessResponseParams::public_prefix`]. - public_prefix: &'a crate::prefix::PublicPrefix, -} - struct PublisherBodyProcessor { inner: Box, /// What the serve middleware write on the response, taken by the @@ -679,6 +674,8 @@ struct TextResponse<'a> { /// The response's `Content-Type`, parameters and all. content_type: &'a str, status: StatusCode, + /// See [`MiddlewareContext::origin_private`](crate::middleware::MiddlewareContext::origin_private). + origin_private: bool, request_path: &'a str, request_host: &'a str, request_scheme: &'a str, @@ -744,6 +741,8 @@ fn middleware_stream_processors( request_host: response.request_host, request_scheme: response.request_scheme, origin_host: response.origin_host, + status: response.status, + origin_private: response.origin_private, platform: integration_registry.platform(), document_state: &document_state, max_buffered_script_bytes: response.max_buffered_script_bytes, @@ -898,6 +897,8 @@ async fn prepare_document( request_host: document.request_host, request_scheme: document.request_scheme, origin_host: document.origin_host, + status: document.status, + origin_private: document.origin_private, platform: integration_registry.platform(), document_state: &document_state, max_buffered_script_bytes: document.max_buffered_script_bytes, @@ -1004,6 +1005,27 @@ impl StreamProcessor for WithStreamProcessors { processor.reset(); } } + + /// Ends core's move of the address and then each processor in order, so + /// a processor is handed its last chunk only after the one before it has + /// finished, its asynchronous finish included. + fn finish<'a>( + &'a mut self, + chunk: &'a [u8], + services: &'a RuntimeServices, + ) -> LocalBoxFuture<'a, Result, std::io::Error>> { + Box::pin(async move { + let mut output = match &mut self.inner { + Some(inner) => inner.finish(chunk, services).await?, + None => chunk.to_vec(), + }; + for processor in &mut self.processors { + let finished = processor.finish(&output, services).await?; + output = finished; + } + Ok(output) + }) + } } impl PublisherBodyProcessor { @@ -1029,6 +1051,8 @@ impl PublisherBodyProcessor { { let (processor, headers) = create_html_stream_processor(HtmlStreamProcessorParams { origin_host: ¶ms.origin_host, + status: params.status, + origin_private: params.origin_private, request_host: ¶ms.request_host, request_scheme: ¶ms.request_scheme, request_path: ¶ms.request_path, @@ -1052,6 +1076,7 @@ impl PublisherBodyProcessor { &TextResponse { content_type: ¶ms.content_type, status: params.status, + origin_private: params.origin_private, request_path: ¶ms.request_path, request_host: ¶ms.request_host, request_scheme: ¶ms.request_scheme, @@ -1093,105 +1118,31 @@ impl StreamProcessor for PublisherBodyProcessor { fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> Result, std::io::Error> { self.inner.process_chunk(chunk, is_last) } + + fn finish<'a>( + &'a mut self, + chunk: &'a [u8], + services: &'a RuntimeServices, + ) -> LocalBoxFuture<'a, Result, std::io::Error>> { + self.inner.finish(chunk, services) + } } -/// Process response body through the streaming pipeline. -/// -/// Selects the processor by content type, the HTML rewriter or the text -/// pass of the entries' middleware, and pipes chunks from `body` through it -/// into `output`. The caller decides what `output` is, a `Vec` for -/// buffered responses or a `StreamingBody` for streaming. +/// Pipes `body` through the processor its content type selects, the HTML +/// rewriter or the text pass of the entries' middleware, into `output`, and +/// ends the processor with its asynchronous finish. A body read whole and a +/// streamed one take the same chunk pipeline. /// /// # Errors /// /// Returns an error if processor creation or chunk processing fails. -fn process_response_streaming( - body: EdgeBody, - output: &mut W, - params: &ProcessResponseParams, - output_compression: Compression, -) -> Result, Report> { - let is_html = is_html_content_type(params.content_type); - log::debug!( - "process_response_streaming: content_type={}, content_encoding={}, is_html={}", - params.content_type, - params.content_encoding, - is_html - ); - - let compression = Compression::from_content_encoding(params.content_encoding); - let config = PipelineConfig { - input_compression: compression, - output_compression, - chunk_size: 8192, - }; - // Bound how much decoded gzip output may sit in the heap at once, using the - // same ceiling the buffered writer enforces on the rewritten output: a gzip - // bomb is then rejected mid-decode instead of materializing its full - // expansion first. The bound is per-step, so a large honest body still - // streams through and only the buffered writer judges the total — otherwise - // gzip would reject bodies the identity, deflate and brotli paths accept. - let max_pending_decoded_bytes = params.settings.publisher.max_buffered_body_bytes; - - let response_headers = if is_html { - let (processor, headers) = create_html_stream_processor(HtmlStreamProcessorParams { - origin_host: params.origin_host, - request_host: params.request_host, - request_scheme: params.request_scheme, - request_path: params.request_path, - settings: params.settings, - integration_registry: params.integration_registry, - permissions_script: params.permissions_script.map(str::to_string), - ad_slots_script: params.ad_slots_script.map(str::to_string), - ad_bids_state: params.ad_bids_state.clone(), - request_state: params.request_state.clone(), - shared_template_authorized: params.shared_template_authorized, - csp_nonce_observed: params.csp_nonce_observed.cloned(), - prepared: Arc::clone(params.prepared), - public_prefix: params.public_prefix, - deferred_inline_marker: None, - })?; - StreamingPipeline::new(config, processor) - .with_max_pending_decoded_bytes(max_pending_decoded_bytes) - .process(body_as_reader(body)?, output)?; - headers - } else { - let (processor, headers) = middleware_stream_processors( - &TextResponse { - content_type: params.content_type, - status: params.status, - request_path: params.request_path, - request_host: params.request_host, - request_scheme: params.request_scheme, - origin_host: params.origin_host, - request_state: params.request_state, - max_buffered_script_bytes: params.settings.publisher.max_buffered_body_bytes, - prepared: params.prepared, - }, - params.settings, - params.integration_registry, - )? - .into_processor( - params.origin_host, - params.origin_url, - params.request_host, - params.request_scheme, - ); - StreamingPipeline::new(config, processor) - .with_max_pending_decoded_bytes(max_pending_decoded_bytes) - .process(body_as_reader(body)?, output)?; - headers - }; - - Ok(response_headers) -} - async fn process_response_streaming_async( body: EdgeBody, output: &mut W, params: &OwnedProcessResponseParams, settings: &Settings, integration_registry: &IntegrationRegistry, + services: &RuntimeServices, ) -> Result, Report> { log::debug!( "process_response_streaming_async: content_type={}, content_encoding={}", @@ -1228,6 +1179,7 @@ async fn process_response_streaming_async( input_compression, output_compression, settings.publisher.max_buffered_body_bytes, + services, ) .await?; Ok(response_headers) @@ -1239,7 +1191,7 @@ async fn process_response_streaming_async( /// [`passthrough_finish_segments`]. Shared by the write-sink driver /// ([`process_body_chunks_async`]) and the lazy publisher body stream so the /// two no-hold paths cannot drift apart. -async fn passthrough_step( +async fn passthrough_step( source: &mut BodyChunkSource, decoder: &mut BodyStreamDecoder, encoder: &mut BodyStreamEncoder, @@ -1253,25 +1205,22 @@ async fn passthrough_step( return Ok(Some(Vec::new())); } let mut segments = Vec::new(); - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &decoded, - false, - "Failed to process chunk", - )? { + if let Some(encoded) = + process_and_encode_chunk(processor, encoder, &decoded, "Failed to process chunk")? + { segments.push(encoded); } Ok(Some(segments)) } -async fn process_body_chunks_async( +async fn process_body_chunks_async( body: EdgeBody, writer: &mut W, processor: &mut P, input_compression: Compression, output_compression: Compression, max_body_bytes: usize, + services: &RuntimeServices, ) -> Result<(), Report> { let mut decoder = BodyStreamDecoder::new(input_compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(output_compression); @@ -1285,7 +1234,9 @@ async fn process_body_chunks_async( } } - for encoded in passthrough_finish_segments(processor, &mut decoder, &mut encoder)? { + for encoded in + passthrough_finish_segments(processor, &mut decoder, &mut encoder, services).await? + { write_encoded_segment(writer, &encoded)?; } writer.flush().change_context(TrustedServerError::Proxy { @@ -1308,12 +1259,13 @@ fn write_encoded_segment( } /// Finalize a no-hold chunk pipeline: drain the decoder tail through the -/// processor, signal end-of-stream to the processor, and emit the encoder -/// trailer. Returns the encoded segments for the caller to emit. -fn passthrough_finish_segments( +/// processor, end the processor with its asynchronous finish, and emit the +/// encoder trailer. Returns the encoded segments for the caller to emit. +async fn passthrough_finish_segments( processor: &mut P, decoder: &mut BodyStreamDecoder, encoder: &mut BodyStreamEncoder, + services: &RuntimeServices, ) -> Result, Report> { let mut segments = Vec::new(); let decoded_tail = decoder.finish()?; @@ -1322,19 +1274,12 @@ fn passthrough_finish_segments( processor, encoder, &decoded_tail, - false, "Failed to process decoded tail", )? { segments.push(encoded); } - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &[], - true, - "Failed to finalize processor", - )? { + if let Some(encoded) = finish_and_encode(processor, encoder, services).await? { segments.push(encoded); } let trailer = encoder.finish()?; @@ -1585,8 +1530,9 @@ async fn hold_step_next_chunk( .map(Some) } -/// Drain the decoder tail at end of the origin stream, returning the prefix the -/// caller must emit before [`hold_finish_tail_segments`]. +/// Drain the decoder tail at end of the origin stream and end the processor, +/// returning the prefix the caller must emit before +/// [`hold_finish_tail_segments`]. /// /// A codec can hold document bytes back until its own finalization — the gzip /// decoder releases the remainder of the final member at `finish()` — and that @@ -1594,8 +1540,12 @@ async fn hold_step_next_chunk( /// collection keeps the invariant the mid-stream path already has: only the /// closing `` tail waits for the auction, never renderable content. /// -/// On decoder failure the pending auction is abandoned before the error is -/// returned. +/// The processor's end awaits each middleware processor's asynchronous finish +/// in order, so a seam token a processor held reaches the seam scanner here, +/// once the whole chain has finished. +/// +/// On decoder or processor failure the pending auction is abandoned before +/// the error is returned. async fn hold_finish_ready_segments( processor: &mut P, decoder: &mut BodyStreamDecoder, @@ -1613,18 +1563,18 @@ async fn hold_finish_ready_segments( let mut step = hold_step_decoded_chunk(processor, encoder, &decoded_tail, state, collect_refs).await?; - let final_processed = - match processor - .process_chunk(&[], true) - .change_context(TrustedServerError::Proxy { - message: "Failed to finalize processor".to_string(), - }) { - Ok(processed) => processed, - Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; - return Err(err); - } - }; + let final_processed = match processor + .finish(&[], collect_refs.services) + .await + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize processor".to_string(), + }) { + Ok(processed) => processed, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; + return Err(err); + } + }; let final_ready = match state.hold.as_mut() { Some(seam) => seam.push(&final_processed), None => final_processed, @@ -1703,6 +1653,10 @@ async fn hold_finish_tail_segments( /// registry alive for as long as the processor. struct HtmlStreamProcessorParams<'a> { origin_host: &'a str, + /// See [`OwnedProcessResponseParams::status`]. + status: StatusCode, + /// See [`OwnedProcessResponseParams::origin_private`]. + origin_private: bool, request_host: &'a str, request_scheme: &'a str, /// See [`OwnedProcessResponseParams::request_path`]. @@ -1920,7 +1874,8 @@ fn create_html_stream_processor( .with_body_close(body_close) .with_csp_nonce_observer(csp_nonce_observed) .with_public_prefix(params.public_prefix.clone()) - .with_prepared(params.prepared); + .with_prepared(params.prepared) + .with_response(params.status, params.origin_private); // The middleware of the first [[fetch]] entry covering this path. let fetch = params.integration_registry.middleware_chain( @@ -1975,6 +1930,8 @@ fn reader_serve_processor( request_host: ¶ms.request_host, request_scheme: ¶ms.request_scheme, origin_host, + status: params.status, + origin_private: params.origin_private, platform: integration_registry.platform(), request_state: ¶ms.request_state, max_buffered_script_bytes: settings.publisher.max_buffered_body_bytes, @@ -1985,14 +1942,15 @@ fn reader_serve_processor( } /// Runs the `[[serve]]` entry covering this request over a reader's whole -/// copy, assembled and not yet encoded, and writes its headers on the -/// response. -fn run_serve_pass( +/// copy, assembled and not yet encoded, ending it with its asynchronous +/// finish, and writes its headers on the response. +async fn run_serve_pass( bytes: Vec, params: &OwnedProcessResponseParams, settings: &Settings, integration_registry: &IntegrationRegistry, response: &mut Response, + services: &RuntimeServices, ) -> Result, Report> { let pass = reader_serve_processor(params, settings, integration_registry)?; write_response_headers(response, pass.response_headers); @@ -2000,7 +1958,8 @@ fn run_serve_pass( return Ok(bytes); }; processor - .process_chunk(&bytes, true) + .finish(&bytes, services) + .await .change_context(TrustedServerError::Proxy { message: "a serve middleware could not rewrite the page".to_string(), }) @@ -2011,10 +1970,22 @@ fn run_serve_pass( fn serve_chunk( serve: &mut Option>, bytes: Vec, - is_last: bool, ) -> Result, std::io::Error> { match serve { - Some(processor) => processor.process_chunk(&bytes, is_last), + Some(processor) => processor.process_chunk(&bytes, false), + None => Ok(bytes), + } +} + +/// The last piece of a streamed reader's copy through the `[[serve]]` entry, +/// when one covers the request, ending it with its asynchronous finish. +async fn serve_last_chunk( + serve: &mut Option>, + bytes: Vec, + services: &RuntimeServices, +) -> Result, std::io::Error> { + match serve { + Some(processor) => processor.finish(&bytes, services).await, None => Ok(bytes), } } @@ -2045,7 +2016,7 @@ pub enum PublisherResponse { response: Response, /// Origin body to be piped through the streaming pipeline. body: EdgeBody, - /// Parameters for [`process_response_streaming`]. + /// Parameters for [`stream_publisher_body_async`]. params: Box, }, /// A shared template read from template cache, to be assembled on the way out. @@ -2253,8 +2224,8 @@ fn apply_publisher_asset_cache_policy( Ok(()) } -/// Owned version of [`ProcessResponseParams`] for returning from -/// [`handle_publisher_request`] without lifetime issues. +/// Owned body-processing parameters returned from [`handle_publisher_request`] +/// so the body can be processed after the handler returns. pub struct OwnedProcessResponseParams { /// Where to store the transformed template, or [`None`] to store nothing. /// @@ -2290,6 +2261,9 @@ pub struct OwnedProcessResponseParams { /// The origin's status, which decides whether the entries' middleware /// run on a body that is not HTML. See [`text_entry_media_type`]. pub(crate) status: StatusCode, + /// Whether the origin marked its response as one reader's, see + /// [`MiddlewareContext::origin_private`](crate::middleware::MiddlewareContext::origin_private). + pub(crate) origin_private: bool, pub(crate) ad_slots_script: Option, pub(crate) ad_bids_state: AdBidsState, /// Observation context for the in-flight auction. @@ -2535,7 +2509,9 @@ pub async fn buffer_publisher_response_async( settings, integration_registry, &mut response, - )?; + services, + ) + .await?; encode_complete_body(bytes, response_compression(&response))? } else { bytes @@ -2591,7 +2567,9 @@ pub async fn buffer_publisher_response_async( settings, integration_registry, &mut response, - )?; + services, + ) + .await?; let assembled = encode_complete_body(assembled, response_compression(&response))?; response.headers_mut().insert( http::header::CONTENT_LENGTH, @@ -2803,6 +2781,8 @@ fn build_template_assembly_params( request_path: String::new(), content_type: entry.metadata.content_type.clone(), status: StatusCode::OK, + // A stored template passed the gate that keeps one reader's page out. + origin_private: false, // The template already carries the head seam; re-injecting would duplicate it. permissions_json, ad_slots_script: None, @@ -3158,7 +3138,7 @@ pub async fn publisher_response_into_streaming_response( // Each piece goes through the serve entry as it leaves, so // the head is still written before the auction is awaited. let encoded_head = encoder - .encode_chunk(serve_chunk(&mut serve, head.to_vec(), false)?) + .encode_chunk(serve_chunk(&mut serve, head.to_vec())?) .map_err(publisher_stream_error)?; if !encoded_head.is_empty() { yield bytes::Bytes::from(encoded_head); @@ -3189,14 +3169,14 @@ pub async fn publisher_response_into_streaming_response( let seam = seam_script_for(¶ms); if !seam.is_empty() { let encoded_seam = encoder - .encode_chunk(serve_chunk(&mut serve, seam.into_bytes(), false)?) + .encode_chunk(serve_chunk(&mut serve, seam.into_bytes())?) .map_err(publisher_stream_error)?; if !encoded_seam.is_empty() { yield bytes::Bytes::from(encoded_seam); } } let encoded_tail = encoder - .encode_chunk(serve_chunk(&mut serve, tail.to_vec(), true)?) + .encode_chunk(serve_last_chunk(&mut serve, tail.to_vec(), &services).await?) .map_err(publisher_stream_error)?; if !encoded_tail.is_empty() { yield bytes::Bytes::from(encoded_tail); @@ -3410,9 +3390,14 @@ pub async fn publisher_response_into_streaming_response( yield encoded; } } - for encoded in - passthrough_finish_segments(&mut processor, &mut decoder, &mut encoder) - .map_err(publisher_stream_error)? + for encoded in passthrough_finish_segments( + &mut processor, + &mut decoder, + &mut encoder, + &services, + ) + .await + .map_err(publisher_stream_error)? { yield encoded; } @@ -3571,55 +3556,6 @@ impl Write for BoundedWriter { } } -/// Stream the publisher response body through the processing pipeline. -/// -/// Called by the adapter after `stream_to_client()` has committed the response -/// headers. Runs synchronously against an already-materialised body; the async -/// I/O happened upstream in [`handle_publisher_request`]. Writes processed -/// chunks directly to `output`. -/// -/// # Errors -/// -/// Returns an error if processing fails mid-stream. Since headers are already -/// committed, the caller should log the error and drop the `StreamingBody` -/// (client sees a truncated response). -pub fn stream_publisher_body( - body: EdgeBody, - output: &mut W, - params: &OwnedProcessResponseParams, - settings: &Settings, - integration_registry: &IntegrationRegistry, -) -> Result, Report> { - let permissions_script = permissions_script_for(params, settings); - let borrowed = ProcessResponseParams { - content_encoding: ¶ms.content_encoding, - origin_host: ¶ms.origin_host, - origin_url: ¶ms.origin_url, - request_host: ¶ms.request_host, - request_scheme: ¶ms.request_scheme, - request_path: ¶ms.request_path, - settings, - content_type: ¶ms.content_type, - status: params.status, - integration_registry, - permissions_script: permissions_script.as_deref(), - ad_slots_script: params.ad_slots_script.as_deref(), - ad_bids_state: params.ad_bids_state.script_cell(), - request_state: ¶ms.request_state, - shared_template_authorized: params.template_cache_key.is_some(), - csp_nonce_observed: params.csp_nonce_observed.as_ref(), - prepared: ¶ms.prepared, - public_prefix: ¶ms.public_prefix, - }; - let input_compression = Compression::from_content_encoding(¶ms.content_encoding); - let output_compression = if params.template_cache_key.is_some() { - Compression::None - } else { - input_compression - }; - process_response_streaming(body, output, &borrowed, output_compression) -} - /// Stream publisher body with parser-confirmed live bid injection. /// /// Drives the origin body through the HTML pipeline one chunk at a time. The @@ -3632,9 +3568,11 @@ pub fn stream_publisher_body( /// 3. The generated marker is replaced with the collected bid script. /// /// For non-HTML content types the auction is collected before any body bytes -/// are written (no `` to inject). If `params.dispatched_auction` is -/// `None` the function falls back to the synchronous -/// [`stream_publisher_body`] path. +/// are written (no `` to inject). Without a dispatched auction the body +/// takes the same chunk pipeline with no seam, whether it was read whole or +/// arrives as a stream. Every path ends the processor with its asynchronous +/// finish, so a middleware processor's asynchronous work completes before the +/// output is finalized. /// /// # Errors /// @@ -3650,19 +3588,15 @@ pub async fn stream_publisher_body_async( services: &RuntimeServices, ) -> Result, Report> { let Some(dispatched) = params.dispatched_auction.take() else { - if body.is_stream() { - return process_response_streaming_async( - body, - output, - params, - settings, - integration_registry, - ) - .await; - } - - // No auction and already-buffered body — keep the existing sync pipeline. - return stream_publisher_body(body, output, params, settings, integration_registry); + return process_response_streaming_async( + body, + output, + params, + settings, + integration_registry, + services, + ) + .await; }; let telemetry = AuctionTelemetryCarry { observation: params.auction_observation.take(), @@ -3683,17 +3617,15 @@ pub async fn stream_publisher_body_async( settings, ) .await; - if body.is_stream() { - return process_response_streaming_async( - body, - output, - params, - settings, - integration_registry, - ) - .await; - } - return stream_publisher_body(body, output, params, settings, integration_registry); + return process_response_streaming_async( + body, + output, + params, + settings, + integration_registry, + services, + ) + .await; } // HTML: let lol_html mark the structural body end for the auction seam. @@ -3708,6 +3640,8 @@ pub async fn stream_publisher_body_async( prepared: Arc::clone(¶ms.prepared), public_prefix: ¶ms.public_prefix, origin_host: ¶ms.origin_host, + status: params.status, + origin_private: params.origin_private, request_host: ¶ms.request_host, request_scheme: ¶ms.request_scheme, request_path: ¶ms.request_path, @@ -3913,6 +3847,8 @@ fn write_serve_middleware_headers( request_host: reader.request_host, request_scheme: reader.request_scheme, origin_host: reader.origin_host, + status: response.status(), + origin_private: reader.origin_private, platform: reader.platform, document_state: &document_state, max_buffered_script_bytes: reader.max_buffered_script_bytes, @@ -4848,11 +4784,12 @@ async fn body_close_hold_loop( loop { match reader.read(&mut buffer) { Ok(0) => { - let final_out = match processor.process_chunk(&[], true).change_context( - TrustedServerError::Proxy { + let final_out = match processor + .finish(&[], deps.services) + .await + .change_context(TrustedServerError::Proxy { message: "Failed to finalize processor".to_string(), - }, - ) { + }) { Ok(output) => output, Err(err) => { abandon_reader_auction( @@ -6121,6 +6058,7 @@ pub async fn handle_publisher_request( let document = TextResponse { content_type: crate::middleware::HTML_MEDIA_TYPE, status: StatusCode::OK, + origin_private: false, request_path: &request_path, request_host, request_scheme, @@ -6280,6 +6218,11 @@ pub async fn handle_publisher_request( response = empty_document(&entry.media_type)?; } + // Read before core writes its own cache policy on the response below, so a + // middleware is told what the origin declared. + let origin_private = response.headers().contains_key(header::SET_COOKIE) + || cache_control_forbids_shared_storage(response.headers()); + // Every serve middleware an entry names for this path is asked for the // headers it sets on the response that is sent, whatever it is, so a // redirect with no body reaches a middleware that rewrites its Location. @@ -6288,6 +6231,8 @@ pub async fn handle_publisher_request( request_host, request_scheme, origin_host: &origin_host_header, + status: response.status(), + origin_private, platform: integration_registry.platform(), request_state: &request_state, max_buffered_script_bytes: settings.publisher.max_buffered_body_bytes, @@ -6538,6 +6483,7 @@ pub async fn handle_publisher_request( let document = TextResponse { content_type: &content_type, status, + origin_private, request_path: &request_path, request_host, request_scheme, @@ -6677,6 +6623,7 @@ pub async fn handle_publisher_request( request_path: request_path.clone(), content_type, status, + origin_private, permissions_json, ad_slots_script: ad_slots_script.clone(), ad_bids_state: ad_bids_state.clone(), @@ -9821,6 +9768,26 @@ mod tests { use http::{Method, Request as HttpRequest, StatusCode, header}; use std::sync::Arc; + /// Runs the body pipeline with no auction over `body`, ending the + /// processor with the asynchronous finish on services with no timer, as a + /// buffered adapter does with a body it read whole. + fn stream_publisher_body( + body: EdgeBody, + output: &mut W, + params: &OwnedProcessResponseParams, + settings: &Settings, + integration_registry: &IntegrationRegistry, + ) -> Result, Report> { + futures::executor::block_on(process_response_streaming_async( + body, + output, + params, + settings, + integration_registry, + &noop_services(), + )) + } + /// A resolved permission state as page JSON, for tests that need a payload /// with something in it rather than the empty state. /// @@ -11368,6 +11335,7 @@ mod tests { request_path: "/".to_owned(), content_type: "application/json".to_owned(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -11828,6 +11796,8 @@ mod tests { csp_nonce_observed: None, public_prefix: None, prepared: Arc::new(Prepared::default()), + status: StatusCode::OK, + origin_private: false, origin_host: "origin.example.com".to_string(), request_host: "example.com".to_string(), request_scheme: "https".to_string(), @@ -23795,6 +23765,7 @@ mod tests { request_path: "/a.txt".to_owned(), content_type: content_type.to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -23911,6 +23882,7 @@ mod tests { request_path: "/a.txt".to_owned(), content_type: fixture::FRAMED_MEDIA_TYPE.to_string(), status: StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -23980,6 +23952,7 @@ mod tests { request_path: "/a.txt".to_owned(), content_type: "text/plain; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -24035,6 +24008,7 @@ mod tests { request_path: "/a.txt".to_owned(), content_type: content_type.to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -26760,6 +26734,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/css".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -26826,6 +26801,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -26913,6 +26889,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27009,6 +26986,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27120,6 +27098,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27172,6 +27151,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27304,6 +27284,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/css".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27374,6 +27355,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/css".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27447,6 +27429,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/css".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27520,6 +27503,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/css".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27593,6 +27577,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/css".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27646,6 +27631,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/css".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -27875,6 +27861,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: Some( r#""# @@ -27956,6 +27943,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: Some( r#""# @@ -28039,6 +28027,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/css".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -28116,6 +28105,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/css".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -28275,6 +28265,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: Some( r#""# @@ -28955,6 +28946,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -29156,6 +29148,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: Some( r#""# @@ -29241,6 +29234,7 @@ mod tests { request_path: "/".to_owned(), content_type: "Text/HTML; Charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: Some( r#""# @@ -29309,6 +29303,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -29432,6 +29427,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html; charset=utf-8".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), @@ -29495,6 +29491,7 @@ mod tests { request_path: "/".to_owned(), content_type: "text/html".to_string(), status: http::StatusCode::OK, + origin_private: false, permissions_json: String::new(), ad_slots_script: None, ad_bids_state: AdBidsState::default(), diff --git a/crates/trusted-server-core/src/streaming_processor.rs b/crates/trusted-server-core/src/streaming_processor.rs index 4c751ebdcd..1b379ec8c3 100644 --- a/crates/trusted-server-core/src/streaming_processor.rs +++ b/crates/trusted-server-core/src/streaming_processor.rs @@ -54,7 +54,7 @@ pub trait StreamProcessor { /// Reset the processor state (useful for reuse) fn reset(&mut self) {} - /// Start an optional asynchronous finish of this processor's final output. + /// Starts an optional asynchronous finish of this processor's final output. /// /// `held` is exactly what the final [`process_chunk`](Self::process_chunk) /// call (`is_last = true`) returned: the bytes the processor emits when it @@ -62,15 +62,16 @@ pub trait StreamProcessor { /// once, right after that final call, with the request's /// [`RuntimeServices`]. /// - /// Returning `None` (the default) emits `held` unchanged; no future is - /// created. Returning a finish makes the driver await it and emit its + /// Returning `None`, the default, emits `held` unchanged and creates no + /// future. Returning a finish makes the driver await it and emit its /// output in place of `held`. The output is infallible: a processor absorbs /// its own failures and returns the bytes to emit, typically `held` itself. /// On a runtime with a [`PlatformTimer`](crate::platform::PlatformTimer), /// the driver drops the finish once the deadline given to /// [`AsyncFinish::new`] elapses and emits `held` instead. /// - /// Only drivers that can await call this. A synchronous driver, such as + /// Only a driver that can await calls this, through + /// [`finish`](Self::finish). A synchronous driver, such as /// [`StreamingPipeline`], emits `held`. fn async_finish<'a>( &'a mut self, @@ -80,6 +81,30 @@ pub trait StreamProcessor { let _ = (held, services); None } + + /// Ends the document with `chunk` as its last input, awaiting any + /// asynchronous finish, for a driver that can await. Called once, in place + /// of `process_chunk(chunk, true)`. + /// + /// The default processes `chunk` as the last chunk and runs + /// [`async_finish`](Self::async_finish) over what that returned. A + /// processor made of others ends each of them in order with their own + /// `finish`, so each is handed what the one before it finished with, and + /// none is handed its last chunk before the one before it has finished. + /// + /// # Errors + /// + /// Returns an error if processing the last chunk fails. + fn finish<'a>( + &'a mut self, + chunk: &'a [u8], + services: &'a RuntimeServices, + ) -> LocalBoxFuture<'a, Result, io::Error>> { + Box::pin(async move { + let held = self.process_chunk(chunk, true)?; + Ok(finish_held(self, held, services).await) + }) + } } /// Asynchronous work a [`StreamProcessor`] completes after its final chunk. @@ -92,7 +117,7 @@ pub struct AsyncFinish<'a> { } impl<'a> AsyncFinish<'a> { - /// Wrap `output`, which resolves to the bytes to emit, with the time the + /// Wraps `output`, which resolves to the bytes to emit, with the time the /// driver may wait for it. /// /// `deadline` is measured from when the driver starts awaiting the finish. @@ -106,14 +131,15 @@ impl<'a> AsyncFinish<'a> { } } -/// Run `processor`'s optional asynchronous finish over `held`, its final output. +/// Runs `processor`'s optional asynchronous finish over `held`, its final +/// output. /// /// Returns the finish output, or `held` when the processor has no finish. When /// `services` has a timer, the finish races its deadline; on expiry the finish /// future is dropped and `held` is returned. Without a timer the finish is /// awaited to completion. -pub(crate) async fn finish_held( - processor: &mut dyn StreamProcessor, +async fn finish_held( + processor: &mut P, held: Vec, services: &RuntimeServices, ) -> Vec { @@ -130,7 +156,7 @@ pub(crate) async fn finish_held( } }; log::warn!( - "stream processor finish exceeded its {} ms deadline; emitting its held output", + "A stream processor's finish ran past its {} ms deadline, so its held output is emitted", deadline.as_millis() ); held From 6ddcfaf2a936d50d25fa1c5f3c46548e5709ee4d Mon Sep 17 00:00:00 2001 From: Erik Svilich Date: Sat, 24 Oct 2026 16:09:37 +0000 Subject: [PATCH 04/17] Wire Axum runtime KV from TRUSTED_SERVER_KV_PATH --- Cargo.lock | 3 + crates/trusted-server-adapter-axum/Cargo.toml | 3 + .../trusted-server-adapter-axum/src/main.rs | 19 +- .../src/platform.rs | 112 ++++++++-- .../tests/runtime_kv.rs | 205 ++++++++++++++++++ docs/guide/axum-dev.md | 10 + 6 files changed, 334 insertions(+), 18 deletions(-) create mode 100644 crates/trusted-server-adapter-axum/tests/runtime_kv.rs diff --git a/Cargo.lock b/Cargo.lock index 89550f2f36..90fda973c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6594,9 +6594,11 @@ dependencies = [ name = "trusted-server-adapter-axum" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", "axum", "base64", + "bytes", "edgezero-adapter-axum", "edgezero-core", "error-stack", @@ -6606,6 +6608,7 @@ dependencies = [ "serde_json", "simple_logger", "temp-env", + "tempfile", "tokio", "tower 0.4.13", "trusted-server-ad-server-iabtl", diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 47604fc941..02ca020e30 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -19,7 +19,9 @@ name = "trusted-server-axum" path = "src/main.rs" [dependencies] +anyhow = { workspace = true } async-trait = { workspace = true } +bytes = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } edgezero-core = { workspace = true } error-stack = { workspace = true } @@ -64,6 +66,7 @@ axum = { workspace = true } base64 = { workspace = true } serde_json = { workspace = true } temp-env = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tower = { workspace = true, features = ["util"] } # Test fixture only. It proves the integration seam from outside core and must diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 66d592e0b7..0a88091dda 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,7 +1,10 @@ +use std::path::Path; + use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; use edgezero_core::app::Hooks as _; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::platform::{KV_PATH_ENV, open_kv_handle}; /// Environment variable that sets the maximum log level. const LOG_LEVEL_ENV: &str = "RUST_LOG"; @@ -39,7 +42,21 @@ fn main() { log::info!("Listening on http://{}", config.addr); let router = TrustedServerApp::routes(); - if let Err(err) = AxumDevServer::with_config(router, config).run() { + let mut server = AxumDevServer::with_config(router, config); + if let Some(path) = std::env::var_os(KV_PATH_ENV).filter(|path| !path.is_empty()) { + // Construct the store once so every request shares one handle. + match open_kv_handle(Path::new(&path)) { + Ok(handle) => { + log::info!("Runtime KV: {}", Path::new(&path).display()); + server = server.with_kv_handle(handle); + } + Err(err) => { + log::error!("{KV_PATH_ENV} is set but the KV store cannot be opened: {err}"); + std::process::exit(1); + } + } + } + if let Err(err) = server.run() { log::error!("trusted-server-adapter-axum failed: {err}"); std::process::exit(1); } diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 8d0edfdc67..86c3ecbf02 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -1,18 +1,23 @@ use std::future::Future; use std::net::IpAddr; +use std::path::Path; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Duration; use async_trait::async_trait; +use bytes::Bytes; +use edgezero_adapter_axum::key_value_store::PersistentKvStore; use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; +use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; use error_stack::{Report, ResultExt as _}; use trusted_server_core::platform::{ - AuctionTargetCapabilities, AuctionTargetDescriptor, ClientInfo, GeoInfo, PlatformBackend, - PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, - PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, - PlatformSelectResult, RuntimeServices, StoreId, StoreName, + AuctionTargetCapabilities, AuctionTargetDescriptor, ClientInfo, GeoInfo, KvError, + PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, + PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, + StoreName, UnavailableKvStore, }; // --------------------------------------------------------------------------- @@ -563,17 +568,85 @@ impl PlatformHttpClient for AxumPlatformHttpClient { } } +// --------------------------------------------------------------------------- +// Runtime KV +// --------------------------------------------------------------------------- + +/// Environment variable naming the file that backs runtime KV. +/// +/// When set at startup, the dev server persists runtime KV in that file. When +/// unset, runtime KV is unavailable. +pub const KV_PATH_ENV: &str = "TRUSTED_SERVER_KV_PATH"; + +/// Open the persistent runtime KV store at `path`, creating missing parent +/// directories. +/// +/// # Errors +/// +/// Returns an error when the directory cannot be created or the database +/// cannot be opened (corrupted file, locked by another process). +pub fn open_kv_handle(path: &Path) -> Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent).map_err(|err| { + KvError::Internal(anyhow::anyhow!( + "failed to create KV directory `{}`: {err}", + parent.display() + )) + })?; + } + Ok(KvHandle::new(Arc::new(PersistentKvStore::new(path)?))) +} + +/// Bridges edgezero's [`KvHandle`] to [`PlatformKvStore`]. +struct KvHandleAdapter(KvHandle); + +#[async_trait(?Send)] +impl KvStore for KvHandleAdapter { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + self.0.get_bytes(key).await + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.0.put_bytes(key, value).await + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + self.0.put_bytes_with_ttl(key, value, ttl).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.0.delete(key).await + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + self.0.list_keys_page(prefix, cursor, limit).await + } +} + // --------------------------------------------------------------------------- // build_runtime_services // --------------------------------------------------------------------------- /// Construct [`RuntimeServices`] for an incoming Axum request. /// -/// # Degraded features in dev +/// # Runtime KV /// -/// The generic runtime KV slot uses -/// [`trusted_server_core::platform::UnavailableKvStore`]. A `warn` log is -/// emitted once per process. +/// Runtime KV comes from the KV handle the dev server injects into each +/// request, which `main` builds when [`KV_PATH_ENV`] is set. Without it the +/// slot is [`UnavailableKvStore`] and a `warn` log is emitted once per process. pub fn build_runtime_services( ctx: &edgezero_core::context::RequestContext, settings: &trusted_server_core::settings::Settings, @@ -581,17 +654,25 @@ pub fn build_runtime_services( [Arc], >, ) -> RuntimeServices { - static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); - KV_WARNED.get_or_init(|| { - log::warn!("Axum dev server: generic runtime KV is unavailable (UnavailableKvStore)."); - }); + let kv_store: Arc = match ctx.kv_store_default() { + Some(handle) => Arc::new(KvHandleAdapter(handle)), + None => { + static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + KV_WARNED.get_or_init(|| { + log::warn!( + "Axum dev server: generic runtime KV is unavailable; set {KV_PATH_ENV} to enable it." + ); + }); + Arc::new(UnavailableKvStore) + } + }; let client_ip = edgezero_adapter_axum::context::AxumRequestContext::get(ctx.request()) .and_then(|c| c.remote_addr) .map(|addr| addr.ip()); use trusted_server_core::platform::{ - PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformKvStore, PlatformSecretStore, + PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore, }; // Stateless shims are promoted to process-wide statics so callers clone @@ -600,7 +681,6 @@ pub fn build_runtime_services( std::sync::OnceLock::new(); static SECRET_STORE: std::sync::OnceLock> = std::sync::OnceLock::new(); - static KV_STORE: std::sync::OnceLock> = std::sync::OnceLock::new(); static BACKEND: std::sync::OnceLock> = std::sync::OnceLock::new(); static GEO: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -611,9 +691,7 @@ pub fn build_runtime_services( .secret_store(Arc::clone(SECRET_STORE.get_or_init(|| { Arc::new(AxumPlatformSecretStore) as Arc }))) - .kv_store(Arc::clone(KV_STORE.get_or_init(|| { - Arc::new(trusted_server_core::platform::UnavailableKvStore) as Arc - }))) + .kv_store(kv_store) .backend(Arc::clone(BACKEND.get_or_init(|| { Arc::new(AxumPlatformBackend) as Arc }))) diff --git a/crates/trusted-server-adapter-axum/tests/runtime_kv.rs b/crates/trusted-server-adapter-axum/tests/runtime_kv.rs new file mode 100644 index 0000000000..9be72ff12d --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/runtime_kv.rs @@ -0,0 +1,205 @@ +//! Runtime KV wiring for the Axum dev server. +//! +//! Each test builds the KV handle exactly as `main` does (`open_kv_handle` on +//! the `TRUSTED_SERVER_KV_PATH` value) and attaches it with +//! `EdgeZeroAxumService::with_kv_handle`, the same call the dev server makes. +//! A probe route reads and writes through `build_runtime_services`, so the +//! adapter's own request-to-`RuntimeServices` path is what is exercised. + +use axum::body::{Body as AxumBody, to_bytes}; +use axum::http::Request; +use bytes::Bytes; +use edgezero_adapter_axum::service::EdgeZeroAxumService; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::http::{HandlerFuture, StatusCode, response_builder}; +use edgezero_core::router::RouterService; +use tower::{Service as _, ServiceExt as _}; +use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::platform::{build_runtime_services, open_kv_handle}; + +const PROBE_KEY: &str = "probe-key"; + +fn reply( + status: StatusCode, + body: Body, +) -> Result { + Ok(response_builder() + .status(status) + .body(body) + .expect("should build probe response")) +} + +/// The services the request path builds for `ctx`, with default settings and +/// no permission signal module. +fn services_for(ctx: &RequestContext) -> trusted_server_core::platform::RuntimeServices { + build_runtime_services( + ctx, + &trusted_server_core::settings::Settings::default(), + &std::sync::Arc::default(), + ) +} + +fn probe_router() -> RouterService { + RouterService::builder() + .put("/probe", |ctx: RequestContext| -> HandlerFuture { + Box::pin(async move { + let services = services_for(&ctx); + match services + .kv_store() + .put_bytes(PROBE_KEY, Bytes::from_static(b"stored")) + .await + { + Ok(()) => reply(StatusCode::NO_CONTENT, Body::empty()), + Err(_) => reply(StatusCode::SERVICE_UNAVAILABLE, Body::empty()), + } + }) + }) + .get("/probe", |ctx: RequestContext| -> HandlerFuture { + Box::pin(async move { + let services = services_for(&ctx); + match services.kv_store().get_bytes(PROBE_KEY).await { + Ok(Some(value)) => reply(StatusCode::OK, Body::from(value.to_vec())), + Ok(None) => reply(StatusCode::NOT_FOUND, Body::empty()), + Err(_) => reply(StatusCode::SERVICE_UNAVAILABLE, Body::empty()), + } + }) + }) + .build() +} + +async fn call(service: &mut EdgeZeroAxumService, method: &str) -> (u16, Bytes) { + let request = Request::builder() + .method(method) + .uri("/probe") + .body(AxumBody::empty()) + .expect("should build probe request"); + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should serve probe request"); + let status = response.status().as_u16(); + let body = to_bytes(response.into_body(), 1024) + .await + .expect("should read probe body"); + (status, body) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn runtime_kv_persists_across_requests_and_restarts() { + let dir = tempfile::tempdir().expect("should create temp dir"); + let path = dir.path().join("nested").join("kv.redb"); + + { + let handle = open_kv_handle(&path).expect("should open KV and create its directory"); + let mut service = EdgeZeroAxumService::new(probe_router()).with_kv_handle(handle); + assert_eq!(call(&mut service, "PUT").await.0, 204, "should write"); + let (status, body) = call(&mut service, "GET").await; + assert_eq!(status, 200, "a second request should see the write"); + assert_eq!(body, Bytes::from_static(b"stored")); + } + + let handle = open_kv_handle(&path).expect("should reopen the same KV file"); + let mut restarted = EdgeZeroAxumService::new(probe_router()).with_kv_handle(handle); + let (status, body) = call(&mut restarted, "GET").await; + assert_eq!(status, 200, "data should survive a process restart"); + assert_eq!(body, Bytes::from_static(b"stored")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn runtime_kv_is_isolated_between_server_instances() { + let dir = tempfile::tempdir().expect("should create temp dir"); + let mut first = EdgeZeroAxumService::new(probe_router()) + .with_kv_handle(open_kv_handle(&dir.path().join("first.redb")).expect("should open")); + let mut second = EdgeZeroAxumService::new(probe_router()) + .with_kv_handle(open_kv_handle(&dir.path().join("second.redb")).expect("should open")); + + assert_eq!( + call(&mut first, "PUT").await.0, + 204, + "should write to first" + ); + + assert_eq!( + call(&mut second, "GET").await.0, + 404, + "a write to one instance must not be visible to another" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn runtime_kv_is_unavailable_without_a_configured_path() { + let mut service = EdgeZeroAxumService::new(probe_router()); + + assert_eq!( + call(&mut service, "GET").await.0, + 503, + "with no KV handle injected, KV should report unavailable" + ); +} + +#[test] +fn open_kv_handle_rejects_a_path_that_cannot_be_created() { + let dir = tempfile::tempdir().expect("should create temp dir"); + let blocker = dir.path().join("file"); + std::fs::write(&blocker, b"not a directory").expect("should write blocker file"); + + let result = open_kv_handle(&blocker.join("kv.redb")); + + assert!(result.is_err(), "a path under a regular file should fail"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn application_serves_requests_with_and_without_runtime_kv() { + let settings = trusted_server_core::settings::Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "integration-test-proxy-secret" + + [ec] + passphrase = "test-secret-key-32-bytes-minimum" + "#, + ) + .expect("should parse settings"); + let dir = tempfile::tempdir().expect("should create temp dir"); + let handle = open_kv_handle(&dir.path().join("kv.redb")).expect("should open KV"); + + for kv in [None, Some(handle)] { + let router = + TrustedServerApp::routes_with_settings(settings.clone()).expect("should build router"); + let mut service = EdgeZeroAxumService::new(router); + if let Some(handle) = kv { + service = service.with_kv_handle(handle); + } + let request = Request::builder() + .method("POST") + .uri("/_ts/admin/keys/rotate") + .body(AxumBody::empty()) + .expect("should build request"); + + let response = service + .ready() + .await + .expect("should be ready") + .call(request) + .await + .expect("should serve request"); + + assert_eq!( + response.status().as_u16(), + 401, + "the app should answer the same with or without runtime KV" + ); + } +} diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index 73988b96b8..d7ce5d8802 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -59,6 +59,16 @@ its `ext.debug` echo of the resolved request) when the `[demand.]` table of a Prebid Server demand source sets `debug = true`. Enable it only for short local debugging sessions. +## Runtime KV + +Generic runtime KV (`RuntimeServices::kv_store`) is off by default. Set +`TRUSTED_SERVER_KV_PATH` to a file path before starting the server and the dev +server opens one persistent store at that path, creating missing directories, +and shares it across all requests. Data survives restarts; two servers pointed +at different paths do not see each other's data. If the variable is set but the +store cannot be opened, the server exits with an error. When it is unset, +runtime KV reports unavailable and a warning is logged once. + ## Runtime boundaries Axum sanitizes forwarded client-IP headers but does not resolve From 47afad3562346c7e726cbaa0c62601b2da788f09 Mon Sep 17 00:00:00 2001 From: Erik Svilich Date: Sat, 24 Oct 2026 16:09:55 +0000 Subject: [PATCH 05/17] Run Axum runtime KV operations on the blocking pool --- .../src/platform.rs | 166 +++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 86c3ecbf02..0afbe89000 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -597,7 +597,88 @@ pub fn open_kv_handle(path: &Path) -> Result { )) })?; } - Ok(KvHandle::new(Arc::new(PersistentKvStore::new(path)?))) + Ok(KvHandle::new(Arc::new(BlockingPoolKvStore(Arc::new( + PersistentKvStore::new(path)?, + ))))) +} + +/// Runs each operation of a synchronous [`KvStore`] on Tokio's blocking pool. +/// +/// [`PersistentKvStore`] does its `redb` transactions inside its `async fn`s +/// without yielding, so a slow commit would hold the request task and keep +/// any timer raced against it (an integration deadline, for example) from +/// firing. Here the request task only awaits the blocking task and stays +/// free to observe its deadline. +/// +/// A caller that stops waiting drops only the join handle: the operation +/// keeps running to its end in the background. That is safe for `redb`. A +/// write either commits atomically or fails and its transaction aborts on +/// drop, and `redb` admits one write transaction at a time, so a later +/// operation waits for the abandoned one instead of interleaving with it. +/// Dropping the Tokio runtime waits for running blocking tasks, so a commit in +/// flight is not cut short when the server stops. +struct BlockingPoolKvStore(Arc); + +impl BlockingPoolKvStore { + async fn run(&self, op: F) -> Result + where + T: Send + 'static, + F: FnOnce(Arc) -> Fut + Send + 'static, + Fut: Future>, + { + let store = Arc::clone(&self.0); + tokio::task::spawn_blocking(move || futures::executor::block_on(op(store))) + .await + .map_err(|err| KvError::Internal(anyhow::anyhow!("KV task failed: {err}")))? + } +} + +#[async_trait(?Send)] +impl KvStore for BlockingPoolKvStore { + async fn delete(&self, key: &str) -> Result<(), KvError> { + let key = key.to_owned(); + self.run(move |store| async move { store.delete(&key).await }) + .await + } + + async fn get_bytes(&self, key: &str) -> Result, KvError> { + let key = key.to_owned(); + self.run(move |store| async move { store.get_bytes(&key).await }) + .await + } + + async fn list_keys_page( + &self, + prefix: &str, + cursor: Option<&str>, + limit: usize, + ) -> Result { + let prefix = prefix.to_owned(); + let cursor = cursor.map(str::to_owned); + self.run(move |store| async move { + store + .list_keys_page(&prefix, cursor.as_deref(), limit) + .await + }) + .await + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + let key = key.to_owned(); + self.run(move |store| async move { store.put_bytes(&key, value).await }) + .await + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + let key = key.to_owned(); + self.run(move |store| async move { store.put_bytes_with_ttl(&key, value, ttl).await }) + .await + } } /// Bridges edgezero's [`KvHandle`] to [`PlatformKvStore`]. @@ -1072,6 +1153,89 @@ mod tests { ); } + /// Stands in for a slow `redb` commit: `put_bytes` blocks its thread + /// until the test releases it. + struct GatedStore { + gate: std::sync::Mutex>, + finished: std::sync::atomic::AtomicBool, + } + + #[async_trait(?Send)] + impl KvStore for GatedStore { + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Err(KvError::Unavailable) + } + + async fn get_bytes(&self, _key: &str) -> Result, KvError> { + Err(KvError::Unavailable) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Err(KvError::Unavailable) + } + + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + let _ = self + .gate + .lock() + .expect("should lock gate") + .recv_timeout(Duration::from_secs(5)); + self.finished + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + Err(KvError::Unavailable) + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn blocking_kv_op_does_not_delay_a_racing_deadline() { + let (release, gate) = std::sync::mpsc::channel(); + let store = Arc::new(GatedStore { + gate: std::sync::Mutex::new(gate), + finished: std::sync::atomic::AtomicBool::new(false), + }); + let handle = KvHandle::new(Arc::new(BlockingPoolKvStore(Arc::clone(&store)))); + + let started = std::time::Instant::now(); + let raced = tokio::time::timeout( + Duration::from_millis(50), + handle.put_bytes("key", Bytes::from_static(b"value")), + ) + .await; + let elapsed = started.elapsed(); + + assert!( + raced.is_err(), + "the deadline should fire while the store op is still blocked" + ); + assert!( + elapsed < Duration::from_secs(2), + "the deadline should fire on time, fired after {elapsed:?}" + ); + + release.send(()).expect("should release the blocked op"); + tokio::time::timeout(Duration::from_secs(5), async { + while !store.finished.load(std::sync::atomic::Ordering::SeqCst) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the abandoned op should still run to completion"); + } + async fn serve_raw_response(response: &'static [u8]) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await From d5f250fbdcb25e07d719028f5f7c80341aa943d8 Mon Sep 17 00:00:00 2001 From: Erik Svilich Date: Sat, 24 Oct 2026 16:10:32 +0000 Subject: [PATCH 06/17] Provide platform timers on Cloudflare, Spin and Axum --- .../src/platform.rs | 39 ++++++++++++++++++- .../src/platform.rs | 34 +++++++++++++++- .../src/platform.rs | 31 ++++++++++++++- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 0afbe89000..b97eeef211 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -16,8 +16,8 @@ use trusted_server_core::platform::{ AuctionTargetCapabilities, AuctionTargetDescriptor, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, - PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, - StoreName, UnavailableKvStore, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, PlatformTimer, RuntimeServices, + StoreId, StoreName, UnavailableKvStore, }; // --------------------------------------------------------------------------- @@ -764,6 +764,7 @@ pub fn build_runtime_services( std::sync::OnceLock::new(); static BACKEND: std::sync::OnceLock> = std::sync::OnceLock::new(); static GEO: std::sync::OnceLock> = std::sync::OnceLock::new(); + static TIMER: std::sync::OnceLock> = std::sync::OnceLock::new(); RuntimeServices::builder() .config_store(Arc::clone(CONFIG_STORE.get_or_init(|| { @@ -791,6 +792,9 @@ pub fn build_runtime_services( // crates this adapter links, so every request asks exactly the ones // configuration named, in that order. .permission_signal_modules(Arc::clone(permission_signal_modules)) + .timer(Some(Arc::clone(TIMER.get_or_init(|| { + Arc::new(AxumPlatformTimer) as Arc + })))) .client_info(ClientInfo { client_ip, tls_protocol: None, @@ -800,6 +804,20 @@ pub fn build_runtime_services( .build() } +// --------------------------------------------------------------------------- +// AxumPlatformTimer +// --------------------------------------------------------------------------- + +/// Wall-clock timer backed by the tokio runtime's time driver. +pub struct AxumPlatformTimer; + +#[async_trait(?Send)] +impl PlatformTimer for AxumPlatformTimer { + async fn sleep(&self, duration: Duration) { + tokio::time::sleep(duration).await; + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -850,6 +868,23 @@ mod tests { ); } + #[tokio::test] + async fn runtime_timer_releases_work_that_never_completes() { + let services = test_services(); + let timer = services.timer().expect("should provide a runtime timer"); + + let raced = futures::future::select( + Box::pin(futures::future::pending::<()>()), + timer.sleep(Duration::from_millis(5)), + ) + .await; + + assert!( + matches!(raced, futures::future::Either::Right(_)), + "should fire while the other future stays pending" + ); + } + #[test] fn the_backend_name_and_transport_timer_are_pinned() { let backend = AxumPlatformBackend; diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 8cbb0a93c0..d8c387ec02 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -11,8 +11,8 @@ use error_stack::ResultExt as _; use trusted_server_core::platform::{ AuctionTargetCapabilities, AuctionTargetDescriptor, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, - PlatformHttpClient, PlatformKvStore, PlatformSecretStore, RuntimeServices, StoreId, StoreName, - UnavailableKvStore, + PlatformHttpClient, PlatformKvStore, PlatformSecretStore, PlatformTimer, RuntimeServices, + StoreId, StoreName, UnavailableKvStore, }; #[cfg(not(target_arch = "wasm32"))] @@ -614,6 +614,35 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { } } +// --------------------------------------------------------------------------- +// Timer — wasm32 only (Workers `setTimeout`) +// --------------------------------------------------------------------------- + +/// Wall-clock timer backed by the Workers `setTimeout`. +/// +/// workers-rs [`worker::Delay`] clears its timeout when dropped, so a +/// cancelled sleep leaves no timer behind in the isolate. +#[cfg(target_arch = "wasm32")] +struct CloudflareTimer; + +#[cfg(target_arch = "wasm32")] +#[async_trait::async_trait(?Send)] +impl PlatformTimer for CloudflareTimer { + async fn sleep(&self, duration: Duration) { + worker::Delay::from(duration).await; + } +} + +/// The Workers timer on wasm32; none on the native host, which has no +/// Workers event loop. +fn runtime_timer() -> Option> { + #[cfg(target_arch = "wasm32")] + let timer: Option> = Some(Arc::new(CloudflareTimer)); + #[cfg(not(target_arch = "wasm32"))] + let timer: Option> = None; + timer +} + // --------------------------------------------------------------------------- // build_runtime_services // --------------------------------------------------------------------------- @@ -693,6 +722,7 @@ pub fn build_runtime_services( // crates this adapter links, so every request asks exactly the ones // configuration named, in that order. .permission_signal_modules(Arc::clone(permission_signal_modules)) + .timer(runtime_timer()) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 9a3a160a36..f9efe3f96d 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -13,8 +13,8 @@ use http_body_util::BodyExt as _; use trusted_server_core::platform::{ AuctionTargetCapabilities, AuctionTargetDescriptor, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, - PlatformHttpClient, PlatformKvStore, PlatformSecretStore, RuntimeServices, StoreId, StoreName, - UnavailableKvStore, + PlatformHttpClient, PlatformKvStore, PlatformSecretStore, PlatformTimer, RuntimeServices, + StoreId, StoreName, UnavailableKvStore, }; #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] @@ -757,6 +757,32 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { } } +// --------------------------------------------------------------------------- +// Timer — Spin wasm32 only (WASI monotonic clock) +// --------------------------------------------------------------------------- + +/// Wall-clock timer backed by the WASI monotonic clock through +/// [`spin_sdk::time::sleep`]. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +struct SpinPlatformTimer; + +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +#[async_trait::async_trait(?Send)] +impl PlatformTimer for SpinPlatformTimer { + async fn sleep(&self, duration: Duration) { + spin_sdk::time::sleep(duration).await; + } +} + +/// The WASI timer in the Spin component; none on the native host. +fn runtime_timer() -> Option> { + #[cfg(all(feature = "spin", target_arch = "wasm32"))] + let timer: Option> = Some(Arc::new(SpinPlatformTimer)); + #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] + let timer: Option> = None; + timer +} + // --------------------------------------------------------------------------- // build_runtime_services // --------------------------------------------------------------------------- @@ -816,6 +842,7 @@ pub fn build_runtime_services( // crates this adapter links, so every request asks exactly the ones // configuration named, in that order. .permission_signal_modules(Arc::clone(permission_signal_modules)) + .timer(runtime_timer()) .client_info(ClientInfo { client_ip, tls_protocol: None, From a0eb046ce90bf0693146668ccb14c600963f293d Mon Sep 17 00:00:00 2001 From: Erik Svilich Date: Sat, 24 Oct 2026 16:18:51 +0000 Subject: [PATCH 07/17] Move Encypher text provenance into the provenance.encypher module crate The Encypher signing integration becomes a serve middleware in crates/provenance/encypher, selected by [provenance] module = "encypher" and placed by a [[serve]] entry. Its preparation applies the request gates with no I/O, and its stream processor signs the article in the asynchronous finish core now awaits. The worker token is a module secret setting. The enabled flag and path_prefixes give way to the selection and the entry's path, and the module is offered on every adapter and the CLI. --- Cargo.lock | 26 + Cargo.toml | 2 + crates/provenance/encypher/Cargo.toml | 39 + crates/provenance/encypher/src/api.rs | 303 ++ crates/provenance/encypher/src/boundary.rs | 272 ++ crates/provenance/encypher/src/embed.rs | 477 ++++ .../encypher/src/fixtures/parity.json | 848 ++++++ crates/provenance/encypher/src/lib.rs | 346 +++ crates/provenance/encypher/src/processor.rs | 2520 +++++++++++++++++ crates/provenance/encypher/src/tests.rs | 399 +++ crates/provenance/encypher/src/text.rs | 500 ++++ crates/trusted-server-adapter-axum/Cargo.toml | 1 + crates/trusted-server-adapter-axum/src/app.rs | 1 + .../Cargo.toml | 1 + .../src/app.rs | 1 + .../trusted-server-adapter-fastly/Cargo.toml | 1 + .../trusted-server-adapter-fastly/src/app.rs | 1 + crates/trusted-server-adapter-spin/Cargo.toml | 1 + crates/trusted-server-adapter-spin/src/app.rs | 1 + crates/trusted-server-cli/Cargo.toml | 1 + crates/trusted-server-cli/src/app_config.rs | 1 + crates/trusted-server-core/src/publisher.rs | 93 +- 22 files changed, 5830 insertions(+), 5 deletions(-) create mode 100644 crates/provenance/encypher/Cargo.toml create mode 100644 crates/provenance/encypher/src/api.rs create mode 100644 crates/provenance/encypher/src/boundary.rs create mode 100644 crates/provenance/encypher/src/embed.rs create mode 100644 crates/provenance/encypher/src/fixtures/parity.json create mode 100644 crates/provenance/encypher/src/lib.rs create mode 100644 crates/provenance/encypher/src/processor.rs create mode 100644 crates/provenance/encypher/src/tests.rs create mode 100644 crates/provenance/encypher/src/text.rs diff --git a/Cargo.lock b/Cargo.lock index 90fda973c2..a9586ea2e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6636,6 +6636,7 @@ dependencies = [ "trusted-server-permission-signal-mtm", "trusted-server-permission-signal-tcf", "trusted-server-permission-signal-us-privacy", + "trusted-server-provenance-encypher", "trusted-server-shared-aws", "trusted-server-tag-google-tag", "trusted-server-tag-google-tag-manager", @@ -6686,6 +6687,7 @@ dependencies = [ "trusted-server-permission-signal-mtm", "trusted-server-permission-signal-tcf", "trusted-server-permission-signal-us-privacy", + "trusted-server-provenance-encypher", "trusted-server-shared-aws", "trusted-server-tag-google-tag", "trusted-server-tag-google-tag-manager", @@ -6742,6 +6744,7 @@ dependencies = [ "trusted-server-permission-signal-mtm", "trusted-server-permission-signal-tcf", "trusted-server-permission-signal-us-privacy", + "trusted-server-provenance-encypher", "trusted-server-shared-aws", "trusted-server-tag-google-tag", "trusted-server-tag-google-tag-manager", @@ -6794,6 +6797,7 @@ dependencies = [ "trusted-server-permission-signal-mtm", "trusted-server-permission-signal-tcf", "trusted-server-permission-signal-us-privacy", + "trusted-server-provenance-encypher", "trusted-server-shared-aws", "trusted-server-tag-google-tag", "trusted-server-tag-google-tag-manager", @@ -7020,6 +7024,7 @@ dependencies = [ "trusted-server-identity-lockr", "trusted-server-loading-cache-51degrees", "trusted-server-middleware-51degrees", + "trusted-server-provenance-encypher", "trusted-server-shared-aws", "trusted-server-tag-google-tag", "trusted-server-tag-google-tag-manager", @@ -7352,6 +7357,27 @@ dependencies = [ "trusted-server-permission-signal-gpc", ] +[[package]] +name = "trusted-server-provenance-encypher" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "edgezero-core", + "error-stack", + "futures", + "http", + "log", + "regex", + "serde", + "serde_json", + "sha2 0.10.9", + "trusted-server-core", + "url", + "validator", + "web-time", +] + [[package]] name = "trusted-server-shared-51degrees" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4f1e312212..1f27b93efb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/ads-txt/iabtl", "crates/analytics/tinybird", "crates/headers/geo", + "crates/provenance/encypher", "crates/loading-cache/51degrees", "crates/shared/aws", "crates/shared/51degrees", @@ -176,6 +177,7 @@ trusted-server-cmp-inmobi = { path = "crates/cmp/inmobi" } trusted-server-ads-txt-iabtl = { path = "crates/ads-txt/iabtl" } trusted-server-analytics-tinybird = { path = "crates/analytics/tinybird" } trusted-server-headers-geo = { path = "crates/headers/geo" } +trusted-server-provenance-encypher = { path = "crates/provenance/encypher" } trusted-server-loading-cache-51degrees = { path = "crates/loading-cache/51degrees" } trusted-server-shared-aws = { path = "crates/shared/aws" } trusted-server-shared-51degrees = { path = "crates/shared/51degrees" } diff --git a/crates/provenance/encypher/Cargo.toml b/crates/provenance/encypher/Cargo.toml new file mode 100644 index 0000000000..5c596e1370 --- /dev/null +++ b/crates/provenance/encypher/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "trusted-server-provenance-encypher" +description = "Encypher text provenance: signs the article on each page a [[serve]] entry names it for, embedding invisible markers and a C2PA manifest link, and passes every page it cannot sign through unchanged." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +bytes = { workspace = true } +edgezero-core = { workspace = true } +error-stack = { workspace = true } +futures = { workspace = true } +http = { workspace = true } +log = { workspace = true } +regex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +trusted-server-core = { workspace = true } +url = { workspace = true } +validator = { workspace = true } +web-time = { workspace = true } + +[dev-dependencies] +async-trait = { workspace = true } +trusted-server-core = { workspace = true, features = ["test-utils"] } + +# The visible owner of this vendor crate. +[package.metadata.maintainers] +owner = "Encypher" +status = "vendor owned" diff --git a/crates/provenance/encypher/src/api.rs b/crates/provenance/encypher/src/api.rs new file mode 100644 index 0000000000..ddf89b92a7 --- /dev/null +++ b/crates/provenance/encypher/src/api.rs @@ -0,0 +1,303 @@ +use serde::{Deserialize, Serialize}; + +/// A 2 MiB cap accommodates roughly 600 KiB surrogate-escaped plans at the 50,000-scalar input limit. +pub const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +/// Maximum visible-text length sent for signing, measured in Unicode scalars. +pub const MAX_TEXT_SCALARS: usize = 50_000; +/// Policy responses contain only one digest; cap them independently of plans. +pub const MAX_POLICY_RESPONSE_BYTES: usize = 1024; + +/// JSON request body for the Encypher CDN signing endpoint. +#[derive(Serialize)] +pub struct SignRequest<'a> { + /// Canonical visible article text. + pub text: &'a str, + /// Absolute page URL without its query string. + pub page_url: &'a str, + /// Optional page title capped at 500 Unicode scalars. + #[serde(skip_serializing_if = "Option::is_none")] + pub document_title: Option<&'a str>, + /// Boundary detection label used for the article. + pub boundary_selector: &'a str, + /// Server policy used to select this article's cache namespace. + pub expected_policy_digest: &'a str, +} + +/// One invisible-marker insertion returned by the signing endpoint. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanOperation { + /// Visible codepoint index after which to insert, or -1 for before the first. + pub insert_after_index: i64, + /// Invisible marker characters to insert. + pub marker: String, +} + +/// A complete marker embedding plan. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EmbeddingPlan { + /// Index unit, which must be `codepoint`. + pub index_unit: String, + /// Ordered marker insertion operations. + pub operations: Vec, +} + +/// Parsed response from the Encypher CDN signing endpoint. +#[derive(Debug, Deserialize)] +pub struct SignResponse { + /// Whether signing succeeded. + pub success: bool, + /// Marker plan when signing succeeded. + #[serde(default)] + pub embedding_plan: Option, + /// C2PA manifest URL when signing succeeded. + #[serde(default)] + pub manifest_url: Option, + /// Stable failure reason when signing did not succeed. + #[serde(default)] + pub error: Option, + /// Actual policy used by the server; required before a successful plan is used. + #[serde(default)] + pub policy_digest: Option, +} + +/// Fail-closed signing-response parse errors. +#[derive(Debug, PartialEq)] +pub enum ApiError { + /// The response exceeded [`MAX_RESPONSE_BYTES`]. + TooLarge, + /// The response was not valid JSON for [`SignResponse`]. + Malformed, +} + +/// Parses a bounded signing response. +/// +/// # Errors +/// +/// Returns [`ApiError::TooLarge`] above the response limit and +/// [`ApiError::Malformed`] when JSON decoding fails. +pub fn parse_sign_response(body: &[u8]) -> Result { + if body.len() > MAX_RESPONSE_BYTES { + return Err(ApiError::TooLarge); + } + serde_json::from_slice(body).map_err(|_| ApiError::Malformed) +} + +/// A policy digest is exactly a lowercase SHA-256 hex value. +pub fn valid_policy_digest(digest: &str) -> bool { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Parse the policy endpoint's strict, bounded response. +/// +/// # Errors +/// +/// Rejects oversized JSON, extra or duplicate fields, and invalid digests. +pub fn parse_policy_response(body: &[u8]) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct PolicyResponse { + policy_digest: String, + } + if body.len() > MAX_POLICY_RESPONSE_BYTES { + return Err(ApiError::TooLarge); + } + let response: PolicyResponse = serde_json::from_slice(body).map_err(|_| ApiError::Malformed)?; + if !valid_policy_digest(&response.policy_digest) { + return Err(ApiError::Malformed); + } + Ok(response.policy_digest) +} + +/// Reasons an embedding plan is unsafe or cannot address the visible text. +#[derive(Debug, PartialEq)] +pub enum PlanError { + /// The plan does not use Unicode codepoint indices. + IndexUnit, + /// An insertion index is outside the visible-text range. + IndexOutOfRange, + /// A marker is empty. + EmptyMarker, + /// A marker contains a character outside the edge-core invisible set. + DisallowedMarkerChar, + /// The plan has no operations. + Empty, + /// The manifest URL is present but not an absolute `https` URL. + ManifestUrl, +} + +/// Validates a signing result before use: index bounds and the invisible marker +/// alphabet for every operation, and a manifest URL that is absent or `https`. +/// +/// This is the only check between the API or KV and the page, so callers must +/// pass the scalar count of the exact text that was signed. +/// +/// # Errors +/// +/// Returns a [`PlanError`] for the first invalid property. +pub fn validate_plan( + plan: &EmbeddingPlan, + manifest_url: Option<&str>, + visible_scalars: usize, +) -> Result<(), PlanError> { + if plan.index_unit != "codepoint" { + return Err(PlanError::IndexUnit); + } + if plan.operations.is_empty() { + return Err(PlanError::Empty); + } + for operation in &plan.operations { + if operation.insert_after_index < -1 { + return Err(PlanError::IndexOutOfRange); + } + if operation.insert_after_index >= 0 { + let Ok(index) = usize::try_from(operation.insert_after_index) else { + return Err(PlanError::IndexOutOfRange); + }; + if index >= visible_scalars { + return Err(PlanError::IndexOutOfRange); + } + } + if operation.marker.is_empty() { + return Err(PlanError::EmptyMarker); + } + if !operation.marker.chars().all(is_allowed_marker_char) { + return Err(PlanError::DisallowedMarkerChar); + } + } + if let Some(url) = manifest_url { + let https = url::Url::parse(url).is_ok_and(|url| { + url.scheme() == "https" && url.host_str().is_some_and(|h| !h.is_empty()) + }); + if !https { + return Err(PlanError::ManifestUrl); + } + } + Ok(()) +} + +fn is_allowed_marker_char(ch: char) -> bool { + matches!(ch, '\u{fe00}'..='\u{fe0f}' | '\u{e0100}'..='\u{e01ef}' | '\u{feff}') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn single_op_plan() -> EmbeddingPlan { + EmbeddingPlan { + index_unit: "codepoint".to_string(), + operations: vec![PlanOperation { + insert_after_index: 0, + marker: "\u{fe00}".to_string(), + }], + } + } + + #[test] + fn policy_response_requires_only_a_lowercase_sha256_digest() { + let digest = "ab".repeat(32); + let body = format!(r#"{{"policy_digest":"{digest}"}}"#); + assert_eq!(parse_policy_response(body.as_bytes()), Ok(digest.clone())); + for body in [ + "{}".to_owned(), + r#"{"policy_digest":null}"#.to_owned(), + r#"{"policy_digest":42}"#.to_owned(), + format!(r#"{{"policy_digest":"{}"}}"#, digest.to_uppercase()), + format!(r#"{{"policy_digest":"{}"}}"#, "g".repeat(64)), + format!(r#"{{"policy_digest":"{}"}}"#, "a".repeat(63)), + format!(r#"{{"policy_digest":"{digest}","extra":true}}"#), + format!(r#"{{"policy_digest":"{digest}","policy_digest":"{digest}"}}"#), + format!(r#"{{"policy_digest":"{digest}"}} trailing"#), + ] { + assert_eq!( + parse_policy_response(body.as_bytes()), + Err(ApiError::Malformed) + ); + } + assert_eq!( + parse_policy_response(&vec![b' '; MAX_POLICY_RESPONSE_BYTES + 1]), + Err(ApiError::TooLarge) + ); + } + + #[test] + fn parses_and_validates_a_response_plan() { + let body = r#"{"success":true,"embedding_plan":{"index_unit":"codepoint","operations":[{"insert_after_index":0,"marker":"︀"}]},"manifest_url":"https://manifest.example.com/x"}"#; + let response = parse_sign_response(body.as_bytes()).expect("should parse"); + let plan = response.embedding_plan.expect("should contain plan"); + assert_eq!( + validate_plan(&plan, response.manifest_url.as_deref(), 1), + Ok(()) + ); + } + + #[test] + fn manifest_url_must_be_absent_or_https() { + let plan = single_op_plan(); + assert_eq!(validate_plan(&plan, None, 1), Ok(())); + for url in [ + "", + "http://manifest.example.com/x", + "javascript:alert(1)", + "data:text/html,", + "/api/v1/public/cdn/manifest/1", + "https://", + ] { + assert_eq!( + validate_plan(&plan, Some(url), 1), + Err(PlanError::ManifestUrl), + "{url} should be rejected" + ); + } + } + + #[test] + fn rejects_markup_and_bad_ranges() { + let markup = EmbeddingPlan { + index_unit: "codepoint".to_string(), + operations: vec![PlanOperation { + insert_after_index: 0, + marker: "".to_string(), + }], + }; + assert_eq!( + validate_plan(&markup, None, 1), + Err(PlanError::DisallowedMarkerChar) + ); + assert_eq!( + validate_plan(&single_op_plan(), None, 0), + Err(PlanError::IndexOutOfRange) + ); + } + + #[test] + fn parses_realistic_large_marker_response() { + let escaped_marker = r"\uDB40\uDD00".repeat(50_000); + let body = format!( + r#"{{"success":true,"embedding_plan":{{"index_unit":"codepoint","operations":[{{"insert_after_index":0,"marker":"{escaped_marker}"}}]}}}}"# + ); + assert!(body.len() > 600_000); + let response = + parse_sign_response(body.as_bytes()).expect("large valid response should parse"); + let plan = response + .embedding_plan + .expect("response should contain a plan"); + assert_eq!(plan.operations[0].marker.chars().count(), 50_000); + } + + #[test] + fn enforces_size_and_empty_plan_limits() { + assert_eq!( + parse_sign_response(&vec![b' '; MAX_RESPONSE_BYTES + 1]).map(|_| ()), + Err(ApiError::TooLarge) + ); + let empty = EmbeddingPlan { + index_unit: "codepoint".to_string(), + operations: Vec::new(), + }; + assert_eq!(validate_plan(&empty, None, 1), Err(PlanError::Empty)); + } +} diff --git a/crates/provenance/encypher/src/boundary.rs b/crates/provenance/encypher/src/boundary.rs new file mode 100644 index 0000000000..b2b78c749d --- /dev/null +++ b/crates/provenance/encypher/src/boundary.rs @@ -0,0 +1,272 @@ +use std::sync::LazyLock; + +use regex::Regex; + +const CMS_CONTENT_CLASSES: [&str; 13] = [ + "entry-content", + "wp-block-post-content", + "post-content", + "article-body", + "article-content", + "story-body", + "story-body-text", + "gh-content", + "w-richtext", + "sqs-block-content", + "body markup", + "e-content", + "RichTextStoryBody", +]; + +static ARTICLE: LazyLock> = LazyLock::new(|| tag_opening("article")); +static MAIN: LazyLock> = LazyLock::new(|| tag_opening("main")); +static BODY: LazyLock> = LazyLock::new(|| tag_opening("body")); +static CLUSTER_CONTAINERS: LazyLock> = LazyLock::new(|| { + ["div", "section", "main"] + .into_iter() + .filter_map(tag_opening) + .collect() +}); +static PARAGRAPH: LazyLock> = LazyLock::new(|| Regex::new(r"(?i)> = LazyLock::new(|| { + CMS_CONTENT_CLASSES + .into_iter() + .filter_map(|class_name| Some((class_name, class_opening(class_name)?))) + .collect() +}); +static ITEMPROP_ARTICLE_BODY: LazyLock> = + LazyLock::new(|| attribute_opening("itemprop", "articleBody")); +static ROLE_MAIN: LazyLock> = LazyLock::new(|| attribute_opening("role", "main")); +static SELECTOR_ATTRIBUTE: LazyLock> = + LazyLock::new(|| Regex::new(r#"\[([A-Za-z0-9_]+)=["']?([^"'\]]+)["']?\]"#).ok()); + +/// The UTF-8 byte range and detection label of an article's outer HTML. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Boundary { + /// Inclusive byte offset of the opening element. + pub start: usize, + /// Exclusive byte offset after the first matching closing element. + pub end: usize, + /// Detection-chain selector label. + pub selector: String, +} + +/// Finds an article boundary using the edge-core priority chain, excluding JSON-LD P5. +#[must_use] +pub fn find_article_boundary(html: &str, override_selector: Option<&str>) -> Option { + if let Some(selector) = override_selector.filter(|selector| !selector.is_empty()) + && let Some(range) = match_selector(html, selector) + { + return Some(Boundary { + start: range.0, + end: range.1, + selector: format!("override:{selector}"), + }); + } + + let articles = ARTICLE + .as_ref() + .map_or_else(Vec::new, |opening| match_all(html, opening)); + if let Some(first) = articles.first().copied() { + let mut selected = first; + let mut selected_count = count_paragraphs(html.get(first.0..first.1).unwrap_or("")); + for candidate in articles.iter().copied().skip(1) { + let candidate_count = + count_paragraphs(html.get(candidate.0..candidate.1).unwrap_or("")); + if candidate_count > selected_count { + selected = candidate; + selected_count = candidate_count; + } + } + return Some(boundary(selected, "article")); + } + + for (class_name, opening) in CMS_CLASSES.iter() { + if let Some(range) = match_first(html, opening) { + return Some(boundary(range, &format!("class:{class_name}"))); + } + } + + if let Some(range) = match_fixed(html, &ITEMPROP_ARTICLE_BODY) { + return Some(boundary(range, "itemprop:articleBody")); + } + if let Some(range) = match_fixed(html, &MAIN) { + return Some(boundary(range, "main")); + } + if let Some(range) = match_fixed(html, &ROLE_MAIN) { + return Some(boundary(range, "role:main")); + } + if let Some(range) = find_largest_p_cluster(html) { + return Some(boundary(range, "p-cluster")); + } + match_fixed(html, &BODY).map(|range| boundary(range, "body")) +} + +fn boundary((start, end): (usize, usize), selector: &str) -> Boundary { + Boundary { + start, + end, + selector: selector.to_owned(), + } +} + +// Every opening pattern captures the element's tag name as group 1. +fn tag_opening(tag_name: &str) -> Option { + Regex::new(&format!( + r"(?i)<({})(?-u:\b)[^>]*>", + regex::escape(tag_name) + )) + .ok() +} + +fn class_opening(class_name: &str) -> Option { + Regex::new(&format!( + r#"(?i)<([A-Za-z0-9_]+)(?-u:\b)[^>]*class\s*=\s*["'][^"']*(?-u:\b){}(?-u:\b)[^"']*["'][^>]*>"#, + regex::escape(class_name) + )) + .ok() +} + +fn attribute_opening(attribute: &str, value: &str) -> Option { + Regex::new(&format!( + r#"(?i)<([A-Za-z0-9_]+)(?-u:\b)[^>]*{}\s*=\s*["']{}["'][^>]*>"#, + regex::escape(attribute), + regex::escape(value) + )) + .ok() +} + +fn match_fixed(html: &str, opening: &LazyLock>) -> Option<(usize, usize)> { + match_first(html, opening.as_ref()?) +} + +fn match_first(html: &str, opening: &Regex) -> Option<(usize, usize)> { + let captures = opening.captures(html)?; + let whole = captures.get(0)?; + close_first_element(html, whole.start(), whole.end(), captures.get(1)?.as_str()) +} + +fn match_all(html: &str, opening: &Regex) -> Vec<(usize, usize)> { + let mut matches = Vec::new(); + let mut cursor = 0_usize; + while let Some(tail) = html.get(cursor..) { + let Some(captures) = opening.captures(tail) else { + break; + }; + let (Some(whole), Some(tag_name)) = (captures.get(0), captures.get(1)) else { + break; + }; + let start = cursor.saturating_add(whole.start()); + let opening_end = cursor.saturating_add(whole.end()); + let Some(range) = close_first_element(html, start, opening_end, tag_name.as_str()) else { + break; + }; + matches.push(range); + cursor = range.1; + } + matches +} + +// An operator-configured selector is compiled per document. +fn match_selector(html: &str, selector: &str) -> Option<(usize, usize)> { + if let Some(class_name) = selector.strip_prefix('.') { + return match_first(html, &class_opening(class_name)?); + } + if selector.starts_with('[') + && let Some(captures) = SELECTOR_ATTRIBUTE.as_ref()?.captures(selector) + { + let opening = attribute_opening(captures.get(1)?.as_str(), captures.get(2)?.as_str())?; + return match_first(html, &opening); + } + match_first(html, &tag_opening(selector)?) +} + +/// Extends an opening tag to the first `` after it, ASCII case-insensitively. +fn close_first_element( + html: &str, + opening_start: usize, + opening_end: usize, + tag_name: &str, +) -> Option<(usize, usize)> { + let closing = format!(""); + let tail = html.as_bytes().get(opening_end..)?; + let offset = tail + .windows(closing.len()) + .position(|window| window.eq_ignore_ascii_case(closing.as_bytes()))?; + Some(( + opening_start, + opening_end + .saturating_add(offset) + .saturating_add(closing.len()), + )) +} + +fn count_paragraphs(html: &str) -> usize { + PARAGRAPH + .as_ref() + .map_or(0, |regex| regex.find_iter(html).count()) +} + +fn find_largest_p_cluster(html: &str) -> Option<(usize, usize)> { + let mut best = None; + let mut best_count = 4_usize; + for opening in CLUSTER_CONTAINERS.iter() { + for range in match_all(html, opening) { + let count = count_paragraphs(html.get(range.0..range.1).unwrap_or("")); + if count > best_count { + best = Some(range); + best_count = count; + } + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn follows_priority_chain_and_byte_offsets() { + let html = "🐟

Main.

Chosen.

"; + let boundary = find_article_boundary(html, None).expect("should find article"); + assert_eq!(boundary.selector, "article"); + assert_eq!( + html.get(boundary.start..boundary.end), + Some("

Chosen.

") + ); + } + + #[test] + fn nested_same_name_uses_first_closing_tag() { + let html = "
outer
inner
tail
"; + let boundary = find_article_boundary(html, None).expect("should find article"); + assert_eq!( + html.get(boundary.start..boundary.end), + Some("
outer
inner
") + ); + } + + #[test] + fn closing_tags_match_ascii_case_insensitively() { + let html = "

One.

x
"; + let boundary = find_article_boundary(html, None).expect("should find article"); + assert_eq!( + html.get(boundary.start..boundary.end), + Some("

One.

") + ); + let boundary = + find_article_boundary(html, Some(".entry-content")).expect("should find override"); + assert_eq!( + html.get(boundary.start..boundary.end), + Some("
x
") + ); + } + + #[test] + fn selects_largest_paragraph_cluster_at_five() { + let html = "

1

2

3

4

5

"; + let boundary = find_article_boundary(html, None).expect("should find cluster"); + assert_eq!(boundary.selector, "p-cluster"); + } +} diff --git a/crates/provenance/encypher/src/embed.rs b/crates/provenance/encypher/src/embed.rs new file mode 100644 index 0000000000..b5195ba58b --- /dev/null +++ b/crates/provenance/encypher/src/embed.rs @@ -0,0 +1,477 @@ +use std::sync::LazyLock; + +use regex::Regex; + +use super::api::EmbeddingPlan; +use super::boundary::Boundary; +use super::text::{ + assemble_text, decode_html_entities, extract_fragments, is_js_whitespace, structural_close_tag, +}; + +const MAGIC_PREFIX: &str = "\u{e01df}\u{e01e0}\u{e01e1}\u{e01e2}"; + +static MANIFEST_META: LazyLock> = LazyLock::new(|| { + Regex::new(r#"(?i)]*name\s*=\s*['"]c2pa-manifest-url['"][^>]*>"#).ok() +}); + +struct MappedChar { + ch: char, + start: usize, + end: usize, +} + +/// Reports whether a string contains the four-character Encypher magic prefix. +#[must_use] +pub fn has_existing_markers(s: &str) -> bool { + s.contains(MAGIC_PREFIX) +} + +/// Reports whether HTML already declares a C2PA manifest URL meta element. +#[must_use] +pub fn has_manifest_meta(html: &str) -> bool { + MANIFEST_META + .as_ref() + .is_some_and(|regex| regex.is_match(html)) +} + +/// Applies a codepoint embedding plan within an article boundary. +/// +/// The plan must already have passed [`super::api::validate_plan`]; markers are +/// inserted verbatim. Returns `None` when the boundary has no visible text, an +/// index has no text position, or text-to-byte alignment fails. +#[must_use] +pub fn apply_plan(html: &str, boundary: &Boundary, plan: &EmbeddingPlan) -> Option { + let boundary_html = html.get(boundary.start..boundary.end)?; + let fragments = extract_fragments(boundary_html); + let visible = assemble_text(boundary_html, &fragments); + let visible_chars: Vec = visible.chars().collect(); + if visible_chars.is_empty() { + return None; + } + + let mut insertion_offsets = vec![None; visible_chars.len()]; + let mut first_visible_start = None; + let mut previous_visible_end = None; + let mut visible_cursor = 0_usize; + + for fragment in fragments { + let raw = boundary_html.get(fragment.start..fragment.end)?; + let normalized = normalize_mapped(decode_with_offsets(raw)); + if normalized.is_empty() { + continue; + } + + while visible_chars.get(visible_cursor) == Some(&' ') { + if let Some(offset) = previous_visible_end + && let Some(slot) = insertion_offsets.get_mut(visible_cursor) + { + *slot = Some(offset); + } + visible_cursor = visible_cursor.saturating_add(1); + } + + for item in normalized { + let target = visible_chars.get(visible_cursor)?; + if !equivalent(item.ch, *target) { + return None; + } + let start = fragment.start.checked_add(item.start)?; + let end = fragment.start.checked_add(item.end)?; + if first_visible_start.is_none() { + first_visible_start = Some(start); + } + let slot = insertion_offsets.get_mut(visible_cursor)?; + *slot = Some(end); + previous_visible_end = Some(end); + visible_cursor = visible_cursor.saturating_add(1); + } + } + + while visible_chars.get(visible_cursor) == Some(&' ') { + if let Some(offset) = previous_visible_end { + let slot = insertion_offsets.get_mut(visible_cursor)?; + *slot = Some(offset); + } + visible_cursor = visible_cursor.saturating_add(1); + } + if visible_cursor != visible_chars.len() { + return None; + } + let first_visible_start = first_visible_start?; + + let mut insertions = Vec::with_capacity(plan.operations.len()); + let mut output_len = html.len(); + for operation in &plan.operations { + let relative = if operation.insert_after_index == -1 { + first_visible_start + } else { + let index = usize::try_from(operation.insert_after_index).ok()?; + insertion_offsets.get(index).copied().flatten()? + }; + let absolute = boundary.start.checked_add(relative)?; + output_len = output_len.checked_add(operation.marker.len())?; + insertions.push((absolute, operation.marker.as_str())); + } + // Stable sorting preserves API order for multiple markers at the same offset. + insertions.sort_by_key(|(offset, _)| *offset); + let mut result = String::with_capacity(output_len); + let mut cursor = 0; + for (offset, marker) in insertions { + result.push_str(html.get(cursor..offset)?); + result.push_str(marker); + cursor = offset; + } + result.push_str(html.get(cursor..)?); + Some(result) +} + +/// Injects an escaped manifest meta element before the structural closing head tag. +/// +/// Returns the input unchanged when the URL is empty, a manifest meta already exists, +/// or no structural closing head tag exists. +#[must_use] +pub fn inject_manifest_meta(html: &str, manifest_url: &str) -> String { + if html.is_empty() || manifest_url.is_empty() || has_manifest_meta(html) { + return html.to_owned(); + } + let Some(offset) = structural_close_tag(html, "head") else { + return html.to_owned(); + }; + let safe_url = manifest_url + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">"); + let meta = format!(r#""#); + let Some(before) = html.get(..offset) else { + return html.to_owned(); + }; + let Some(after) = html.get(offset..) else { + return html.to_owned(); + }; + let mut output = String::with_capacity(html.len().saturating_add(meta.len())); + output.push_str(before); + output.push_str(&meta); + output.push_str(after); + output +} + +fn decode_with_offsets(raw: &str) -> Vec { + let mut mapped = Vec::new(); + let mut cursor = 0_usize; + while cursor < raw.len() { + if raw.as_bytes().get(cursor) == Some(&b'&') + && let Some(relative_semicolon) = raw + .as_bytes() + .get(cursor.saturating_add(1)..) + // Entity names/numbers cannot contain another ampersand or markup. + // Stop there rather than rescanning the suffix for every literal '&'. + .and_then(|tail| { + let end = tail + .iter() + .position(|byte| !byte.is_ascii_alphanumeric() && *byte != b'#')?; + (tail[end] == b';').then_some(end) + }) + { + let end = cursor + .saturating_add(1) + .saturating_add(relative_semicolon) + .saturating_add(1); + if let Some(token) = raw.get(cursor..end) { + let decoded = decode_html_entities(token); + if decoded != token { + mapped.extend(decoded.chars().map(|ch| MappedChar { + ch, + start: cursor, + end, + })); + cursor = end; + continue; + } + } + } + + let Some(ch) = raw.get(cursor..).and_then(|tail| tail.chars().next()) else { + break; + }; + let end = cursor.saturating_add(ch.len_utf8()); + mapped.push(MappedChar { + ch, + start: cursor, + end, + }); + cursor = end; + } + mapped +} + +fn normalize_mapped(mapped: Vec) -> Vec { + let mut normalized: Vec = Vec::with_capacity(mapped.len()); + for item in mapped { + if is_js_whitespace(item.ch) { + let Some(last) = normalized.last_mut() else { + continue; + }; + if last.ch == ' ' { + last.end = item.end; + } else { + normalized.push(MappedChar { + ch: ' ', + start: item.start, + end: item.end, + }); + } + } else { + normalized.push(item); + } + } + while normalized.last().is_some_and(|item| item.ch == ' ') { + normalized.pop(); + } + normalized +} + +fn equivalent(left: char, right: char) -> bool { + left == right || matches!((left, right), ('\u{00a0}', ' ') | (' ', '\u{00a0}')) +} + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + use super::*; + use crate::api::{EmbeddingPlan, PlanOperation}; + use crate::boundary::{Boundary, find_article_boundary}; + use crate::text::{assemble_text, extract_fragments}; + + #[test] + fn adversarial_ampersands_have_bounded_embedding_work() { + let html = format!("
{}
", "&".repeat(100_000)); + let boundary = find_article_boundary(&html, None).expect("article"); + let plan = EmbeddingPlan { + index_unit: "codepoint".to_owned(), + operations: vec![PlanOperation { + insert_after_index: 0, + marker: "\u{fe00}".to_owned(), + }], + }; + let output = apply_plan(&html, &boundary, &plan).expect("valid plan"); + assert_eq!( + output, + format!("
&\u{fe00}{}
", "&".repeat(99_999)) + ); + } + + #[test] + fn dense_plan_on_large_document_preserves_operation_order() { + let html = format!( + "
{}
", + "a".repeat(50_000), + "x".repeat(4_900_000) + ); + let boundary = find_article_boundary(&html, None).expect("article"); + let plan = EmbeddingPlan { + index_unit: "codepoint".to_owned(), + operations: (0..20_000) + .rev() + .map(|index| PlanOperation { + insert_after_index: index, + marker: "\u{fe00}".to_owned(), + }) + .collect(), + }; + let output = apply_plan(&html, &boundary, &plan).expect("valid dense plan"); + assert_eq!( + output, + format!( + "
{}{}
", + "a\u{fe00}".repeat(20_000), + "a".repeat(30_000), + "x".repeat(4_900_000) + ) + ); + } + + #[derive(Deserialize)] + struct FixtureFile { + cases: Vec, + } + + #[derive(Deserialize)] + struct FixtureCase { + id: String, + html: String, + override_selector: Option, + boundary: FixtureBoundary, + text: String, + text_rust: String, + text_divergence: Option, + plan: EmbeddingPlan, + expected_output: String, + invalid_plan: Option, + expected_invalid_output: Option, + manifest_url: String, + expected_meta_output_js: String, + expected_meta_output_rust: String, + meta_divergence: Option, + } + + #[derive(Deserialize)] + struct FixtureBoundary { + selector: String, + start: usize, + end: usize, + } + + fn fixtures() -> FixtureFile { + serde_json::from_str(include_str!("fixtures/parity.json")) + .expect("parity fixtures should deserialize") + } + + #[test] + fn groups_same_offset_in_plan_order() { + let html = "🐟
A B
"; + let start = html.find("
").expect("should find boundary"); + let boundary = Boundary { + start, + end: html.len(), + selector: "article".to_string(), + }; + let plan = EmbeddingPlan { + index_unit: "codepoint".to_string(), + operations: vec![ + PlanOperation { + insert_after_index: 0, + marker: "\u{fe00}".to_string(), + }, + PlanOperation { + insert_after_index: 0, + marker: "\u{fe01}".to_string(), + }, + ], + }; + assert_eq!( + apply_plan(html, &boundary, &plan), + Some("🐟
A\u{fe00}\u{fe01} B
".to_string()) + ); + } + + #[test] + fn manifest_meta_uses_structural_head_close_and_escapes() { + let html = ""; + let output = inject_manifest_meta(html, "https://example.com/?a=1&b=\"x\""); + assert!(output.contains(""#; + let html = format!("{prefix}
Visible
"); + assert_eq!( + inject_manifest_meta(&html, "javascript:alert(1)"), + format!( + "{prefix}
Visible
" + ) + ); + // The URL is inert metadata, not an href, script source or refresh target. + let script = + r#""#; + assert_eq!( + inject_manifest_meta( + &format!("{script}"), + "data:text/html," + ), + format!( + "{script}" + ) + ); + } + + #[test] + fn manifest_meta_ignores_quotes_inside_unquoted_values() { + // The browser reads `x"y` as an unquoted value, so the second meta's quoted + // content, including its ``, is attribute text. + let prefix = + r#""#; + let html = format!("{prefix}Visible"); + assert_eq!( + inject_manifest_meta(&html, "https://manifest.example.com/m"), + format!( + "{prefix}Visible" + ) + ); + } + + #[test] + fn edge_core_parity_fixtures_match() { + for fixture in fixtures().cases { + let boundary = + find_article_boundary(&fixture.html, fixture.override_selector.as_deref()) + .unwrap_or_else(|| panic!("{} should have a boundary", fixture.id)); + assert_eq!( + boundary.selector, fixture.boundary.selector, + "{}", + fixture.id + ); + assert_eq!(boundary.start, fixture.boundary.start, "{}", fixture.id); + assert_eq!(boundary.end, fixture.boundary.end, "{}", fixture.id); + let boundary_html = fixture + .html + .get(boundary.start..boundary.end) + .expect("fixture boundary should be valid"); + let fragments = extract_fragments(boundary_html); + let text = assemble_text(boundary_html, &fragments); + assert_eq!(text, fixture.text_rust, "{}", fixture.id); + if fixture.text_divergence.is_some() { + assert_ne!(text, fixture.text, "{}", fixture.id); + } else { + assert_eq!(text, fixture.text, "{}", fixture.id); + } + assert_eq!( + apply_plan(&fixture.html, &boundary, &fixture.plan), + Some(fixture.expected_output), + "{}", + fixture.id + ); + if let Some(invalid_plan) = fixture.invalid_plan { + assert_eq!( + apply_plan(&fixture.html, &boundary, &invalid_plan), + fixture.expected_invalid_output, + "{}", + fixture.id + ); + } + let meta = inject_manifest_meta(&fixture.html, &fixture.manifest_url); + assert_eq!(meta, fixture.expected_meta_output_rust, "{}", fixture.id); + if fixture.meta_divergence.is_some() { + assert_ne!(meta, fixture.expected_meta_output_js, "{}", fixture.id); + } else { + assert_eq!(meta, fixture.expected_meta_output_js, "{}", fixture.id); + } + } + } + + #[test] + fn every_fixture_byte_prefix_is_panic_free() { + for fixture in fixtures().cases { + for end in 0..=fixture.html.len() { + let prefix = String::from_utf8_lossy( + fixture + .html + .as_bytes() + .get(..end) + .expect("prefix range should be valid"), + ); + let fragments = extract_fragments(&prefix); + let _ = assemble_text(&prefix, &fragments); + if let Some(boundary) = + find_article_boundary(&prefix, fixture.override_selector.as_deref()) + { + let _ = apply_plan(&prefix, &boundary, &fixture.plan); + } + let _ = inject_manifest_meta(&prefix, &fixture.manifest_url); + } + } + } +} diff --git a/crates/provenance/encypher/src/fixtures/parity.json b/crates/provenance/encypher/src/fixtures/parity.json new file mode 100644 index 0000000000..8487676317 --- /dev/null +++ b/crates/provenance/encypher/src/fixtures/parity.json @@ -0,0 +1,848 @@ +{ + "generated_from": [ + "integrations/edge-core/boundary.js", + "integrations/edge-core/fragments.js", + "integrations/edge-core/embed.js" + ], + "fixed_plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "cases": [ + { + "id": "override-multibyte-prefix", + "html": "Café & News🙂前

Alpha & beta.

", + "override_selector": "[data=story]", + "boundary": { + "selector": "override:[data=story]", + "start": 78, + "end": 126 + }, + "text": "Alpha & beta.", + "text_rust": "Alpha & beta.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "Café & News🙂前

A︀︁lpha & beta.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "Café & News🙂前

Alpha & beta.

", + "expected_meta_output_rust": "Café & News🙂前

Alpha & beta.

", + "meta_divergence": null + }, + { + "id": "article-most-paragraphs", + "html": "Two articles

Short.

First paragraph.

Second paragraph.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 84, + "end": 159 + }, + "text": "First paragraph. Second paragraph .", + "text_rust": "First paragraph. Second paragraph .", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "Two articles

Short.

F︀︁irst paragraph.

Second paragraph.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "Two articles

Short.

First paragraph.

Second paragraph.

", + "expected_meta_output_rust": "Two articles

Short.

First paragraph.

Second paragraph.

", + "meta_divergence": null + }, + { + "id": "cms-class-entities", + "html": "

Fish & chips — © 🐟.

", + "override_selector": null, + "boundary": { + "selector": "class:entry-content", + "start": 25, + "end": 123 + }, + "text": "Fish & chips — © 🐟.", + "text_rust": "Fish & chips — © 🐟.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

F︀︁ish & chips — © 🐟.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Fish & chips — © 🐟.

", + "expected_meta_output_rust": "

Fish & chips — © 🐟.

", + "meta_divergence": null + }, + { + "id": "itemprop-nbsp", + "html": "

One two three.

", + "override_selector": null, + "boundary": { + "selector": "itemprop:articleBody", + "start": 25, + "end": 94 + }, + "text": "One two three.", + "text_rust": "One two three.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

O︀︁ne two three.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

One two three.

", + "expected_meta_output_rust": "

One two three.

", + "meta_divergence": null + }, + { + "id": "main-inline-tags", + "html": "

Inline

Hello fictional example.com.

", + "override_selector": null, + "boundary": { + "selector": "main", + "start": 25, + "end": 105 + }, + "text": "Inline Hello fictional example.com.", + "text_rust": "Inline Hello fictional example.com.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

I︀︁nline

Hello fictional example.com.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Inline

Hello fictional example.com.

", + "expected_meta_output_rust": "

Inline

Hello fictional example.com.

", + "meta_divergence": null + }, + { + "id": "role-main", + "html": "

Role based article boundary.

", + "override_selector": null, + "boundary": { + "selector": "role:main", + "start": 25, + "end": 83 + }, + "text": "Role based article boundary.", + "text_rust": "Role based article boundary.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

R︀︁ole based article boundary.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Role based article boundary.

", + "expected_meta_output_rust": "

Role based article boundary.

", + "meta_divergence": null + }, + { + "id": "paragraph-cluster", + "html": "

One.

Two.

Three.

Four.

Five.

", + "override_selector": null, + "boundary": { + "selector": "p-cluster", + "start": 25, + "end": 109 + }, + "text": "One. Two. Three. Four. Five.", + "text_rust": "One. Two. Three. Four. Five.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

O︀︁ne.

Two.

Three.

Four.

Five.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

One.

Two.

Three.

Four.

Five.

", + "expected_meta_output_rust": "

One.

Two.

Three.

Four.

Five.

", + "meta_divergence": null + }, + { + "id": "body-fallback", + "html": "

Fallback

Body article text.", + "override_selector": null, + "boundary": { + "selector": "body", + "start": 19, + "end": 80 + }, + "text": "Fallback Body article text.", + "text_rust": "Fallback Body article text.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

F︀︁allback

Body article text.", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Fallback

Body article text.", + "expected_meta_output_rust": "

Fallback

Body article text.", + "meta_divergence": null + }, + { + "id": "nested-same-name-first-close", + "html": "

Outer start.

Inner text.

Outer tail omitted by parity.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 90 + }, + "text": "Outer start. Inner text.", + "text_rust": "Outer start. Inner text.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

O︀︁uter start.

Inner text.

Outer tail omitted by parity.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Outer start.

Inner text.

Outer tail omitted by parity.

", + "expected_meta_output_rust": "

Outer start.

Inner text.

Outer tail omitted by parity.

", + "meta_divergence": null + }, + { + "id": "head-close-inside-script", + "html": "Script case

Script boundary fallback.

", + "override_selector": null, + "boundary": { + "selector": "body", + "start": 86, + "end": 131 + }, + "text": "Script boundary fallback.", + "text_rust": "Script boundary fallback.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "Script case

S︀︁cript boundary fallback.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "Script case

Script boundary fallback.

", + "expected_meta_output_rust": "Script case

Script boundary fallback.

", + "meta_divergence": "JS inserts at a closing-head sequence inside script text; Rust inserts before the structural closing head tag." + }, + { + "id": "head-close-inside-comment", + "html": "Comment case

Comment boundary fallback.

", + "override_selector": null, + "boundary": { + "selector": "body", + "start": 80, + "end": 126 + }, + "text": "Comment boundary fallback.", + "text_rust": "Comment boundary fallback.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "Comment case

C︀︁omment boundary fallback.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "Comment case

Comment boundary fallback.

", + "expected_meta_output_rust": "Comment case

Comment boundary fallback.

", + "meta_divergence": "JS inserts at a closing-head sequence inside a comment; Rust inserts before the structural closing head tag." + }, + { + "id": "head-close-inside-title", + "html": "Headline </head> text

Title boundary fallback.

", + "override_selector": null, + "boundary": { + "selector": "body", + "start": 55, + "end": 99 + }, + "text": "Title boundary fallback.", + "text_rust": "Title boundary fallback.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "Headline </head> text

T︀︁itle boundary fallback.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "Headline <meta name=\"c2pa-manifest-url\" content=\"https://manifest.example.com/c2pa?id=fictional&kind="article"\"></head> text

Title boundary fallback.

", + "expected_meta_output_rust": "Headline </head> text

Title boundary fallback.

", + "meta_divergence": "JS inserts at a closing-head sequence inside title text; Rust never searches raw-text or RCDATA content for the closing head tag." + }, + { + "id": "javascript-whitespace-set", + "html": "

Alpha 
beta\t gamma.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 77 + }, + "text": "Alpha beta gamma.", + "text_rust": "Alpha beta gamma.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

A︀︁lpha 
beta\t gamma.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Alpha 
beta\t gamma.

", + "expected_meta_output_rust": "

Alpha 
beta\t gamma.

", + "meta_divergence": null + }, + { + "id": "skip-raw-elements-and-numeric-entities", + "html": "

Visible A.

Hidden code.
Hidden pre.

Tail B.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 162 + }, + "text": "Visible A. Tail B.", + "text_rust": "Visible A. Tail B.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

V︀︁isible A.

Hidden code.
Hidden pre.

Tail B.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Visible A.

Hidden code.
Hidden pre.

Tail B.

", + "expected_meta_output_rust": "

Visible A.

Hidden code.
Hidden pre.

Tail B.

", + "meta_divergence": null + }, + { + "id": "only-the-seven-skip-elements-are-hidden", + "html": "

Lead text.

Inline title.

After.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 166 + }, + "text": "Lead text. Draft note. Frame fallback. Inline title. After.", + "text_rust": "Lead text. Draft note. Frame fallback. Inline title. After.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

L︀︁ead text.

Inline title.

After.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Lead text.

Inline title.

After.

", + "expected_meta_output_rust": "

Lead text.

Inline title.

After.

", + "meta_divergence": null + }, + { + "id": "self-closing-svg-hides-nothing", + "html": "

Lead text.

After icon.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 103 + }, + "text": "Lead text. After icon.", + "text_rust": "Lead text. After icon.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

L︀︁ead text.

After icon.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Lead text.

After icon.

", + "expected_meta_output_rust": "

Lead text.

After icon.

", + "meta_divergence": null + }, + { + "id": "quoted-gt-in-attribute", + "html": "

Lead text.

\"a b\">Tail text.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 95 + }, + "text": "Lead text. b\">Tail text.", + "text_rust": "Lead text. Tail text.", + "text_divergence": "JS ends a tag at the first \">\", so the rest of the alt value becomes text; Rust ends it at the first \">\" outside a quoted attribute value.", + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

L︀︁ead text.

\"a b\">Tail text.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Lead text.

\"a b\">Tail text.

", + "expected_meta_output_rust": "

Lead text.

\"a b\">Tail text.

", + "meta_divergence": null + }, + { + "id": "nested-same-name-ignored-element", + "html": "

Lead text.

outer inner tail

After.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 116 + }, + "text": "Lead text. After.", + "text_rust": "Lead text. tail After.", + "text_divergence": "JS counts nested start and end tags and drops the outer tail; Rust closes the ignored region at the first matching end tag.", + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

L︀︁ead text.

outer inner tail

After.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Lead text.

outer inner tail

After.

", + "expected_meta_output_rust": "

Lead text.

outer inner tail

After.

", + "meta_divergence": null + }, + { + "id": "self-closing-script-stays-raw-text", + "html": "

Lead text.

After.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 107 + }, + "text": "Lead text. var hidden = 1; After.", + "text_rust": "Lead text. After.", + "text_divergence": "JS treats \".", + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

L︀︁ead text.

After.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Lead text.

After.

", + "expected_meta_output_rust": "

Lead text.

After.

", + "meta_divergence": null + }, + { + "id": "end-tag-with-attributes-closes-ignored-element", + "html": "

Lead text.

After.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 132 + }, + "text": "Lead text. After.", + "text_rust": "Lead text. After.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

L︀︁ead text.

After.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Lead text.

After.

", + "expected_meta_output_rust": "

Lead text.

After.

", + "meta_divergence": null + }, + { + "id": "abrupt-and-bang-comment-ends", + "html": "

Lead text.

Shown one.Shown two.Shown three.

After.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 131 + }, + "text": "Lead text. Shown two.", + "text_rust": "Lead text. Shown one. Shown two. Shown three. After.", + "text_divergence": "JS ends every comment at the next \"-->\"; Rust, like browsers, treats \"\" and \"\" as whole comments and also ends a comment at \"--!>\".", + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

L︀︁ead text.

Shown one.Shown two.Shown three.

After.

", + "invalid_plan": null, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Lead text.

Shown one.Shown two.Shown three.

After.

", + "expected_meta_output_rust": "

Lead text.

Shown one.Shown two.Shown three.

After.

", + "meta_divergence": null + }, + { + "id": "out-of-range-index", + "html": "

Small boundary.

", + "override_selector": null, + "boundary": { + "selector": "article", + "start": 25, + "end": 66 + }, + "text": "Small boundary.", + "text_rust": "Small boundary.", + "text_divergence": null, + "plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": -1, + "marker": "" + }, + { + "insert_after_index": 0, + "marker": "︀" + }, + { + "insert_after_index": 0, + "marker": "︁" + } + ] + }, + "expected_output": "

S︀︁mall boundary.

", + "invalid_plan": { + "index_unit": "codepoint", + "operations": [ + { + "insert_after_index": 15, + "marker": "︀" + } + ] + }, + "expected_invalid_output": null, + "manifest_url": "https://manifest.example.com/c2pa?id=fictional&kind=\"article\"", + "expected_meta_output_js": "

Small boundary.

", + "expected_meta_output_rust": "

Small boundary.

", + "meta_divergence": null + } + ] +} diff --git a/crates/provenance/encypher/src/lib.rs b/crates/provenance/encypher/src/lib.rs new file mode 100644 index 0000000000..e633f6ee09 --- /dev/null +++ b/crates/provenance/encypher/src/lib.rs @@ -0,0 +1,346 @@ +//! Encypher text provenance for article pages, `provenance.encypher`. +//! +//! A serve middleware finds the article in each HTML page a `[[serve]]` entry +//! names it for, sends its visible text to the Encypher signing API, and +//! embeds the returned invisible markers and a manifest meta tag. Plans are +//! cached in the runtime KV store, bound to a short-lived server policy. +//! Every failure passes the page through unchanged. +//! +//! The middleware's preparation applies the request gates with no I/O: only +//! a `GET` of a `200` page on the configured domain, whose origin did not +//! mark it as one reader's, is held. The signing work runs in the stream +//! processor's asynchronous finish, which core awaits at the end of the +//! document. The module requires no permission: it sends the publisher's own +//! article text and page address, never anything of the reader's, and a page +//! the origin marked as one reader's is never sent. + +use std::any::Any; +use std::sync::Arc; + +use error_stack::Report; +use serde::Deserialize; +use validator::{Validate, ValidationError}; + +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::integrations::{ + IntegrationBuilder, IntegrationRegistration, ModuleSecretSetting, SuppliedMiddleware, +}; +use trusted_server_core::middleware::{ + HTML_MEDIA_TYPE, Middleware, MiddlewareAction, MiddlewareContext, MiddlewarePhase, Preparation, +}; +use trusted_server_core::module_context::{FromModuleContext as _, ModuleCall, ModuleRequest}; +use trusted_server_core::redacted::Redacted; +use trusted_server_core::settings::{IntegrationConfig, Settings}; + +mod api; +mod boundary; +mod embed; +mod processor; +mod text; + +use processor::{PageRequest, Signer, document_processor, is_excluded_path}; + +/// The id the module's registration and its API backend carry. +const INTEGRATION_ID: &str = "provenance_encypher"; + +/// The name `[provenance] module` selects the module by and a `[[serve]]` +/// entry names its middleware by, from this crate's folder. +pub const MODULE: &str = "provenance.encypher"; + +/// `[provenance.encypher]`, the module's settings. +#[derive(Debug, Clone, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +#[validate(schema(function = "validate_hold_exceeds_timeout"))] +pub struct EncypherConfig { + /// Encypher API origin, for example `https://api.example.com`. Must be https. + #[validate(custom(function = validate_api_base_url))] + pub api_base_url: String, + /// Claimed publisher domain: a bare lowercase hostname without `www.`. + #[validate(custom(function = validate_domain))] + pub domain: String, + /// The name of the secret-store entry holding the `cwk_` worker + /// credential, resolved to the credential itself when settings load. + pub worker_token: Redacted, + /// Optional article selector override: `.class`, `[attr=value]` or a tag + /// name. At most 91 characters, so `override:{selector}` fits the API's + /// 100-character `boundary_selector` limit. + #[serde(default)] + #[validate(length(max = 91))] + pub article_selector: String, + /// Minimum visible article text, in Unicode scalars. + #[serde(default = "default_min_text_length")] + #[validate(range(min = 50, max = 50_000))] + pub min_text_length: usize, + /// Largest decoded document held for signing. Capped at + /// `publisher.max_buffered_body_bytes`. + #[serde(default = "default_max_document_bytes")] + #[validate(range(min = 1024))] + pub max_document_bytes: usize, + /// First-byte timeout for each policy or sign call, in milliseconds. The + /// only bound on a hung API on a runtime without a timer. + #[serde(default = "default_timeout_ms")] + #[validate(range(min = 250, max = 10_000))] + pub timeout_ms: u64, + /// Longest one document is held for signing, in milliseconds, on a + /// runtime with a timer. Must exceed `timeout_ms`. + #[serde(default = "default_max_hold_ms")] + #[validate(range(min = 500, max = 15_000))] + pub max_hold_ms: u64, + /// Lifetime of a cached signing plan, in seconds. + #[serde(default = "default_plan_ttl_seconds")] + #[validate(range(min = 60, max = 31_536_000))] + pub plan_ttl_seconds: u64, + /// Lifetime of the server-authoritative policy, in seconds. + #[serde(default = "default_policy_ttl_seconds")] + #[validate(range(min = 60, max = 300))] + pub policy_ttl_seconds: u64, + /// Lifetime of a cached signing failure, in seconds. + #[serde(default = "default_negative_ttl_seconds")] + #[validate(range(min = 60, max = 86_400))] + pub negative_ttl_seconds: u64, +} + +impl IntegrationConfig for EncypherConfig {} + +fn default_min_text_length() -> usize { + 50 +} + +fn default_max_document_bytes() -> usize { + 5_000_000 +} + +fn default_timeout_ms() -> u64 { + 3_000 +} + +fn default_max_hold_ms() -> u64 { + 5_000 +} + +fn default_plan_ttl_seconds() -> u64 { + 604_800 +} + +fn default_policy_ttl_seconds() -> u64 { + 300 +} + +fn default_negative_ttl_seconds() -> u64 { + 300 +} + +fn validate_api_base_url(value: &str) -> Result<(), ValidationError> { + let parsed = + url::Url::parse(value).map_err(|_| ValidationError::new("encypher_api_base_url"))?; + if parsed.scheme() != "https" + || parsed.host_str().is_none_or(str::is_empty) + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(ValidationError::new("encypher_api_base_url")); + } + Ok(()) +} + +fn validate_domain(value: &str) -> Result<(), ValidationError> { + let valid_label = |label: &str| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }; + let valid = value.len() <= 253 + && value.contains('.') + && !value.starts_with("www.") + && value.split('.').all(valid_label); + if valid { + Ok(()) + } else { + Err(ValidationError::new("encypher_domain")) + } +} + +fn validate_hold_exceeds_timeout(config: &EncypherConfig) -> Result<(), ValidationError> { + if config.max_hold_ms > config.timeout_ms { + Ok(()) + } else { + Err(ValidationError::new("encypher_max_hold_ms")) + } +} + +/// The worker token, named in `[provenance.encypher]` by its entry in the +/// secret store, which is in use whenever the module is selected. +const SECRET_SETTINGS: &[ModuleSecretSetting] = &[ModuleSecretSetting { + path: &["worker_token"], + in_use: always_in_use, +}]; + +fn always_in_use(_table: &serde_json::Map) -> bool { + true +} + +/// The page change the module supplies, as a `[[serve]]` entry names it. +const SUPPLIED_MIDDLEWARE: &[SuppliedMiddleware] = + &[SuppliedMiddleware::on_html(MODULE, MiddlewarePhase::Serve)]; + +/// The builder a deployment installs to offer this module. +#[must_use] +pub fn builder() -> IntegrationBuilder { + IntegrationBuilder::new(INTEGRATION_ID, env!("CARGO_PKG_NAME"), register, validate) + .with_module_name(MODULE) + .with_secret_settings(SECRET_SETTINGS) + .with_middleware(SUPPLIED_MIDDLEWARE) +} + +/// Refuses a document that selects the module and names its middleware in no +/// `[[serve]]` entry, where no page would ever be signed. +fn require_an_entry(settings: &Settings) -> Result<(), Report> { + let placed = settings + .serve + .entries() + .iter() + .any(|entry| entry.middleware.iter().any(|name| name == MODULE)); + if placed { + return Ok(()); + } + Err(Report::new(TrustedServerError::Settings { + message: format!( + "[provenance] selects `encypher`, but no [[serve]] entry names `{MODULE}`, so no \ + page would be signed. Add `{MODULE}` to the [[serve]] entry that covers the \ + article pages, with a `path` for each section to sign" + ), + })) +} + +/// The module's settings when a section selects it. +fn config(settings: &Settings) -> Result, Report> { + let Some(config) = settings.module_config::(MODULE)? else { + return Ok(None); + }; + require_an_entry(settings)?; + Ok(Some(config)) +} + +/// Validates the module's table and reports whether a section selects the +/// module. +/// +/// # Errors +/// +/// Returns an error when the table cannot be read or fails its validation, or +/// when no `[[serve]]` entry names the middleware. +pub fn validate(settings: &Settings) -> Result> { + Ok(config(settings)?.is_some()) +} + +/// Registers the middleware when a section selects the module and its worker +/// token is not blank. +/// +/// # Errors +/// +/// Returns the errors [`validate`] returns. +pub fn register( + settings: &Settings, +) -> Result, Report> { + let Some(config) = config(settings)? else { + return Ok(None); + }; + if config.worker_token.expose().trim().is_empty() { + log::warn!("encypher: not registered, because the worker token is empty"); + return Ok(None); + } + log::info!("encypher: registered for domain={}", config.domain); + let max_document_bytes = config + .max_document_bytes + .min(settings.publisher.max_buffered_body_bytes); + Ok(Some( + IntegrationRegistration::builder(INTEGRATION_ID) + .without_js() + .with_middleware(Arc::new(Encypher { + signer: Arc::new(Signer::new(config, max_document_bytes)), + })) + .build(), + )) +} + +/// The middleware, which signs the article of each admitted page. +pub struct Encypher { + signer: Arc, +} + +impl std::fmt::Debug for Encypher { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("Encypher").finish_non_exhaustive() + } +} + +/// What the preparation decided for one document: the page to sign, or none. +type Admission = Option; + +impl Middleware for Encypher { + fn middleware_id(&self) -> &'static str { + MODULE + } + + fn handles_media_type(&self, media_type: &str) -> bool { + media_type == HTML_MEDIA_TYPE + } + + /// Every path but an API, feed, admin or well-known path, or an asset, + /// which is never an article. + fn handles_path(&self, path: &str) -> bool { + !is_excluded_path(path) + } + + /// Serve only: the signed page is written on each reader's copy, so a + /// failed signing is never stored for every reader. + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Serve] + } + + /// Applies the request gates, with no I/O, and keeps the page to sign. + fn prepare<'a>( + &'a self, + call: ModuleCall<'a>, + context: &'a MiddlewareContext<'a>, + ) -> Option> { + let admission = admit(&self.signer, &call, context); + Some(Box::pin(async move { + Ok(Box::new(admission) as Box) + })) + } + + fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { + let Some(page) = context.take_prepared::(MODULE).flatten() else { + return MiddlewareAction::pass(); + }; + MiddlewareAction { + stream: Some(Box::new(document_processor(&self.signer, page))), + ..MiddlewareAction::pass() + } + } +} + +/// The page a document is the body of, when the gates admit it. +fn admit(signer: &Signer, call: &ModuleCall<'_>, context: &MiddlewareContext<'_>) -> Admission { + let request = ModuleRequest::from_module_context(call).ok()?; + let page = PageRequest { + method: request.method().clone(), + page_url: format!("{}://{}{}", request.scheme(), request.host(), request.path()), + response_status: context.status.as_u16(), + origin_response_is_private: context.origin_private, + }; + match signer.admit(page) { + Ok(page) => Some(page), + Err(gate) => { + log::debug!("encypher: document not held, gate={}", gate.code()); + None + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/provenance/encypher/src/processor.rs b/crates/provenance/encypher/src/processor.rs new file mode 100644 index 0000000000..c97ea37013 --- /dev/null +++ b/crates/provenance/encypher/src/processor.rs @@ -0,0 +1,2520 @@ +//! Per-document signing flow: gates, hold, KV cache, sign call, outcomes, logging. +//! +//! A document moves through four phases: +//! +//! 1. **Admit** (the middleware's preparation, with no I/O): the request +//! gates decide whether the document is held at all. +//! 2. **Extract** (synchronous): at end of input the held document's article +//! boundary, visible text and text hash are computed. +//! 3. **Resolve** (the asynchronous finish): policy and plan caches, the policy +//! and sign calls, and cache writes. On runtimes with a timer every call is +//! raced against `max_hold_ms`, keeping a reserve for the skip write. +//! 4. **Render** (synchronous): the plan's markers and the manifest meta tag are +//! applied to the held document. +//! +//! Every module failure emits the held document unchanged. + +use std::io; +use std::pin::pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use bytes::Bytes; +use edgezero_core::body::Body as EdgeBody; +use futures::future::{Either, LocalBoxFuture, select}; +use http::{HeaderValue, Method, Request, StatusCode, header}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::api::{self, ApiError, EmbeddingPlan, SignRequest}; +use crate::boundary::{self, Boundary}; +use crate::{EncypherConfig, INTEGRATION_ID, embed, text}; +use trusted_server_core::integrations::ensure_integration_backend_with_timeout; +use trusted_server_core::platform::{ + KvError, PlatformHttpRequest, PlatformKvStore, PlatformTimer, RuntimeServices, +}; +use trusted_server_core::streaming_processor::{AsyncFinish, StreamProcessor}; + +const SIGN_PATH: &str = "/api/v1/public/cdn/sign"; +const POLICY_PATH: &str = "/api/v1/public/cdn/trusted-server/policy"; +const WORKER_TOKEN_HEADER: &str = "x-encypher-worker-token"; +/// Article plan and skip keys. Entries carry their own `expires_at`. +const CACHE_KEY_PREFIX: &str = "encypher:v3"; +/// Policy digest and policy skip keys. Entries carry their own `expires_at`. +const POLICY_KEY_PREFIX: &str = "encypher:policy:v2"; +/// Part of `max_hold_ms` kept back from resolve work so a skip can still be +/// written after the work runs out of time. +const SKIP_WRITE_RESERVE: Duration = Duration::from_millis(200); +/// How far past `max_hold_ms` the core backstop deadline lies. The module +/// bounds itself first; the backstop only catches an overrun. +const BACKSTOP_MARGIN: Duration = Duration::from_millis(1_000); + +/// Path prefixes never signed (ported from the Fastly worker's `shouldSkipPath`). +const SKIP_PATH_PREFIXES: &[&str] = &[ + "/api/", + "/wp-admin/", + "/wp-json/", + "/feed/", + "/sitemap", + "/.well-known/", +]; +/// Asset suffixes never signed (ported from the Fastly worker's `shouldSkipPath`). +const SKIP_PATH_SUFFIXES: &[&str] = &[ + ".xml", ".json", ".rss", ".atom", ".css", ".js", ".woff", ".woff2", ".png", ".jpg", ".gif", + ".svg", ".ico", +]; + +/// Set once this instance has warned that the runtime KV store is unbound. +static KV_UNAVAILABLE_WARNED: AtomicBool = AtomicBool::new(false); + +/// Why a document was not held. Evaluated in the middleware's preparation, +/// with no I/O. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Gate { + Method, + Status, + OriginPrivate, + InvalidPageUrl, + HostMismatch, + ExcludedPath, +} + +impl Gate { + pub(crate) fn code(self) -> &'static str { + match self { + Self::Method => "method", + Self::Status => "status", + Self::OriginPrivate => "origin_private", + Self::InvalidPageUrl => "invalid_page_url", + Self::HostMismatch => "host_mismatch", + Self::ExcludedPath => "excluded_path", + } + } +} + +/// What the gates are told about the page a document is the body of. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PageRequest { + /// The reader's request method. + pub(crate) method: Method, + /// The scheme, host and path the reader asked for, without the query, + /// which is the address the article is signed for. + pub(crate) page_url: String, + /// The status of the response the document is the body of. + pub(crate) response_status: u16, + /// Whether the origin marked its response as one reader's. + pub(crate) origin_response_is_private: bool, +} + +/// Reason code for a held document's outcome. Sign failures are also stored +/// as the reason of a negative (`skip`) cache entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Reason { + Signed, + CacheHit, + DocumentTooLarge, + NotUtf8, + NoBoundary, + AlreadySigned, + ManifestPresent, + TextTooShort, + KvUnavailable, + KvError, + Timeout, + CachedSkip, + CachedEntryInvalid, + CachedPlanInvalid, + RequestBuild, + Backend, + HttpError, + Forbidden, + Unprocessable, + RateLimited, + ServerError, + UnexpectedStatus, + ResponseTooLarge, + MalformedResponse, + PolicyChanged, + PolicyDigestMismatch, + QuotaExceeded, + SignRejected, + NoPlan, + InvalidPlan, + AlignmentFailed, +} + +impl Reason { + fn code(self) -> &'static str { + match self { + Self::Signed => "signed", + Self::CacheHit => "cache_hit", + Self::DocumentTooLarge => "document_too_large", + Self::NotUtf8 => "not_utf8", + Self::NoBoundary => "no_boundary", + Self::AlreadySigned => "already_signed", + Self::ManifestPresent => "manifest_present", + Self::TextTooShort => "text_too_short", + Self::KvUnavailable => "kv_unavailable", + Self::KvError => "kv_error", + Self::Timeout => "timeout", + Self::CachedSkip => "cached_skip", + Self::CachedEntryInvalid => "cached_entry_invalid", + Self::CachedPlanInvalid => "cached_plan_invalid", + Self::RequestBuild => "request_build", + Self::Backend => "backend", + Self::HttpError => "http_error", + Self::Forbidden => "forbidden", + Self::Unprocessable => "unprocessable", + Self::RateLimited => "rate_limited", + Self::ServerError => "server_error", + Self::UnexpectedStatus => "unexpected_status", + Self::ResponseTooLarge => "response_too_large", + Self::MalformedResponse => "malformed_response", + Self::PolicyChanged => "policy_changed", + Self::PolicyDigestMismatch => "policy_digest_mismatch", + Self::QuotaExceeded => "quota_exceeded", + Self::SignRejected => "sign_rejected", + Self::NoPlan => "no_plan", + Self::InvalidPlan => "invalid_plan", + Self::AlignmentFailed => "alignment_failed", + } + } +} + +/// A cache entry that records when it stops being usable. +trait Expiring { + /// Unix seconds at which the entry expires. + fn expires_at(&self) -> u64; + /// Whether the entry was written without a store TTL, so it stays until deleted. + fn ttl_less(&self) -> bool; + /// Record that the entry is written without a store TTL. + fn mark_ttl_less(&mut self); +} + +/// An article cache entry, keyed by credential context, policy and text hash. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum CacheEntry { + Plan { + plan: EmbeddingPlan, + manifest_url: Option, + expires_at: u64, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + ttl_less: bool, + }, + Skip { + reason: Reason, + expires_at: u64, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + ttl_less: bool, + }, +} + +impl Expiring for CacheEntry { + fn expires_at(&self) -> u64 { + match self { + Self::Plan { expires_at, .. } | Self::Skip { expires_at, .. } => *expires_at, + } + } + + fn ttl_less(&self) -> bool { + match self { + Self::Plan { ttl_less, .. } | Self::Skip { ttl_less, .. } => *ttl_less, + } + } + + fn mark_ttl_less(&mut self) { + match self { + Self::Plan { ttl_less, .. } | Self::Skip { ttl_less, .. } => *ttl_less = true, + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum PolicyEntry { + Digest { + policy_digest: String, + expires_at: u64, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + ttl_less: bool, + }, + Skip { + reason: Reason, + expires_at: u64, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + ttl_less: bool, + }, +} + +impl Expiring for PolicyEntry { + fn expires_at(&self) -> u64 { + match self { + Self::Digest { expires_at, .. } | Self::Skip { expires_at, .. } => *expires_at, + } + } + + fn ttl_less(&self) -> bool { + match self { + Self::Digest { ttl_less, .. } | Self::Skip { ttl_less, .. } => *ttl_less, + } + } + + fn mark_ttl_less(&mut self) { + match self { + Self::Digest { ttl_less, .. } | Self::Skip { ttl_less, .. } => *ttl_less = true, + } + } +} + +/// The one freshness predicate applied to every cache read. +fn is_fresh(expires_at: u64, now: u64) -> bool { + now < expires_at +} + +/// Current Unix time in seconds, from a clock that works on every adapter. +fn unix_now() -> u64 { + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) +} + +/// Configuration shared by every document processor. +pub(crate) struct Signer { + config: EncypherConfig, + max_document_bytes: usize, + sign_url: String, + policy_url: String, + policy_key: String, + /// First 16 hex characters of `sha256(api_base_url \n domain \n token)`. + cache_context: String, + /// Set by the first TTL write the store reports as unsupported, or by reading + /// an entry written without a TTL; from then on this instance writes without + /// a TTL and deletes expired entries it reads. + ttl_less: AtomicBool, +} + +impl Signer { + /// The signer for `config`, holding at most `max_document_bytes` of one + /// document. + pub(crate) fn new(config: EncypherConfig, max_document_bytes: usize) -> Self { + let sign_url = format!("{}{SIGN_PATH}", config.api_base_url.trim_end_matches('/')); + let policy_url = format!("{}{POLICY_PATH}", config.api_base_url.trim_end_matches('/')); + let context_hash = text::content_hash_hex(&format!( + "{}\n{}\n{}", + config.api_base_url, + config.domain, + config.worker_token.expose().trim() + )); + let cache_context = context_hash + .get(..16) + .unwrap_or(context_hash.as_str()) + .to_owned(); + Self { + config, + max_document_bytes, + sign_url, + policy_url, + policy_key: format!("{POLICY_KEY_PREFIX}:{cache_context}"), + cache_context, + ttl_less: AtomicBool::new(false), + } + } +} + +/// Holds the document of an admitted page until it ends. +pub(crate) fn document_processor(signer: &Arc, request: PageRequest) -> DocumentProcessor { + DocumentProcessor { + signer: Arc::clone(signer), + request, + held: Vec::new(), + state: DocumentState::Holding, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DocumentState { + /// Accumulating the document. + Holding, + /// Over the size limit: chunks pass through and nothing is signed. + PassingThrough, + /// The whole document was emitted as held output; it may be signed in the + /// asynchronous finish. + Complete, +} + +/// Holds one admitted document until end of input, then signs it in the +/// asynchronous finish or passes it through. +pub(crate) struct DocumentProcessor { + signer: Arc, + request: PageRequest, + held: Vec, + state: DocumentState, +} + +impl StreamProcessor for DocumentProcessor { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> Result, io::Error> { + if self.state != DocumentState::Holding { + return Ok(chunk.to_vec()); + } + if self.held.len().saturating_add(chunk.len()) > self.signer.max_document_bytes { + self.state = DocumentState::PassingThrough; + log_outcome(false, Reason::DocumentTooLarge); + let mut output = std::mem::take(&mut self.held); + output.extend_from_slice(chunk); + return Ok(output); + } + self.held.extend_from_slice(chunk); + if !is_last { + return Ok(Vec::new()); + } + // The complete document is the fail-open output; the driver owns it. + self.state = DocumentState::Complete; + Ok(std::mem::take(&mut self.held)) + } + + fn reset(&mut self) { + self.held.clear(); + self.state = DocumentState::Holding; + } + + fn async_finish<'a>( + &'a mut self, + held: &'a [u8], + services: &'a RuntimeServices, + ) -> Option> { + if self.state != DocumentState::Complete { + return None; + } + let extracted = std::str::from_utf8(held) + .map_err(|_| Reason::NotUtf8) + .and_then(|html| Ok((html, self.signer.extract(html)?))); + let (html, article) = match extracted { + Ok(extracted) => extracted, + Err(reason) => { + log_outcome(false, reason); + return None; + } + }; + let signer = &*self.signer; + let request = &self.request; + let deadline = + Duration::from_millis(signer.config.max_hold_ms).saturating_add(BACKSTOP_MARGIN); + Some(AsyncFinish::new(deadline, async move { + match signer.resolve(request, services, html, &article).await { + Ok((signed, reason)) => { + log_outcome(true, reason); + signed.into_bytes() + } + Err(reason) => { + log_outcome(false, reason); + held.to_vec() + } + } + })) + } +} + +/// The article found in a held document. +struct Article { + boundary: Boundary, + /// Visible text sent for signing, truncated to the API limit. + text: String, + /// Unicode scalars in `text`. + scalars: usize, +} + +/// Time a document's resolve may still take, on runtimes with a timer. +enum Deadline<'a> { + /// No timer: calls are bounded only by backend timeouts. + Unbounded, + /// The timer has not fired yet. + Running(LocalBoxFuture<'a, ()>), + /// The timer fired; every further race loses at once. + Expired, +} + +impl<'a> Deadline<'a> { + fn new(timer: Option<&'a dyn PlatformTimer>, budget: Duration) -> Self { + timer.map_or(Self::Unbounded, |timer| Self::Running(timer.sleep(budget))) + } + + /// Start the clock now. A timer's sleep starts on its first poll, which + /// would otherwise be the first call that does not complete at once. + async fn start(&mut self) { + if let Self::Running(sleep) = self + && futures::poll!(sleep.as_mut()).is_ready() + { + *self = Self::Expired; + } + } + + /// Run `work` to completion, or return `None` once the deadline passes. + async fn race(&mut self, work: impl Future) -> Option { + match self { + Self::Unbounded => return Some(work.await), + Self::Expired => return None, + Self::Running(sleep) => { + if let Either::Left((value, _)) = select(pin!(work), sleep.as_mut()).await { + return Some(value); + } + } + } + *self = Self::Expired; + None + } + + /// Whether the deadline has passed, so the last race lost. + fn expired(&self) -> bool { + matches!(self, Self::Expired) + } +} + +fn log_outcome(signed: bool, reason: Reason) { + if reason == Reason::KvUnavailable && !KV_UNAVAILABLE_WARNED.swap(true, Ordering::Relaxed) { + log::warn!( + "encypher: the runtime KV store is unavailable, so pages pass through unsigned; bind the adapter's KV store to enable signing" + ); + } + log::info!( + "encypher: outcome={} reason={}", + if signed { "signed" } else { "passed_through" }, + reason.code() + ); +} + +/// Whether `path` is one this module never signs: an API, feed, admin or +/// well-known path, or an asset. +pub(crate) fn is_excluded_path(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + SKIP_PATH_PREFIXES + .iter() + .any(|prefix| lower.starts_with(prefix)) + || SKIP_PATH_SUFFIXES + .iter() + .any(|suffix| lower.ends_with(suffix)) +} + +impl Signer { + /// Apply the request gates. Only an admitted document is held. + pub(crate) fn admit(&self, request: PageRequest) -> Result { + if request.method != Method::GET { + return Err(Gate::Method); + } + if request.response_status != StatusCode::OK.as_u16() { + return Err(Gate::Status); + } + if request.origin_response_is_private { + return Err(Gate::OriginPrivate); + } + let page_url = url::Url::parse(&request.page_url).map_err(|_| Gate::InvalidPageUrl)?; + let host = page_url.host_str().ok_or(Gate::InvalidPageUrl)?; + if host.strip_prefix("www.").unwrap_or(host) != self.config.domain { + return Err(Gate::HostMismatch); + } + if is_excluded_path(page_url.path()) { + return Err(Gate::ExcludedPath); + } + Ok(request) + } + + /// Find the article and its signable text, with no I/O. + fn extract(&self, html: &str) -> Result { + let boundary = boundary::find_article_boundary(html, Some(&self.config.article_selector)) + .ok_or(Reason::NoBoundary)?; + let article = html + .get(boundary.start..boundary.end) + .ok_or(Reason::NoBoundary)?; + if embed::has_existing_markers(article) { + return Err(Reason::AlreadySigned); + } + if embed::has_manifest_meta(html) { + return Err(Reason::ManifestPresent); + } + let mut visible = text::visible_text(article); + let sign_len = text::truncate_scalars(&visible, api::MAX_TEXT_SCALARS).len(); + visible.truncate(sign_len); + let scalars = text::scalar_count(&visible); + if scalars < self.config.min_text_length { + return Err(Reason::TextTooShort); + } + Ok(Article { + boundary, + text: visible, + scalars, + }) + } + + /// Unix time at which an entry written now with `ttl_seconds` expires. + fn expires_at(ttl_seconds: u64) -> u64 { + unix_now().saturating_add(ttl_seconds) + } + + /// Bound for skip writes made after resolve work stopped. + fn skip_deadline<'a>(services: &'a RuntimeServices) -> Deadline<'a> { + Deadline::new(services.timer(), SKIP_WRITE_RESERVE) + } + + /// Resolve the plan for `article` and render it into `html`. + async fn resolve( + &self, + request: &PageRequest, + services: &RuntimeServices, + html: &str, + article: &Article, + ) -> Result<(String, Reason), Reason> { + let kv = services.kv_store(); + let mut work = Deadline::new( + services.timer(), + Duration::from_millis(self.config.max_hold_ms).saturating_sub(SKIP_WRITE_RESERVE), + ); + work.start().await; + + let policy_digest = self.policy_digest(request, services, kv, &mut work).await?; + let key = format!( + "{CACHE_KEY_PREFIX}:{}:{policy_digest}:{}", + self.cache_context, + text::content_hash_hex(&article.text) + ); + let outcome = match self.read::(kv, &key, &mut work).await { + Ok(Some(CacheEntry::Plan { + plan, manifest_url, .. + })) => { + api::validate_plan(&plan, manifest_url.as_deref(), article.scalars) + .map_err(|_| Reason::CachedPlanInvalid)?; + return render(html, &article.boundary, &plan, manifest_url.as_deref()) + .map(|signed| (signed, Reason::CacheHit)) + .ok_or(Reason::CachedPlanInvalid); + } + Ok(Some(CacheEntry::Skip { .. })) => return Err(Reason::CachedSkip), + Err(Reason::Timeout) => Err(Reason::Timeout), + Err(reason) => return Err(reason), + Ok(None) => self + .request_plan(request, services, html, article, &policy_digest, &mut work) + .await + .and_then(|(plan, manifest_url)| { + api::validate_plan(&plan, manifest_url.as_deref(), article.scalars) + .map_err(|_| Reason::InvalidPlan)?; + let signed = render(html, &article.boundary, &plan, manifest_url.as_deref()) + .ok_or(Reason::AlignmentFailed)?; + Ok((signed, plan, manifest_url)) + }), + }; + match outcome { + Ok((signed, plan, manifest_url)) => { + // A plan write that runs out of time is abandoned; the page is still signed. + let entry = CacheEntry::Plan { + plan, + manifest_url, + expires_at: Self::expires_at(self.config.plan_ttl_seconds), + ttl_less: false, + }; + self.write(kv, &key, entry, self.config.plan_ttl_seconds, &mut work) + .await; + Ok((signed, Reason::Signed)) + } + Err(reason) => { + let mut deadline = Self::skip_deadline(services); + if matches!(reason, Reason::PolicyChanged | Reason::PolicyDigestMismatch) { + self.evict_policy(kv, &mut deadline).await; + } + self.write_skip(kv, &key, reason, article.scalars, &mut deadline) + .await; + Err(reason) + } + } + } + + async fn policy_digest( + &self, + request: &PageRequest, + services: &RuntimeServices, + kv: &dyn PlatformKvStore, + work: &mut Deadline<'_>, + ) -> Result { + let result = match self.read::(kv, &self.policy_key, work).await { + Ok(Some(PolicyEntry::Digest { policy_digest, .. })) + if api::valid_policy_digest(&policy_digest) => + { + return Ok(policy_digest); + } + Ok(Some(PolicyEntry::Skip { .. })) => return Err(Reason::CachedSkip), + Ok(Some(PolicyEntry::Digest { .. })) => Err(Reason::CachedEntryInvalid), + // Nothing can be cached without a store. + Err(Reason::KvUnavailable) => return Err(Reason::KvUnavailable), + Err(reason) => Err(reason), + Ok(None) => self.request_policy(request, services, work).await, + }; + let reason = match result { + Ok(policy_digest) => { + let entry = PolicyEntry::Digest { + policy_digest: policy_digest.clone(), + expires_at: Self::expires_at(self.config.policy_ttl_seconds), + ttl_less: false, + }; + self.write( + kv, + &self.policy_key, + entry, + self.config.policy_ttl_seconds, + work, + ) + .await; + if !work.expired() { + return Ok(policy_digest); + } + // The digest is not cached, so the next view could not find a + // plan skip written under it; skip at the policy level instead. + Reason::Timeout + } + Err(reason) => reason, + }; + let ttl = self.policy_skip_ttl(); + let entry = PolicyEntry::Skip { + reason, + expires_at: Self::expires_at(ttl), + ttl_less: false, + }; + self.write( + kv, + &self.policy_key, + entry, + ttl, + &mut Self::skip_deadline(services), + ) + .await; + Err(reason) + } + + fn policy_skip_ttl(&self) -> u64 { + self.config + .policy_ttl_seconds + .min(self.config.negative_ttl_seconds) + } + + async fn request_policy( + &self, + request: &PageRequest, + services: &RuntimeServices, + work: &mut Deadline<'_>, + ) -> Result { + let mut url = url::Url::parse(&self.policy_url).map_err(|_| Reason::RequestBuild)?; + url.query_pairs_mut() + .append_pair("page_url", &request.page_url); + let body = self + .fetch( + services, + Method::GET, + url.as_str(), + EdgeBody::empty(), + api::MAX_POLICY_RESPONSE_BYTES, + work, + ) + .await?; + api::parse_policy_response(&body).map_err(|error| parse_error_reason(&error)) + } + + async fn evict_policy(&self, kv: &dyn PlatformKvStore, deadline: &mut Deadline<'_>) { + if matches!( + deadline.race(kv.delete(&self.policy_key)).await, + Some(Ok(())) + ) { + return; + } + // If deletion fails, try to replace the stale digest with a bounded skip. + let ttl = self.policy_skip_ttl(); + let entry = PolicyEntry::Skip { + reason: Reason::PolicyChanged, + expires_at: Self::expires_at(ttl), + ttl_less: false, + }; + self.write(kv, &self.policy_key, entry, ttl, deadline).await; + log::debug!("encypher: policy eviction failed"); + } + + /// Call the sign API once. Every failure maps to a reason code. + async fn request_plan( + &self, + request: &PageRequest, + services: &RuntimeServices, + html: &str, + article: &Article, + policy_digest: &str, + work: &mut Deadline<'_>, + ) -> Result<(EmbeddingPlan, Option), Reason> { + let title = text::extract_title(html); + let body = serde_json::to_vec(&SignRequest { + text: &article.text, + page_url: &request.page_url, + document_title: title.as_deref(), + boundary_selector: &article.boundary.selector, + expected_policy_digest: policy_digest, + }) + .map_err(|_| Reason::RequestBuild)?; + let body = self + .fetch( + services, + Method::POST, + &self.sign_url, + EdgeBody::from(body), + api::MAX_RESPONSE_BYTES, + work, + ) + .await?; + let parsed = api::parse_sign_response(&body).map_err(|error| parse_error_reason(&error))?; + if !parsed.success { + return Err(if parsed.error.as_deref() == Some("quota_exceeded") { + Reason::QuotaExceeded + } else { + Reason::SignRejected + }); + } + if parsed.policy_digest.as_deref() != Some(policy_digest) { + return Err(Reason::PolicyDigestMismatch); + } + let plan = parsed.embedding_plan.ok_or(Reason::NoPlan)?; + Ok((plan, parsed.manifest_url)) + } + + /// One bounded HTTP call; never retried. + async fn fetch( + &self, + services: &RuntimeServices, + method: Method, + url: &str, + body: EdgeBody, + max_response_bytes: usize, + work: &mut Deadline<'_>, + ) -> Result { + let mut token = HeaderValue::from_str(self.config.worker_token.expose().trim()) + .map_err(|_| Reason::RequestBuild)?; + token.set_sensitive(true); + let http_request = Request::builder() + .method(method) + .uri(url) + .header(header::CONTENT_TYPE, "application/json") + .header(WORKER_TOKEN_HEADER, token) + .body(body) + .map_err(|_| Reason::RequestBuild)?; + let backend = ensure_integration_backend_with_timeout( + services, + &self.config.api_base_url, + INTEGRATION_ID, + Duration::from_millis(self.config.timeout_ms), + ) + .map_err(|_| Reason::Backend)?; + + let response = match work + .race( + services + .http_client() + .send(PlatformHttpRequest::new(http_request, backend)), + ) + .await + { + None => return Err(Reason::Timeout), + Some(Err(_)) => return Err(Reason::HttpError), + Some(Ok(response)) => response.response, + }; + let status = response.status(); + if status != StatusCode::OK { + return Err(match status.as_u16() { + 403 => Reason::Forbidden, + 409 => Reason::PolicyChanged, + 422 => Reason::Unprocessable, + 429 => Reason::RateLimited, + 500..=599 => Reason::ServerError, + _ => Reason::UnexpectedStatus, + }); + } + // One byte over the limit is enough for the parser to report TooLarge. + match work + .race( + response + .into_body() + .into_bytes_bounded(max_response_bytes.saturating_add(1)), + ) + .await + { + None => Err(Reason::Timeout), + Some(Err(_)) => Err(Reason::ResponseTooLarge), + Some(Ok(body)) => Ok(body), + } + } + + /// Read a fresh entry. Expired entries are misses; in TTL-less write mode + /// they are also deleted, best effort, so the store cleans itself. + async fn read( + &self, + kv: &dyn PlatformKvStore, + key: &str, + deadline: &mut Deadline<'_>, + ) -> Result, Reason> { + let bytes = match deadline.race(kv.get_bytes(key)).await { + None => return Err(Reason::Timeout), + Some(Err(KvError::Unavailable)) => return Err(Reason::KvUnavailable), + Some(Err(_)) => return Err(Reason::KvError), + Some(Ok(None)) => return Ok(None), + Some(Ok(Some(bytes))) => bytes, + }; + let entry: T = serde_json::from_slice(&bytes).map_err(|_| Reason::CachedEntryInvalid)?; + // An entry written without a TTL shows the mode to an instance that has + // not written yet, as on Spin, which instantiates the module per request. + if entry.ttl_less() { + self.ttl_less.store(true, Ordering::Relaxed); + } + if is_fresh(entry.expires_at(), unix_now()) { + return Ok(Some(entry)); + } + if self.ttl_less.load(Ordering::Relaxed) + && !matches!(deadline.race(kv.delete(key)).await, Some(Ok(()))) + { + log::debug!("encypher: expired cache entry could not be deleted"); + } + Ok(None) + } + + /// Best-effort cache write; failures are logged and ignored. + /// + /// Writes carry a TTL until the store first reports TTL writes as + /// unsupported; from then on this instance writes without one, and marks + /// the entry so a later instance that reads it knows the mode too. + async fn write( + &self, + kv: &dyn PlatformKvStore, + key: &str, + mut entry: E, + ttl_seconds: u64, + deadline: &mut Deadline<'_>, + ) { + if !self.ttl_less.load(Ordering::Relaxed) { + let Some(value) = encode(&entry) else { + return; + }; + let ttl = Duration::from_secs(ttl_seconds); + match deadline.race(kv.put_bytes_with_ttl(key, value, ttl)).await { + Some(Ok(())) => return, + Some(Err(KvError::Unsupported { .. })) => { + self.ttl_less.store(true, Ordering::Relaxed); + log::info!( + "encypher: the KV store does not support TTL writes; entries expire by their expires_at" + ); + } + Some(Err(_)) => { + log::debug!("encypher: cache write failed"); + return; + } + None => { + log::debug!("encypher: cache write timed out"); + return; + } + } + } + entry.mark_ttl_less(); + let Some(value) = encode(&entry) else { + return; + }; + match deadline.race(kv.put_bytes(key, value)).await { + Some(Ok(())) => {} + Some(Err(_)) => log::debug!("encypher: cache write failed"), + None => log::debug!("encypher: cache write timed out"), + } + } + + /// Write a negative entry unless a fresh, valid plan is already cached for the key. + /// + /// The KV trait has no insert-if-absent, so this re-reads first: a concurrent + /// successful sign for the same text keeps its plan. + async fn write_skip( + &self, + kv: &dyn PlatformKvStore, + key: &str, + reason: Reason, + scalars: usize, + deadline: &mut Deadline<'_>, + ) { + if let Ok(Some(CacheEntry::Plan { + plan, manifest_url, .. + })) = self.read::(kv, key, deadline).await + && api::validate_plan(&plan, manifest_url.as_deref(), scalars).is_ok() + { + return; + } + let ttl = self.config.negative_ttl_seconds; + let entry = CacheEntry::Skip { + reason, + expires_at: Self::expires_at(ttl), + ttl_less: false, + }; + self.write(kv, key, entry, ttl, deadline).await; + } +} + +/// Serialize a cache entry; a failure is logged and the write skipped. +fn encode(entry: &impl Serialize) -> Option { + match serde_json::to_vec(entry) { + Ok(value) => Some(Bytes::from(value)), + Err(_) => { + log::debug!("encypher: cache entry could not be serialized"); + None + } + } +} + +/// Apply a plan that passed [`api::validate_plan`] against the signed text, and +/// add the manifest meta tag when a manifest URL was returned. +fn render( + html: &str, + boundary: &Boundary, + plan: &EmbeddingPlan, + manifest_url: Option<&str>, +) -> Option { + let signed = embed::apply_plan(html, boundary, plan)?; + Some(match manifest_url { + Some(url) => embed::inject_manifest_meta(&signed, url), + None => signed, + }) +} + +fn parse_error_reason(error: &ApiError) -> Reason { + match error { + ApiError::TooLarge => Reason::ResponseTooLarge, + ApiError::Malformed => Reason::MalformedResponse, + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + use std::sync::atomic::AtomicUsize; + use std::sync::{LazyLock, Mutex}; + use std::task::Poll; + + use error_stack::Report; + use serde_json::json; + + use super::*; + use trusted_server_core::platform::test_support::{ + StoppedTimer, StubHttpClient, build_services_with_http_client, + }; + use trusted_server_core::platform::{ + PlatformError, PlatformHttpClient, PlatformPendingRequest, PlatformResponse, + PlatformSelectResult, + }; + + const TOKEN: &str = "cwk_test_worker_token_value"; + const PAGE_URL: &str = "https://www.publisher.example.com/news/harbor-vote"; + const MARKER: &str = "\u{E0100}\u{E0101}\u{FE01}"; + const MANIFEST_URL: &str = "https://api.example.com/manifests/doc-1"; + const POLICY_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const ARTICLE: &str = "Harbor Council Votes\ +

Harbor council votes on the ferry schedule

\ +

The harbor council met on Tuesday evening and approved a revised ferry schedule \ + for the coming season.

Residents asked for earlier departures on weekdays.

\ +
Footer
"; + /// Simulated time that passes each time a [`TickTimer`] sleep is polled. + const TICK: Duration = Duration::from_millis(100); + + // --- test doubles ------------------------------------------------------- + + /// Return `Pending` `times` times, waking the task each time, then `Ready`. + async fn yield_pending(times: usize) { + for _ in 0..times { + let mut yielded = false; + futures::future::poll_fn(|cx| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }) + .await; + } + } + + /// A timer on simulated time: each poll of a sleep advances it by [`TICK`]. + /// Sleeps only advance while the work they race is pending, as on a real + /// runtime where the clock runs while I/O is outstanding. + #[derive(Default)] + struct TickTimer { + requested: Mutex>, + } + + #[async_trait::async_trait(?Send)] + impl PlatformTimer for TickTimer { + async fn sleep(&self, duration: Duration) { + self.requested + .lock() + .expect("should lock requested durations") + .push(duration); + let ticks = duration.as_millis().div_ceil(TICK.as_millis()); + yield_pending(usize::try_from(ticks).expect("ticks should fit")).await; + } + } + + /// In-memory KV store that counts calls and can fail, stall or refuse TTLs. + #[derive(Default)] + struct MemoryKv { + entries: Mutex>, + ttls: Mutex>, + deletes: Mutex>, + reads: AtomicUsize, + ttl_writes: AtomicUsize, + /// Plan-key reads that report a miss before reads see the stored entry. + hidden_reads: AtomicUsize, + /// Every read fails with [`KvError::Unavailable`], as an unbound store does. + unavailable: bool, + /// Every read fails with an error other than unavailability. + fail_reads: bool, + /// Every read never completes. + hung_reads: bool, + /// Writes to keys with this prefix never complete. + hung_writes_to: Option<&'static str>, + /// The first write to this exact key never completes; later writes succeed. + hung_first_write_to: Option, + hung_first_write_taken: AtomicBool, + /// TTL writes fail with [`KvError::Unsupported`], as on Spin. + ttl_unsupported: bool, + /// `Pending` polls before every operation completes. + pending_polls: usize, + } + + impl MemoryKv { + fn entries(&self) -> HashMap { + self.entries.lock().expect("should lock entries").clone() + } + + fn entry(&self, key: &str) -> serde_json::Value { + serde_json::from_slice(&self.entries()[key]).expect("should store JSON") + } + + fn insert(&self, key: &str, value: &serde_json::Value) { + self.entries.lock().expect("should lock entries").insert( + key.to_owned(), + Bytes::from(serde_json::to_vec(value).expect("should serialize")), + ); + } + + fn remove(&self, key: &str) { + self.entries + .lock() + .expect("should lock entries") + .remove(key); + } + + /// The single article entry under the current plan namespace. + fn only_entry(&self) -> (String, serde_json::Value) { + let entries: Vec<_> = self + .entries() + .into_keys() + .filter(|key| key.starts_with("encypher:v3:")) + .collect(); + assert_eq!(entries.len(), 1, "should hold exactly one article entry"); + let key = entries.into_iter().next().expect("should have an entry"); + let value = self.entry(&key); + (key, value) + } + + fn reads(&self) -> usize { + self.reads.load(Ordering::SeqCst) + } + + async fn store(&self, key: &str, value: Bytes) -> Result<(), KvError> { + yield_pending(self.pending_polls).await; + if self + .hung_writes_to + .is_some_and(|prefix| key.starts_with(prefix)) + || (self.hung_first_write_to.as_deref() == Some(key) + && !self.hung_first_write_taken.swap(true, Ordering::SeqCst)) + { + futures::future::pending::<()>().await; + } + self.entries + .lock() + .expect("should lock entries") + .insert(key.to_owned(), value); + Ok(()) + } + } + + #[async_trait::async_trait(?Send)] + impl PlatformKvStore for MemoryKv { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + self.reads.fetch_add(1, Ordering::SeqCst); + yield_pending(self.pending_polls).await; + if self.hung_reads { + futures::future::pending::<()>().await; + } + if self.unavailable { + return Err(KvError::Unavailable); + } + if self.fail_reads { + return Err(KvError::LimitExceeded { + message: "test failure".to_owned(), + }); + } + if key.starts_with(CACHE_KEY_PREFIX) + && self + .hidden_reads + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)) + .is_ok() + { + return Ok(None); + } + Ok(self + .entries + .lock() + .expect("should lock entries") + .get(key) + .cloned()) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.store(key, value).await + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + ttl: Duration, + ) -> Result<(), KvError> { + self.ttl_writes.fetch_add(1, Ordering::SeqCst); + if self.ttl_unsupported { + return Err(KvError::Unsupported { + operation: "put_bytes_with_ttl".to_owned(), + }); + } + self.ttls + .lock() + .expect("should lock TTLs") + .insert(key.to_owned(), ttl); + self.store(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + yield_pending(self.pending_polls).await; + self.deletes + .lock() + .expect("should lock deletes") + .push(key.to_owned()); + self.remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(edgezero_core::key_value_store::KvPage::default()) + } + } + + /// HTTP client whose send never completes. + #[derive(Default)] + struct HungHttpClient { + sends: AtomicUsize, + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for HungHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + self.sends.fetch_add(1, Ordering::SeqCst); + futures::future::pending().await + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient)) + } + } + + /// Wraps [`StubHttpClient`] so the send and the response body each return + /// `Pending` before they are ready, as on Cloudflare, Spin and Axum. + struct YieldingHttpClient { + inner: Arc, + pending_polls: usize, + } + + #[async_trait::async_trait(?Send)] + impl PlatformHttpClient for YieldingHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + yield_pending(self.pending_polls).await; + let response = self.inner.send(request).await?; + let (parts, body) = response.response.into_parts(); + let bytes = body + .into_bytes_bounded(usize::MAX) + .await + .expect("should read the stub body"); + let polls = self.pending_polls; + let body = EdgeBody::stream(futures::stream::once(async move { + yield_pending(polls).await; + bytes + })); + Ok(PlatformResponse::new(http::Response::from_parts( + parts, body, + ))) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient)) + } + } + + /// Records log lines, with their level, emitted on the current thread. + struct ThreadLogger; + + thread_local! { + static LOGS: RefCell> = const { RefCell::new(Vec::new()) }; + } + + impl log::Log for ThreadLogger { + fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool { + true + } + + fn log(&self, record: &log::Record<'_>) { + LOGS.with(|logs| { + logs.borrow_mut() + .push(format!("{} {}", record.level(), record.args())); + }); + } + + fn flush(&self) {} + } + + static LOGGER: ThreadLogger = ThreadLogger; + + /// A sign-failure case: name and the API response, `None` for a transport error. + type FailureCase = (&'static str, Option<(u16, Vec)>); + /// A gate case: name, signer, and the request mutation that trips the gate. + type GateCase<'a> = (&'static str, &'a Arc, fn(&mut PageRequest)); + + /// Runs `run` with this thread's log lines captured. Returns `None`, after + /// skipping `run`, when another logger already owns the process. + fn capture_logs(run: impl FnOnce()) -> Option> { + static INSTALLED: LazyLock = LazyLock::new(|| { + let installed = log::set_logger(&LOGGER).is_ok(); + if installed { + log::set_max_level(log::LevelFilter::Trace); + } + installed + }); + if !*INSTALLED { + return None; + } + LOGS.with(|logs| logs.borrow_mut().clear()); + run(); + Some(LOGS.with(RefCell::take)) + } + + // --- fixtures ----------------------------------------------------------- + + fn config_json(token: &str) -> serde_json::Value { + json!({ + "api_base_url": "https://api.example.com", + "domain": "publisher.example.com", + "worker_token": token, + }) + } + + fn factory_with(config: serde_json::Value, max_document_bytes: usize) -> Arc { + let config: EncypherConfig = serde_json::from_value(config).expect("should parse config"); + Arc::new(Signer::new(config, max_document_bytes)) + } + + fn factory() -> Arc { + factory_with(config_json(TOKEN), 5_000_000) + } + + fn policy_key() -> String { + factory().policy_key.clone() + } + + /// Services without a timer, as on Fastly. + fn services(http: Arc, kv: &Arc) -> RuntimeServices { + build_services_with_http_client(http) + .with_kv_store(Arc::clone(kv) as Arc) + } + + /// Services with a [`TickTimer`], as on Cloudflare, Spin and Axum. + fn timed_services(http: Arc, kv: &Arc) -> RuntimeServices { + services(http, kv).with_timer(Arc::new(TickTimer::default())) + } + + fn request() -> PageRequest { + PageRequest { + method: Method::GET, + page_url: PAGE_URL.to_owned(), + response_status: 200, + origin_response_is_private: false, + } + } + + fn plan_json(marker: &str) -> serde_json::Value { + json!({ + "index_unit": "codepoint", + "operations": [{ "insert_after_index": 5, "marker": marker }], + }) + } + + fn sign_response(marker: &str) -> Vec { + sign_response_for_policy(marker, POLICY_DIGEST) + } + + fn policy_response(digest: &str) -> Vec { + serde_json::to_vec(&json!({"policy_digest": digest})).expect("should serialize policy") + } + + fn sign_response_for_policy(marker: &str, digest: &str) -> Vec { + serde_json::to_vec(&json!({ + "success": true, + "policy_digest": digest, + "embedding_plan": plan_json(marker), + "manifest_url": MANIFEST_URL, + "verification_url": "https://api.example.com/verify/doc-1", + "document_id": "doc-1", + })) + .expect("should serialize sign response") + } + + fn signing_http() -> Arc { + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + http.push_response(200, sign_response(MARKER)); + http + } + + fn seed_plan(kv: &Arc) -> String { + view(&factory(), &services(signing_http(), kv)) + } + + /// Drive one document the way the middleware and the publisher driver do: + /// the gates on the request, when there is one, then two chunks, then the + /// asynchronous finish with the core backstop. A request the gates refuse, + /// or none, holds nothing, so the document passes through. + fn run( + factory: &Arc, + request: Option, + services: &RuntimeServices, + html: &str, + ) -> String { + let Some(request) = request.and_then(|request| factory.admit(request).ok()) else { + return html.to_owned(); + }; + futures::executor::block_on(async { + let mut processor = document_processor(factory, request); + let (first, second) = html.as_bytes().split_at(html.len() / 2); + let mut output = processor + .process_chunk(first, false) + .expect("should process first chunk"); + output.extend( + processor + .finish(second, services) + .await + .expect("should finish the document"), + ); + String::from_utf8(output).expect("should emit UTF-8") + }) + } + + /// One view of [`ARTICLE`] at [`PAGE_URL`]. + fn view(factory: &Arc, services: &RuntimeServices) -> String { + run(factory, Some(request()), services, ARTICLE) + } + + fn assert_signed(output: &str) { + assert!(output.contains(MARKER), "should embed the plan marker"); + assert!( + output.contains("c2pa-manifest-url") && output.contains(MANIFEST_URL), + "should inject the manifest meta tag" + ); + assert_eq!( + output.matches(MARKER).count(), + 1, + "should add the marker once" + ); + } + + /// Rewrite the `expires_at` of the entry at `key` to `expires_at`. + fn set_expiry(kv: &MemoryKv, key: &str, expires_at: u64) { + let mut entry = kv.entry(key); + entry["expires_at"] = json!(expires_at); + kv.insert(key, &entry); + } + + fn expired() -> u64 { + unix_now().saturating_sub(1) + } + + // --- policy ------------------------------------------------------------- + + #[test] + fn policy_failures_are_negatively_cached_without_using_an_old_plan() { + let cases: Vec = vec![ + ("unavailable", Some((503, Vec::new()))), + ("forbidden", Some((403, Vec::new()))), + ("transport", None), + ("malformed", Some((200, b"not json".to_vec()))), + ("missing digest", Some((200, b"{}".to_vec()))), + ( + "uppercase", + Some((200, policy_response(&POLICY_DIGEST.to_uppercase()))), + ), + ( + "oversized", + Some((200, vec![b' '; api::MAX_POLICY_RESPONSE_BYTES + 2])), + ), + ]; + for (case, response) in cases { + let kv = Arc::new(MemoryKv::default()); + seed_plan(&kv); + kv.remove(&policy_key()); + let http = Arc::new(StubHttpClient::new()); + if let Some((status, body)) = response { + http.push_response(status, body); + } + let services = services(Arc::clone(&http) as Arc, &kv); + for _ in 0..2 { + assert_eq!(view(&factory(), &services), ARTICLE, "{case}"); + } + assert_eq!(http.recorded_request_methods(), ["GET"], "{case}"); + assert_eq!(kv.entry(&policy_key())["kind"], "skip", "{case}"); + assert_eq!( + kv.ttls.lock().expect("lock")[&policy_key()], + Duration::from_secs(300) + ); + // Once the skip is gone, refresh may reuse only the matching old plan. + kv.remove(&policy_key()); + http.push_response(200, policy_response(POLICY_DIGEST)); + assert_signed(&view(&factory(), &services)); + assert_eq!(http.recorded_request_methods(), ["GET", "GET"]); + } + } + + #[test] + fn malformed_cached_policy_cannot_unlock_a_cached_article() { + let future = unix_now() + 300; + for value in [ + json!("not an entry"), + json!({"kind": "digest", "policy_digest": null, "expires_at": future}), + json!({"kind": "digest", "policy_digest": "bad", "expires_at": future}), + json!({ + "kind": "digest", "policy_digest": POLICY_DIGEST, "expires_at": future, + "extra": true, + }), + // `expires_at` is required. + json!({"kind": "digest", "policy_digest": POLICY_DIGEST}), + ] { + let kv = Arc::new(MemoryKv::default()); + seed_plan(&kv); + kv.insert(&policy_key(), &value); + let http = Arc::new(StubHttpClient::new()); + let services = services(Arc::clone(&http) as Arc, &kv); + for _ in 0..2 { + assert_eq!(view(&factory(), &services), ARTICLE, "{value}"); + } + assert!(http.recorded_request_methods().is_empty(), "{value}"); + } + } + + #[test] + fn policy_cache_hit_and_article_or_policy_changes_select_the_correct_plan() { + let kv = Arc::new(MemoryKv::default()); + seed_plan(&kv); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, sign_response(MARKER)); + let services = services(Arc::clone(&http) as Arc, &kv); + let changed = ARTICLE.replace("Tuesday", "Wednesday"); + assert_signed(&run(&factory(), Some(request()), &services, &changed)); + assert_eq!(http.recorded_request_methods(), ["POST"]); + + kv.remove(&policy_key()); + let next_digest = "b".repeat(64); + http.push_response(200, policy_response(&next_digest)); + http.push_response(200, sign_response_for_policy(MARKER, &next_digest)); + assert_signed(&view(&factory(), &services)); + assert_eq!(http.recorded_request_methods(), ["POST", "GET", "POST"]); + let count = http.recorded_request_methods().len(); + assert_signed(&view(&factory(), &services)); + assert_eq!(http.recorded_request_methods().len(), count); + // Returning to the original policy selects its original plan, not the intervening one. + kv.remove(&policy_key()); + http.push_response(200, policy_response(POLICY_DIGEST)); + assert_signed(&view(&factory(), &services)); + assert_eq!( + http.recorded_request_methods(), + ["POST", "GET", "POST", "GET"] + ); + } + + #[test] + fn conflict_or_response_digest_mismatch_evicts_policy_without_retrying() { + let mut missing: serde_json::Value = + serde_json::from_slice(&sign_response(MARKER)).expect("JSON"); + missing + .as_object_mut() + .expect("object") + .remove("policy_digest"); + for (status, body) in [ + ( + 409, + br#"{"detail":"TRUSTED_SERVER_POLICY_CHANGED"}"#.to_vec(), + ), + (409, b"unparseable conflict".to_vec()), + (200, sign_response_for_policy(MARKER, &"b".repeat(64))), + (200, sign_response_for_policy(MARKER, "invalid")), + (200, serde_json::to_vec(&missing).expect("JSON")), + ] { + let kv = Arc::new(MemoryKv::default()); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + http.push_response(status, body); + let services = services(Arc::clone(&http) as Arc, &kv); + assert_eq!(view(&factory(), &services), ARTICLE); + assert_eq!(http.recorded_request_methods(), ["GET", "POST"]); + assert!(!kv.entries().contains_key(&policy_key())); + assert_eq!(kv.only_entry().1["kind"], "skip"); + // Next request refetches policy, but the failed article remains negatively cached. + http.push_response(200, policy_response(POLICY_DIGEST)); + assert_eq!(view(&factory(), &services), ARTICLE); + assert_eq!(http.recorded_request_methods(), ["GET", "POST", "GET"]); + } + } + + #[test] + fn entries_expire_at_their_ttl_and_the_store_ttl_matches() { + let kv = Arc::new(MemoryKv::default()); + let before = unix_now(); + seed_plan(&kv); + let after = unix_now(); + let ttls = kv.ttls.lock().expect("lock").clone(); + let (plan_key, plan) = kv.only_entry(); + for (key, entry, ttl) in [ + (policy_key(), kv.entry(&policy_key()), 300), + (plan_key, plan, 604_800), + ] { + assert_eq!(ttls[&key], Duration::from_secs(ttl)); + let expires_at = entry["expires_at"] + .as_u64() + .expect("should carry expires_at"); + assert!( + (before + ttl..=after + ttl).contains(&expires_at), + "{key} should expire {ttl}s after it was written" + ); + } + for (policy_ttl, negative_ttl, expected) in [(60, 300, 60), (300, 60, 60)] { + let kv = Arc::new(MemoryKv::default()); + let http = Arc::new(StubHttpClient::new()); + http.push_response(503, Vec::new()); + let mut config = config_json(TOKEN); + config["policy_ttl_seconds"] = json!(policy_ttl); + config["negative_ttl_seconds"] = json!(negative_ttl); + let factory = factory_with(config, 5_000_000); + assert_eq!(view(&factory, &services(http, &kv)), ARTICLE); + assert_eq!( + kv.ttls.lock().expect("lock")[&factory.policy_key], + Duration::from_secs(expected) + ); + } + } + + // --- sign path and cache ------------------------------------------------ + + #[test] + fn signs_on_a_miss_with_the_worker_token_and_caches_the_plan() { + let http = signing_http(); + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&http) as Arc, &kv); + + let output = view(&factory(), &services); + + assert_signed(&output); + assert_eq!(http.recorded_request_methods(), ["GET", "POST"]); + assert_eq!( + http.recorded_request_uris(), + [ + format!( + "https://api.example.com/api/v1/public/cdn/trusted-server/policy?page_url={}", + url::form_urlencoded::byte_serialize(PAGE_URL.as_bytes()).collect::() + ), + "https://api.example.com/api/v1/public/cdn/sign".to_owned(), + ] + ); + let headers = &http.recorded_request_headers()[1]; + let header = |name: &str| { + headers + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + }; + assert_eq!(header("x-encypher-worker-token"), Some(TOKEN)); + assert_eq!(header("content-type"), Some("application/json")); + assert_eq!( + header("idempotency-key"), + None, + "should send no idempotency key" + ); + let body: serde_json::Value = serde_json::from_slice(&http.recorded_request_bodies()[1]) + .expect("should send a JSON body"); + assert_eq!(body["page_url"], PAGE_URL); + assert_eq!(body["expected_policy_digest"], POLICY_DIGEST); + assert_eq!(body["document_title"], "Harbor Council Votes"); + assert_eq!(body["boundary_selector"], "article"); + let sent_text = body["text"].as_str().expect("should send text"); + assert!( + sent_text.starts_with("Harbor council votes on the ferry schedule The harbor council"), + "should send the article's visible text, got {sent_text}" + ); + assert!(!sent_text.contains("Footer") && !sent_text.contains("Home")); + let (key, entry) = kv.only_entry(); + assert!( + key.starts_with("encypher:v3:") && key.contains(POLICY_DIGEST), + "unexpected cache key {key}" + ); + assert!(!key.contains(TOKEN), "cache key must not contain the token"); + assert!( + policy_key().starts_with("encypher:policy:v2:"), + "unexpected policy key" + ); + assert_eq!(entry["kind"], "plan"); + assert_eq!(entry["plan"], plan_json(MARKER)); + } + + #[test] + fn a_cached_plan_signs_without_calling_the_api() { + let kv = Arc::new(MemoryKv::default()); + let first = seed_plan(&kv); + + let http = Arc::new(StubHttpClient::new()); + let second = view( + &factory(), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!( + second, first, + "a cache hit should serve the same signed page" + ); + assert!( + http.recorded_backend_names().is_empty(), + "should make no API call" + ); + } + + #[test] + fn a_cached_skip_passes_through_without_calling_the_api() { + let kv = Arc::new(MemoryKv::default()); + let first_http = Arc::new(StubHttpClient::new()); + first_http.push_response(200, policy_response(POLICY_DIGEST)); + first_http.push_response(403, b"{}".to_vec()); + view(&factory(), &services(first_http, &kv)); + assert_eq!(kv.only_entry().1["kind"], "skip"); + + let http = Arc::new(StubHttpClient::new()); + let output = view( + &factory(), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE); + assert!( + http.recorded_backend_names().is_empty(), + "should make no API call" + ); + } + + #[test] + fn a_failure_does_not_replace_a_fresh_plan_written_since_the_first_read() { + let seeded = Arc::new(MemoryKv::default()); + seed_plan(&seeded); + // The first read misses, as if a concurrent request wrote the plan after it. + let kv = Arc::new(MemoryKv { + entries: Mutex::new(seeded.entries()), + hidden_reads: AtomicUsize::new(1), + ..MemoryKv::default() + }); + let http = Arc::new(StubHttpClient::new()); + http.push_response(503, Vec::new()); + + let output = view( + &factory(), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE, "the failed request should pass through"); + assert_eq!(http.recorded_backend_names().len(), 1); + assert_eq!(kv.only_entry().1["kind"], "plan", "the plan should survive"); + } + + #[test] + fn sign_failures_pass_through_and_write_a_skip() { + let oversized = { + let mut body = sign_response(MARKER); + body.resize(api::MAX_RESPONSE_BYTES + 2, b' '); + body + }; + let quota = serde_json::to_vec(&json!({ "success": false, "error": "quota_exceeded" })) + .expect("should serialize"); + let insecure_manifest = serde_json::to_vec(&json!({ + "success": true, + "policy_digest": POLICY_DIGEST, + "embedding_plan": plan_json(MARKER), + "manifest_url": "javascript:alert(1)", + })) + .expect("should serialize"); + let cases: Vec = vec![ + ("403", Some((403, b"{}".to_vec()))), + ("422", Some((422, b"{}".to_vec()))), + ("429", Some((429, b"{}".to_vec()))), + ("5xx", Some((502, Vec::new()))), + ("transport error or first-byte timeout", None), + ("oversized body", Some((200, oversized))), + ("malformed body", Some((200, b"not json".to_vec()))), + ( + "invalid marker", + Some((200, sign_response(""))), + ), + ("quota", Some((200, quota))), + ("non-https manifest url", Some((200, insecure_manifest))), + ( + "null plan", + Some(( + 200, + serde_json::to_vec(&json!({ + "success": true, "policy_digest": POLICY_DIGEST, "embedding_plan": null + })) + .expect("JSON"), + )), + ), + ]; + + for (case, response) in cases { + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + if let Some((status, body)) = response { + http.push_response(status, body); + } + let kv = Arc::new(MemoryKv::default()); + + let output = view( + &factory(), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!( + output, ARTICLE, + "{case}: should pass the page through unchanged" + ); + assert_eq!( + http.recorded_backend_names().len(), + 2, + "{case}: one policy call and one sign call" + ); + assert_eq!( + kv.only_entry().1["kind"], + "skip", + "{case}: should cache a skip" + ); + } + } + + #[test] + fn a_tampered_cached_plan_is_rejected_without_calling_the_api() { + for (field, value) in [ + ("/plan/operations/0/marker", ""), + ("/manifest_url", "http://manifest.example.com/doc-1"), + ] { + let kv = Arc::new(MemoryKv::default()); + seed_plan(&kv); + let (key, mut entry) = kv.only_entry(); + *entry + .pointer_mut(field) + .expect("seeded entry should have the field") = json!(value); + kv.insert(&key, &entry); + + let http = Arc::new(StubHttpClient::new()); + let output = view( + &factory(), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE, "{field}: should pass through"); + assert!( + http.recorded_backend_names().is_empty(), + "{field}: should make no API call" + ); + } + } + + #[test] + fn a_changed_credential_misses_the_cache() { + let kv = Arc::new(MemoryKv::default()); + seed_plan(&kv); + + let http = signing_http(); + let output = view( + &factory_with(config_json("cwk_rotated_token"), 5_000_000), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_signed(&output); + assert_eq!(http.recorded_request_methods(), ["GET", "POST"]); + assert_eq!( + kv.entries().len(), + 4, + "each credential should have its own policy and article entries" + ); + } + + // --- asynchronous resolve and the hold deadline ------------------------- + + #[test] + fn calls_that_are_pending_before_ready_are_awaited_and_sign() { + let timers: [Option>; 3] = [ + None, + Some(Arc::new(StoppedTimer)), + Some(Arc::new(TickTimer::default())), + ]; + for timer in timers { + let label = if timer.is_some() { "timer" } else { "no timer" }; + let kv = Arc::new(MemoryKv { + pending_polls: 2, + ..MemoryKv::default() + }); + let stub = signing_http(); + let http = Arc::new(YieldingHttpClient { + inner: Arc::clone(&stub), + pending_polls: 2, + }); + let mut services = services(http, &kv); + if let Some(timer) = timer { + services = services.with_timer(timer); + } + + let first = view(&factory(), &services); + let second = view(&factory(), &services); + + assert_signed(&first); + assert_eq!( + second, first, + "{label}: the second view should hit the cache" + ); + assert_eq!(stub.recorded_request_methods(), ["GET", "POST"], "{label}"); + assert_eq!(kv.only_entry().1["kind"], "plan", "{label}"); + } + } + + #[test] + fn a_hung_policy_call_times_out_writes_a_skip_and_the_next_view_makes_no_call() { + let kv = Arc::new(MemoryKv::default()); + let hung = Arc::new(HungHttpClient::default()); + + let output = view( + &factory(), + &timed_services(Arc::clone(&hung) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE, "should release the page unchanged"); + assert_eq!(hung.sends.load(Ordering::SeqCst), 1); + let skip = kv.entry(&policy_key()); + assert_eq!(skip["kind"], "skip"); + assert_eq!(skip["reason"], "timeout"); + + let http = Arc::new(StubHttpClient::new()); + let next = view( + &factory(), + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + assert_eq!(next, ARTICLE); + assert!( + http.recorded_backend_names().is_empty(), + "the next view should be a cached skip" + ); + } + + #[test] + fn a_hung_sign_call_or_body_times_out_writes_a_plan_skip_and_the_next_view_makes_no_call() { + let kv = Arc::new(MemoryKv::default()); + seed_plan(&kv); + let article_key = kv.only_entry().0; + kv.remove(&article_key); + let hung = Arc::new(HungHttpClient::default()); + let services = timed_services(Arc::clone(&hung) as Arc, &kv); + + for _ in 0..2 { + assert_eq!(view(&factory(), &services), ARTICLE); + } + + assert_eq!( + hung.sends.load(Ordering::SeqCst), + 1, + "one sign call in total" + ); + let (_, skip) = kv.only_entry(); + assert_eq!(skip["kind"], "skip"); + assert_eq!(skip["reason"], "timeout"); + } + + #[test] + fn a_hung_kv_read_times_out_without_calling_the_api() { + let kv = Arc::new(MemoryKv { + hung_reads: true, + ..MemoryKv::default() + }); + let http = Arc::new(StubHttpClient::new()); + + let output = view( + &factory(), + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE); + assert_eq!(kv.reads(), 1); + assert!(http.recorded_backend_names().is_empty()); + } + + #[test] + fn a_plan_write_that_runs_out_of_time_still_returns_the_signed_page() { + let kv = Arc::new(MemoryKv { + hung_writes_to: Some(CACHE_KEY_PREFIX), + ..MemoryKv::default() + }); + let http = signing_http(); + + let output = view( + &factory(), + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + + assert_signed(&output); + assert_eq!(http.recorded_request_methods(), ["GET", "POST"]); + assert!( + kv.entries() + .keys() + .all(|key| !key.starts_with(CACHE_KEY_PREFIX)), + "the abandoned plan write should store nothing" + ); + } + + #[test] + fn a_policy_digest_write_that_runs_out_of_time_caches_a_policy_skip() { + let kv = Arc::new(MemoryKv { + hung_first_write_to: Some(policy_key()), + ..MemoryKv::default() + }); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + + let output = view( + &factory(), + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE, "should release the page unchanged"); + assert_eq!( + http.recorded_request_methods(), + ["GET"], + "no sign call after the digest write ran out of time" + ); + let skip = kv.entry(&policy_key()); + assert_eq!(skip["kind"], "skip"); + assert_eq!(skip["reason"], "timeout"); + assert!( + kv.entries() + .keys() + .all(|key| !key.starts_with(CACHE_KEY_PREFIX)), + "no plan skip under a digest the next view cannot know" + ); + + // A hung client turns any API call into a full hold, so zero sends + // after one KV read shows the next view is a cached skip. + let reads = kv.reads(); + let hung = Arc::new(HungHttpClient::default()); + let next = view( + &factory(), + &timed_services(Arc::clone(&hung) as Arc, &kv), + ); + assert_eq!(next, ARTICLE); + assert_eq!(hung.sends.load(Ordering::SeqCst), 0, "no API call"); + assert_eq!(kv.reads() - reads, 1, "only the policy entry is read"); + } + + #[test] + fn the_core_backstop_lies_beyond_the_module_hold_bound() { + let kv = Arc::new(MemoryKv::default()); + let timer = Arc::new(TickTimer::default()); + let services = services(Arc::new(HungHttpClient::default()), &kv) + .with_timer(Arc::clone(&timer) as Arc); + + assert_eq!(view(&factory(), &services), ARTICLE); + + let max_hold = Duration::from_millis(5_000); + let requested = timer.requested.lock().expect("lock").clone(); + let (backstop, module): (Vec<_>, Vec<_>) = + requested.into_iter().partition(|sleep| *sleep > max_hold); + assert_eq!(backstop.len(), 1, "one core backstop beyond max_hold_ms"); + // The skip-write reserve starts only after the work deadline, so the + // module's own sleeps run back to back. + assert!( + !module.is_empty() && module.iter().sum::() <= max_hold, + "module sleeps {module:?} should fit within max_hold_ms" + ); + assert_eq!( + kv.entry(&policy_key())["reason"], + "timeout", + "the module deadline, not the backstop, should release the page" + ); + } + + // --- expiry, write mode and key namespaces ------------------------------ + + #[test] + fn expired_entries_are_misses() { + // (entry to expire, API calls the next view makes) + let cases: [(&str, &[&str]); 4] = [ + ("policy digest", &["GET"]), + ("policy skip", &["GET"]), + ("plan", &["POST"]), + ("plan skip", &["POST"]), + ]; + for (case, expected_calls) in cases { + let kv = Arc::new(MemoryKv::default()); + seed_plan(&kv); + let (plan_key, _) = kv.only_entry(); + let key = if case.starts_with("policy") { + policy_key() + } else { + plan_key + }; + if case.ends_with("skip") { + kv.insert( + &key, + &json!({"kind": "skip", "reason": "server_error", "expires_at": expired()}), + ); + } else { + set_expiry(&kv, &key, expired()); + } + let http = Arc::new(StubHttpClient::new()); + if case.starts_with("policy") { + http.push_response(200, policy_response(POLICY_DIGEST)); + } else { + http.push_response(200, sign_response(MARKER)); + } + + let output = view( + &factory(), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_signed(&output); + assert_eq!(http.recorded_request_methods(), expected_calls, "{case}"); + assert!( + kv.entry(&key)["expires_at"].as_u64() > Some(unix_now()), + "{case}: should be replaced by a fresh entry" + ); + } + } + + #[test] + fn an_expired_plan_does_not_block_a_skip_write() { + let seeded = Arc::new(MemoryKv::default()); + seed_plan(&seeded); + let kv = Arc::new(MemoryKv { + entries: Mutex::new(seeded.entries()), + hidden_reads: AtomicUsize::new(1), + ..MemoryKv::default() + }); + let (plan_key, _) = kv.only_entry(); + set_expiry(&kv, &plan_key, expired()); + let http = Arc::new(StubHttpClient::new()); + http.push_response(503, Vec::new()); + + assert_eq!(view(&factory(), &services(http, &kv)), ARTICLE); + + assert_eq!(kv.entry(&plan_key)["kind"], "skip"); + } + + #[test] + fn a_store_without_ttl_writes_switches_once_and_expires_by_value() { + let kv = Arc::new(MemoryKv { + ttl_unsupported: true, + ..MemoryKv::default() + }); + let factory = factory(); + let http = signing_http(); + let services = services(Arc::clone(&http) as Arc, &kv); + + let first = view(&factory, &services); + + assert_signed(&first); + assert_eq!( + kv.ttl_writes.load(Ordering::SeqCst), + 1, + "only the first write should try a TTL" + ); + let (plan_key, plan) = kv.only_entry(); + assert_eq!(plan["kind"], "plan"); + assert!(plan["expires_at"].as_u64() > Some(unix_now())); + assert_eq!(kv.entry(&policy_key())["kind"], "digest"); + assert_eq!(view(&factory, &services), first, "should hit the cache"); + assert_eq!(http.recorded_request_methods(), ["GET", "POST"]); + + // Expired entries are misses and are deleted when read. + set_expiry(&kv, &plan_key, expired()); + http.push_response(200, sign_response(MARKER)); + assert_signed(&view(&factory, &services)); + assert_eq!(*kv.deletes.lock().expect("lock"), [plan_key.as_str()]); + assert_eq!( + kv.entry(&plan_key)["kind"], + "plan", + "should store a new plan" + ); + } + + #[test] + fn a_fresh_instance_deletes_expired_entries_written_without_a_ttl() { + // Spin instantiates the module per request, so the instance that reads + // an expired entry has not written anything yet. + let kv = Arc::new(MemoryKv { + ttl_unsupported: true, + ..MemoryKv::default() + }); + view(&factory(), &services(signing_http(), &kv)); + set_expiry(&kv, &policy_key(), expired()); + let http = Arc::new(StubHttpClient::new()); + http.push_response(503, Vec::new()); + + let output = view( + &factory(), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE); + assert_eq!(http.recorded_request_methods(), ["GET"]); + assert_eq!(*kv.deletes.lock().expect("lock"), [policy_key()]); + } + + #[test] + fn expired_entries_are_not_deleted_on_a_store_with_ttls() { + let kv = Arc::new(MemoryKv::default()); + let http = signing_http(); + let services = services(Arc::clone(&http) as Arc, &kv); + view(&factory(), &services); + let (plan_key, _) = kv.only_entry(); + set_expiry(&kv, &plan_key, expired()); + http.push_response(200, sign_response(MARKER)); + + assert_signed(&view(&factory(), &services)); + + assert!(kv.deletes.lock().expect("lock").is_empty()); + } + + #[test] + fn entries_under_the_previous_key_versions_are_never_read() { + let current = Arc::new(MemoryKv::default()); + seed_plan(¤t); + // The same plan and policy, as the previous release stored them. + let kv = Arc::new(MemoryKv::default()); + for (key, value) in current.entries() { + let mut entry: serde_json::Value = + serde_json::from_slice(&value).expect("should store JSON"); + entry.as_object_mut().expect("object").remove("expires_at"); + let old_key = key + .replace("encypher:v3:", "encypher:v2:") + .replace("encypher:policy:v2:", "encypher:policy:v1:"); + kv.insert(&old_key, &entry); + } + let old_entries = kv.entries(); + let http = signing_http(); + + let output = view( + &factory(), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_signed(&output); + assert_eq!(http.recorded_request_methods(), ["GET", "POST"]); + let entries = kv.entries(); + assert_eq!(entries.len(), old_entries.len() * 2); + for (key, value) in old_entries { + assert_eq!(entries[&key], value, "{key} should be left untouched"); + } + } + + // --- KV failures -------------------------------------------------------- + + #[test] + fn kv_read_errors_pass_through_without_calling_the_api() { + for kv in [ + MemoryKv { + fail_reads: true, + ..MemoryKv::default() + }, + MemoryKv { + unavailable: true, + ..MemoryKv::default() + }, + ] { + let kv = Arc::new(kv); + let http = Arc::new(StubHttpClient::new()); + + let output = view( + &factory(), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE); + assert_eq!(kv.reads(), 1); + assert!( + http.recorded_backend_names().is_empty(), + "should make no API call" + ); + } + } + + #[test] + fn an_unbound_kv_store_logs_kv_unavailable_and_warns_once() { + let Some(logs) = capture_logs(|| { + let http: Arc = Arc::new(StubHttpClient::new()); + let unbound = Arc::new(MemoryKv { + unavailable: true, + ..MemoryKv::default() + }); + for _ in 0..2 { + view(&factory(), &services(Arc::clone(&http), &unbound)); + } + let failing = Arc::new(MemoryKv { + fail_reads: true, + ..MemoryKv::default() + }); + view(&factory(), &services(http, &failing)); + }) else { + // Log lines go to a logger installed elsewhere and cannot be observed here. + return; + }; + + let outcomes: Vec<_> = logs + .iter() + .filter(|line| line.contains("encypher: outcome=")) + .collect(); + assert_eq!( + outcomes, + [ + "INFO encypher: outcome=passed_through reason=kv_unavailable", + "INFO encypher: outcome=passed_through reason=kv_unavailable", + "INFO encypher: outcome=passed_through reason=kv_error", + ] + ); + assert_eq!( + logs.iter() + .filter(|line| line.starts_with("WARN") && line.contains("KV store is unavailable")) + .count(), + 1, + "should warn once per instance: {logs:?}" + ); + } + + // --- gates and skips ---------------------------------------------------- + + #[test] + fn gated_documents_pass_through_with_no_kv_or_api_io() { + let signer = factory(); + let cases: Vec<(GateCase<'_>, Gate)> = vec![ + (("method", &signer, |r| r.method = Method::POST), Gate::Method), + (("status", &signer, |r| r.response_status = 404), Gate::Status), + ( + ("origin private", &signer, |r| { + r.origin_response_is_private = true; + }), + Gate::OriginPrivate, + ), + ( + ("host mismatch", &signer, |r| { + r.page_url = "https://other.example.com/news/harbor-vote".to_owned(); + }), + Gate::HostMismatch, + ), + ( + ("excluded path", &signer, |r| { + r.page_url = "https://publisher.example.com/WP-Admin/post".to_owned(); + }), + Gate::ExcludedPath, + ), + ( + ("asset suffix", &signer, |r| { + r.page_url = "https://publisher.example.com/feed.XML".to_owned(); + }), + Gate::ExcludedPath, + ), + ( + ("invalid page url", &signer, |r| { + r.page_url = "not a url".to_owned(); + }), + Gate::InvalidPageUrl, + ), + ]; + + for ((case, signer, mutate), gate) in cases { + let http = Arc::new(StubHttpClient::new()); + let kv = Arc::new(MemoryKv::default()); + let services = services(Arc::clone(&http) as Arc, &kv); + let mut request = request(); + mutate(&mut request); + + assert_eq!( + signer.admit(request.clone()), + Err(gate), + "{case}: should be refused by its gate" + ); + let output = run(signer, Some(request), &services, ARTICLE); + + assert_eq!(output, ARTICLE, "{case}: should pass through"); + assert_eq!(kv.reads(), 0, "{case}: should read no KV"); + assert!( + http.recorded_backend_names().is_empty(), + "{case}: no API call" + ); + } + + let kv = Arc::new(MemoryKv::default()); + assert_eq!( + run( + &factory(), + None, + &services(Arc::new(StubHttpClient::new()), &kv), + ARTICLE + ), + ARTICLE, + "nothing prepared" + ); + } + + #[test] + fn a_bare_domain_page_is_signed() { + let kv = Arc::new(MemoryKv::default()); + let mut request = request(); + request.page_url = "https://publisher.example.com/news/harbor-vote".to_owned(); + + let output = run( + &factory(), + Some(request), + &services(signing_http(), &kv), + ARTICLE, + ); + + assert_signed(&output); + } + + #[test] + fn an_oversized_document_is_flushed_and_passed_through() { + let http = Arc::new(StubHttpClient::new()); + let kv = Arc::new(MemoryKv::default()); + + let output = view( + &factory_with(config_json(TOKEN), 64), + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE); + assert_eq!(kv.reads(), 0); + assert!(http.recorded_backend_names().is_empty()); + } + + #[test] + fn already_signed_or_short_pages_are_not_sent() { + let magic = "\u{E01DF}\u{E01E0}\u{E01E1}\u{E01E2}"; + let marked = ARTICLE.replace("The harbor council", &format!("The harbor{magic} council")); + let with_meta = ARTICLE.replace( + "", + r#""#, + ); + let short = "

Too short.

"; + + for page in [marked.as_str(), with_meta.as_str(), short] { + let http = Arc::new(StubHttpClient::new()); + let kv = Arc::new(MemoryKv::default()); + + let output = run( + &factory(), + Some(request()), + &services(Arc::clone(&http) as Arc, &kv), + page, + ); + + assert_eq!(output, page); + assert_eq!(kv.reads(), 0, "should read no KV"); + assert!( + http.recorded_backend_names().is_empty(), + "should make no API call" + ); + } + } + + #[test] + fn logs_carry_outcomes_but_never_the_token() { + let kv = Arc::new(MemoryKv::default()); + let Some(logs) = capture_logs(|| { + view(&factory(), &services(signing_http(), &kv)); + view(&factory(), &services(Arc::new(StubHttpClient::new()), &kv)); + let failing = Arc::new(StubHttpClient::new()); + failing.push_response(403, b"{}".to_vec()); + view( + &factory(), + &services(failing, &Arc::new(MemoryKv::default())), + ); + }) else { + // Log lines go to a logger installed elsewhere and cannot be observed here. + return; + }; + + assert!( + logs.iter() + .any(|line| line.ends_with("outcome=signed reason=signed")), + "should log the signing outcome: {logs:?}" + ); + for line in &logs { + assert!(!line.contains(TOKEN), "log line leaks the token: {line}"); + assert!( + !line.contains("encypher:v3:") && !line.contains("encypher:policy:v2:"), + "log line leaks the cache key: {line}" + ); + assert!( + !line.contains("ferry"), + "log line leaks article text: {line}" + ); + } + } +} diff --git a/crates/provenance/encypher/src/tests.rs b/crates/provenance/encypher/src/tests.rs new file mode 100644 index 0000000000..a9cbaf0865 --- /dev/null +++ b/crates/provenance/encypher/src/tests.rs @@ -0,0 +1,399 @@ +use super::*; + +use std::sync::Arc; + +use edgezero_core::body::Body as EdgeBody; +use futures::executor::block_on; +use http::{Method, Request, header}; +use serde_json::json; +use trusted_server_core::auction::compile_auction_plan; +use trusted_server_core::auction::orchestrator::AuctionOrchestrator; +use trusted_server_core::cache_policy::EdgeCacheHeader; +use trusted_server_core::ec::EcContext; +use trusted_server_core::html_processor::test_support::page_settings; +use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::platform::test_support::{ + StubHttpClient, build_services_with_http_client, +}; +use edgezero_core::key_value_store::KvPage; +use trusted_server_core::platform::{ + KvError, PlatformHttpClient, PlatformKvStore, RuntimeServices, +}; +use trusted_server_core::publisher::{ + AppContext, AuctionDispatch, buffer_publisher_response_async, handle_publisher_request, + publisher_response_into_streaming_response, +}; + +const TOKEN: &str = "cwk_test_worker_token_value"; +const MARKER: &str = "\u{E0100}\u{E0101}\u{FE01}"; +const MANIFEST_URL: &str = "https://api.example.com/manifests/doc-1"; +const POLICY_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ARTICLE: &str = "Harbor Council Votes\ +

Harbor council votes on the ferry schedule

\ +

The harbor council met on Tuesday evening and approved a revised ferry schedule \ + for the coming season.

Residents asked for earlier departures on weekdays.

\ +
Footer
"; + +fn module_settings(table: &str, serve: bool) -> String { + let entry = if serve { + format!("[[serve]]\nmedia_type = \"text/html\"\nmiddleware = [\"{MODULE}\"]\n") + } else { + String::new() + }; + format!("[provenance]\nmodule = \"encypher\"\n\n[provenance.encypher]\n{table}\n{entry}") +} + +fn table() -> String { + format!( + "api_base_url = \"https://api.example.com\"\ndomain = \"publisher.example.com\"\n\ + worker_token = \"{TOKEN}\"\n" + ) +} + +fn placed() -> Settings { + page_settings(&module_settings(&table(), true)) +} + +fn config_value() -> serde_json::Value { + json!({ + "api_base_url": "https://api.example.com", + "domain": "publisher.example.com", + "worker_token": TOKEN, + }) +} + +fn parsed(value: &serde_json::Value) -> Result { + let config: EncypherConfig = + serde_json::from_value(value.clone()).map_err(|error| error.to_string())?; + config.validate().map_err(|error| error.to_string())?; + Ok(config) +} + +#[test] +fn module_constant_is_the_crate_folder() { + assert_eq!( + MODULE, + trusted_server_core::module_name!(), + "should be named by the folder this crate lives in" + ); +} + +#[test] +fn an_unselected_module_registers_nothing() { + let settings = page_settings(""); + + assert!(register(&settings).expect("should read").is_none()); + assert!(!validate(&settings).expect("should read")); +} + +#[test] +fn a_selected_and_placed_module_registers_its_middleware() { + let settings = placed(); + + assert!(validate(&settings).expect("should validate")); + assert!( + register(&settings).expect("should register").is_some(), + "a selected module with a worker token should register" + ); +} + +#[test] +fn a_module_named_in_no_serve_entry_is_refused() { + let settings = page_settings(&module_settings(&table(), false)); + + let error = validate(&settings).expect_err("should refuse the document"); + + assert!( + format!("{error:?}").contains("no [[serve]] entry names"), + "should name the missing entry: {error:?}" + ); +} + +#[test] +fn a_blank_worker_token_registers_nothing() { + for token in ["", " \t"] { + let table = format!( + "api_base_url = \"https://api.example.com\"\ndomain = \"publisher.example.com\"\n\ + worker_token = \"{token}\"\n" + ); + let settings = page_settings(&module_settings(&table, true)); + + assert!( + register(&settings).expect("should read").is_none(), + "token {token:?} should not register" + ); + } +} + +#[test] +fn required_settings_and_bounds_are_enforced() { + for field in ["domain", "api_base_url", "worker_token"] { + let mut config = config_value(); + config + .as_object_mut() + .expect("should be an object") + .remove(field); + assert!(parsed(&config).is_err(), "config without {field} should fail"); + } + let defaults = parsed(&config_value()).expect("should parse defaults"); + assert_eq!(defaults.policy_ttl_seconds, 300); + assert_eq!(defaults.plan_ttl_seconds, 604_800); + assert_eq!(defaults.max_hold_ms, 5_000); + for (field, value) in [ + ("api_base_url", json!("http://api.example.com")), + ("api_base_url", json!("https://api.example.com/?q=1")), + ("domain", json!("Publisher.example.com")), + ("domain", json!("www.publisher.example.com")), + ("domain", json!("publisher.example.com/path")), + ("domain", json!("localhost")), + ("timeout_ms", json!(100)), + ("timeout_ms", json!(20_000)), + ("min_text_length", json!(10)), + ("negative_ttl_seconds", json!(10)), + ("policy_ttl_seconds", json!(301)), + ("article_selector", json!("a".repeat(92))), + ("enabled", json!(true)), + ("path_prefixes", json!(["/news/"])), + ] { + let mut config = config_value(); + config[field] = value.clone(); + assert!(parsed(&config).is_err(), "{field} = {value} should be rejected"); + } + for (max_hold_ms, timeout_ms, valid) in [ + (499, 250, false), + (500, 250, true), + (15_000, 3_000, true), + (15_001, 3_000, false), + (3_000, 3_000, false), + (3_001, 3_000, true), + ] { + let mut config = config_value(); + config["max_hold_ms"] = json!(max_hold_ms); + config["timeout_ms"] = json!(timeout_ms); + assert_eq!( + parsed(&config).is_ok(), + valid, + "max_hold_ms {max_hold_ms} with timeout_ms {timeout_ms}" + ); + } +} + +#[test] +fn excluded_paths_leave_the_middleware_out_of_the_chain() { + let middleware = Encypher { + signer: Arc::new(Signer::new( + parsed(&config_value()).expect("should parse"), + 5_000_000, + )), + }; + + assert!(middleware.handles_path("/news/harbor-vote")); + for path in ["/wp-admin/post", "/feed/", "/sitemap.xml", "/app.js", "/api/v1/x"] { + assert!(!middleware.handles_path(path), "{path} should be left out"); + } + assert_eq!(middleware.phases(), [MiddlewarePhase::Serve]); +} + +/// A runtime KV store held in memory. +#[derive(Default)] +struct MemoryKv(std::sync::Mutex>); + +#[async_trait::async_trait(?Send)] +impl PlatformKvStore for MemoryKv { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + Ok(self.0.lock().expect("should lock").get(key).cloned()) + } + + async fn put_bytes(&self, key: &str, value: bytes::Bytes) -> Result<(), KvError> { + self.0 + .lock() + .expect("should lock") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: bytes::Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + self.put_bytes(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.0.lock().expect("should lock").remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } +} + +/// An origin answering [`ARTICLE`] with `cache_control`, then the policy and +/// sign answers of the API. +fn stub(cache_control: &str) -> Arc { + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + ARTICLE.as_bytes().to_vec(), + vec![ + ("content-type", "text/html; charset=utf-8"), + ("cache-control", cache_control), + ], + ); + stub.push_response( + 200, + serde_json::to_vec(&json!({ "policy_digest": POLICY_DIGEST })).expect("should encode"), + ); + stub.push_response( + 200, + serde_json::to_vec(&json!({ + "success": true, + "policy_digest": POLICY_DIGEST, + "embedding_plan": { + "index_unit": "codepoint", + "operations": [{ "insert_after_index": 5, "marker": MARKER }], + }, + "manifest_url": MANIFEST_URL, + "verification_url": "https://api.example.com/verify/doc-1", + "document_id": "doc-1", + })) + .expect("should encode"), + ); + stub +} + +fn page_request(method: Method) -> Request { + Request::builder() + .method(method) + .uri("https://publisher.example.com/news/harbor-vote?utm_source=feed") + .header(header::HOST, "publisher.example.com") + .body(EdgeBody::empty()) + .expect("should build the page request") +} + +/// The page body a reader receives from the publisher path, through the +/// buffered or the streaming finalizer. +fn served( + settings: &Settings, + services: &RuntimeServices, + request: Request, + streaming: bool, +) -> String { + let registry = IntegrationRegistry::with_registrations(settings, &[builder()]) + .expect("should build the registry"); + let plan = compile_auction_plan(settings).expect("should compile the auction plan"); + let orchestrator = Arc::new(AuctionOrchestrator::from_plan(Arc::new(plan), None)); + let method = request.method().clone(); + block_on(async { + let mut ec_context = EcContext::read_from_request_resolving_geo(settings, &request, services) + .await + .expect("should read the request's state"); + let publisher = handle_publisher_request( + AppContext { + settings, + integration_registry: ®istry, + }, + services, + None, + &mut ec_context, + AuctionDispatch { + orchestrator: &orchestrator, + slots: &[], + registry: None, + }, + request, + EdgeCacheHeader::SurrogateControl, + ) + .await + .expect("should fetch the page"); + let response = if streaming { + publisher_response_into_streaming_response( + publisher, + &method, + Arc::new(settings.clone()), + ®istry, + Arc::clone(&orchestrator), + services.clone(), + ) + .await + } else { + buffer_publisher_response_async( + publisher, + &method, + settings, + ®istry, + &orchestrator, + services, + ) + .await + } + .expect("should finish the page"); + let body = response + .into_body() + .into_bytes_bounded(16 * 1024 * 1024) + .await + .expect("should read the body"); + String::from_utf8(body.to_vec()).expect("should be UTF-8") + }) +} + +fn services_for(stub: &Arc) -> RuntimeServices { + build_services_with_http_client(Arc::clone(stub) as Arc) + .with_kv_store(Arc::new(MemoryKv::default())) +} + +#[test] +fn the_publisher_path_signs_an_article_through_both_finalizers() { + for streaming in [false, true] { + let stub = stub("public, max-age=60"); + let page = served( + &placed(), + &services_for(&stub), + page_request(Method::GET), + streaming, + ); + + assert!( + page.contains(MARKER), + "streaming={streaming}: should embed the marker: {page}" + ); + assert!( + page.contains("c2pa-manifest-url") && page.contains(MANIFEST_URL), + "streaming={streaming}: should write the manifest meta tag" + ); + assert_eq!( + stub.recorded_request_methods(), + ["GET", "GET", "POST"], + "streaming={streaming}: origin, policy, then sign" + ); + // The test request carries no TLS signal, so the scheme the publisher + // path detects is `http`. + let policy_uri = &stub.recorded_request_uris()[1]; + assert!( + policy_uri.ends_with("page_url=http%3A%2F%2Fpublisher.example.com%2Fnews%2Fharbor-vote"), + "should send the page address without its query: {policy_uri}" + ); + } +} + +#[test] +fn a_page_the_origin_marked_private_is_never_sent() { + let stub = stub("private, max-age=0"); + + let page = served(&placed(), &services_for(&stub), page_request(Method::GET), false); + + assert!(!page.contains(MARKER), "should not sign a reader's own page"); + assert_eq!( + stub.recorded_request_methods(), + ["GET"], + "should call the origin and nothing else" + ); +} diff --git a/crates/provenance/encypher/src/text.rs b/crates/provenance/encypher/src/text.rs new file mode 100644 index 0000000000..28ebd3450a --- /dev/null +++ b/crates/provenance/encypher/src/text.rs @@ -0,0 +1,500 @@ +use std::sync::LazyLock; + +use regex::Regex; +use sha2::{Digest as _, Sha256}; + +/// Elements whose content canonical text drops: edge-core's set in +/// `integrations/edge-core/fragments.js`. +const TEXT_IGNORED: [&str; 7] = ["script", "style", "noscript", "svg", "math", "code", "pre"]; + +/// Elements whose content browsers never parse as markup (raw text and RCDATA). +/// A `/>` spelling does not close them, and the closing-tag search skips them. +const RAW_TEXT: [&str; 9] = [ + "script", + "style", + "noscript", + "title", + "textarea", + "xmp", + "iframe", + "noembed", + "plaintext", +]; + +static TITLE: LazyLock> = + LazyLock::new(|| Regex::new(r"(?is)]*>(.*?)").ok()); +static DECIMAL_ENTITY: LazyLock> = LazyLock::new(|| Regex::new(r"&#([0-9]+);").ok()); +static HEX_ENTITY: LazyLock> = + LazyLock::new(|| Regex::new(r"&#x([0-9a-fA-F]+);").ok()); + +/// A byte range containing one raw text run in an HTML document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fragment { + /// Inclusive UTF-8 byte offset at which the text begins. + pub start: usize, + /// Exclusive UTF-8 byte offset at which the text ends. + pub end: usize, +} + +/// Extracts visible raw-text fragments and their UTF-8 byte ranges. +#[must_use] +pub fn extract_fragments(html: &str) -> Vec { + let mut fragments = Vec::new(); + scan_html( + html, + |name| contains_tag(&TEXT_IGNORED, name), + |start, end| { + if html + .get(start..end) + .is_some_and(|raw| raw.chars().any(|ch| !is_js_whitespace(ch))) + { + fragments.push(Fragment { start, end }); + } + }, + |_, _| {}, + ); + fragments +} + +/// Decodes, normalizes, trims, and joins raw text fragments with single spaces. +#[must_use] +pub fn assemble_text(html: &str, fragments: &[Fragment]) -> String { + let mut output = String::new(); + for fragment in fragments { + if let Some(raw) = html.get(fragment.start..fragment.end) { + let decoded = decode_html_entities(raw); + let normalized = normalize_js_whitespace(&decoded); + if !normalized.is_empty() { + if !output.is_empty() { + output.push(' '); + } + output.push_str(&normalized); + } + } + } + output +} + +/// Returns the canonical visible text of an HTML string. +#[must_use] +pub fn visible_text(html: &str) -> String { + assemble_text(html, &extract_fragments(html)) +} + +/// Returns a lowercase SHA-256 digest of `text` as 64 hexadecimal characters. +#[must_use] +pub fn content_hash_hex(text: &str) -> String { + let digest = Sha256::digest(text.as_bytes()); + let mut output = String::with_capacity(64); + for byte in digest { + if let Some(high) = char::from_digit(u32::from(byte >> 4), 16) { + output.push(high); + } + if let Some(low) = char::from_digit(u32::from(byte & 0x0f), 16) { + output.push(low); + } + } + output +} + +/// Returns at most `max_scalars` Unicode scalar values without splitting one. +#[must_use] +pub fn truncate_scalars(text: &str, max_scalars: usize) -> &str { + let Some((end, _)) = text.char_indices().nth(max_scalars) else { + return text; + }; + text.get(..end).unwrap_or(text) +} + +/// Counts Unicode scalar values in `text`. +#[must_use] +pub fn scalar_count(text: &str) -> usize { + text.chars().count() +} + +/// Extracts the first title, decodes entities, normalizes whitespace, and caps it at 500 scalars. +#[must_use] +pub fn extract_title(html: &str) -> Option { + let regex = TITLE.as_ref()?; + let raw = regex.captures(html)?.get(1)?.as_str(); + let decoded = decode_html_entities(raw); + let mut normalized = normalize_js_whitespace(&decoded); + normalized.truncate(truncate_scalars(&normalized, 500).len()); + Some(normalized) +} + +/// Finds the first closing tag outside comments, the canonical-text ignored +/// elements, and raw-text or RCDATA elements. +#[must_use] +pub fn structural_close_tag(html: &str, tag: &str) -> Option { + let mut found = None; + scan_html( + html, + |name| contains_tag(&TEXT_IGNORED, name) || contains_tag(&RAW_TEXT, name), + |_, _| {}, + |start, token| { + if found.is_none() + && closing_tag_name(token).is_some_and(|name| name.eq_ignore_ascii_case(tag)) + { + found = Some(start); + } + }, + ); + found +} + +/// Scans tags, comments and text runs; both consumers share its comment and +/// quoted-attribute handling. `ignores` picks the elements whose content is +/// skipped: canonical text passes [`TEXT_IGNORED`], and the structural +/// closing-tag search passes [`TEXT_IGNORED`] and [`RAW_TEXT`]. +fn scan_html<'a>( + html: &'a str, + ignores: impl Fn(&str) -> bool, + mut text: impl FnMut(usize, usize), + mut tag: impl FnMut(usize, &'a str), +) { + let bytes = html.as_bytes(); + let mut cursor = 0; + let mut ignored: Option<&str> = None; + while cursor < bytes.len() { + if let Some(name) = ignored { + let Some(open) = find_byte(bytes, b'<', cursor) else { + break; + }; + cursor = open + 1; + if !starts_with_at(bytes, open, b"' || *byte == b'/' + }) + { + continue; + } + let Some(end) = tag_end(bytes, open + 2) else { + break; + }; + // As in browsers, attributes and `/` do not stop an end tag from + // closing. Unlike edge-core's depth count, no other end tag closes. + ignored = None; + cursor = end + 1; + continue; + } + if starts_with_at(bytes, cursor, b"` and `` are whole comments, and `--!>` also ends one. +fn comment_end(bytes: &[u8], start: usize) -> Option { + let body = start + 4; + if starts_with_at(bytes, body, b">") { + return Some(body + 1); + } + if starts_with_at(bytes, body, b"->") { + return Some(body + 2); + } + let dash = find_bytes(bytes, b"-->", body).map(|end| end + 3); + let bang = find_bytes(bytes, b"--!>", body).map(|end| end + 4); + match (dash, bang) { + (Some(dash), Some(bang)) => Some(dash.min(bang)), + (end, None) | (None, end) => end, + } +} + +/// Tokenizer states between a tag's name and its closing `>`. +#[derive(Clone, Copy)] +enum TagState { + Name, + BeforeAttributeName, + AttributeName, + BeforeValue, + Quoted(u8), + Unquoted, +} + +/// Returns the offset of the `>` ending the tag whose name starts at `start`. +/// As in the HTML tokenizer, a quote delimits only a value it begins. +fn tag_end(bytes: &[u8], start: usize) -> Option { + let mut state = TagState::Name; + for (offset, byte) in bytes.get(start..)?.iter().copied().enumerate() { + let space = byte.is_ascii_whitespace(); + state = match state { + TagState::Quoted(quote) => { + if byte == quote { + TagState::BeforeAttributeName + } else { + state + } + } + _ if byte == b'>' => return Some(start + offset), + TagState::Name if space || byte == b'/' => TagState::BeforeAttributeName, + TagState::BeforeAttributeName if !space && byte != b'/' => TagState::AttributeName, + TagState::AttributeName if byte == b'=' => TagState::BeforeValue, + TagState::AttributeName if byte == b'/' => TagState::BeforeAttributeName, + TagState::BeforeValue if byte == b'"' || byte == b'\'' => TagState::Quoted(byte), + TagState::BeforeValue if !space => TagState::Unquoted, + TagState::Unquoted if space => TagState::BeforeAttributeName, + _ => state, + }; + } + None +} + +pub(crate) fn decode_html_entities(input: &str) -> String { + let mut result = input.to_owned(); + for (encoded, decoded) in [ + ("&", "&"), + ("<", "<"), + (">", ">"), + (""", "\""), + ("'", "'"), + ("'", "'"), + ("'", "'"), + ("/", "/"), + (" ", "\u{00a0}"), + ("–", "\u{2013}"), + ("—", "\u{2014}"), + ("‘", "\u{2018}"), + ("’", "\u{2019}"), + ("“", "\u{201c}"), + ("”", "\u{201d}"), + ("…", "\u{2026}"), + ("•", "\u{2022}"), + ] { + if result.contains(encoded) { + result = result.replace(encoded, decoded); + } + } + result = decode_numeric_entities(&result, false); + decode_numeric_entities(&result, true) +} + +pub(crate) fn normalize_js_whitespace(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut pending_space = false; + for ch in input.chars() { + if is_js_whitespace(ch) { + pending_space = !output.is_empty(); + } else { + if pending_space { + output.push(' '); + pending_space = false; + } + output.push(ch); + } + } + output +} + +pub(crate) fn is_js_whitespace(ch: char) -> bool { + matches!( + ch, + '\u{0009}'..='\u{000d}' + | '\u{0020}' + | '\u{00a0}' + | '\u{1680}' + | '\u{2000}'..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +fn decode_numeric_entities(input: &str, hexadecimal: bool) -> String { + let regex = if hexadecimal { + &*HEX_ENTITY + } else { + &*DECIMAL_ENTITY + }; + let Some(regex) = regex.as_ref() else { + return input.to_owned(); + }; + let radix = if hexadecimal { 16 } else { 10 }; + regex + .replace_all(input, |captures: ®ex::Captures<'_>| { + captures + .get(1) + .and_then(|digits| u32::from_str_radix(digits.as_str(), radix).ok()) + .and_then(char::from_u32) + .map_or_else( + || { + captures + .get(0) + .map_or("", |matched| matched.as_str()) + .to_owned() + }, + |decoded| decoded.to_string(), + ) + }) + .into_owned() +} + +fn find_byte(bytes: &[u8], needle: u8, start: usize) -> Option { + let relative = bytes + .get(start..)? + .iter() + .position(|byte| *byte == needle)?; + Some(start.saturating_add(relative)) +} + +fn find_bytes(bytes: &[u8], needle: &[u8], start: usize) -> Option { + if needle.is_empty() { + return Some(start.min(bytes.len())); + } + let haystack = bytes.get(start..)?; + let relative = haystack + .windows(needle.len()) + .position(|window| window == needle)?; + Some(start.saturating_add(relative)) +} + +fn starts_with_at(bytes: &[u8], start: usize, needle: &[u8]) -> bool { + bytes + .get(start..) + .is_some_and(|tail| tail.starts_with(needle)) +} + +fn opening_tag_name(token: &str) -> Option<&str> { + let content = token + .strip_prefix('<')? + .trim_start_matches(is_js_whitespace); + if content.starts_with('/') { + return None; + } + take_tag_name(content) +} + +fn closing_tag_name(token: &str) -> Option<&str> { + let content = token.strip_prefix("").then_some(name) +} + +fn take_tag_name(content: &str) -> Option<&str> { + let end = content + .char_indices() + .find(|(_, ch)| !(ch.is_ascii_alphanumeric() || *ch == '_')) + .map_or(content.len(), |(index, _)| index); + (end > 0).then(|| content.get(..end)).flatten() +} + +fn contains_tag(tags: &[&str], name: &str) -> bool { + tags.iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +/// Matches edge-core's `/\/\s*>$/` test on a complete tag token. +fn is_self_closing(token: &str) -> bool { + token + .strip_suffix('>') + .is_some_and(|body| body.trim_end_matches(is_js_whitespace).ends_with('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quoted_markup_and_unmatched_script_tags_stay_inert() { + for html in [ + r#"
Visible
"#, + r#"
Visible
"#, + ] { + assert_eq!(structural_close_tag(html, "head"), html.rfind("")); + assert_eq!(visible_text(html), "Visible"); + } + } + + #[test] + fn browser_end_tag_and_comment_forms_close_where_browsers_do() { + // `` ends the script and `` is a whole comment, so each + // later `` sits inside an attribute value. + for prefix in [ + r#""#, + r#""#, + ] { + let html = format!("{prefix}
Visible
"); + assert_eq!(structural_close_tag(&html, "head"), Some(prefix.len())); + } + assert_eq!( + visible_text("abce" + ); + let unknown = scan_html(&document, None); + assert_eq!( + texts(&document, &unknown), + vec![PNG], + "should edit only the attribute" + ); + assert_eq!( + unknown.refused, + vec![Reason::CspUnknown, Reason::CspUnknown], + "should report both styles under an unseen policy" + ); + + let open = [String::from("img-src 'self' data:")]; + let scan = scan_html(&document, Some(&open)); + assert_eq!( + texts(&document, &scan), + vec![PNG; 3], + "should edit all, in order" + ); + assert!( + scan.found + .windows(2) + .all(|pair| pair[0].start < pair[1].start), + "should keep document order" + ); + + let hashed = [String::from("style-src 'sha256-abc' 'nonce-n'")]; + let scan = scan_html(&document, Some(&hashed)); + assert_eq!( + texts(&document, &scan).len(), + 2, + "should edit the image and the nonced element" + ); + assert_eq!(scan.refused, vec![Reason::CspStyleHash]); + + let meta = format!( + "{document}" + ); + let scan = scan_html(&meta, Some(&open)); + assert_eq!(texts(&meta, &scan), vec![PNG], "should honor a meta policy"); + } + + #[test] + fn only_the_style_s_own_nonce_admits_an_edit_under_a_hash_policy() { + let style = |nonce: &str| { + format!("") + }; + let policy = [String::from("style-src 'sha256-abc' 'NONCE-n0Nce'")]; + for (nonce, edited) in [ + ("n0Nce", true), + ("other", false), + ("n0nce", false), + ("", false), + ] { + let document = style(nonce); + let scan = scan_html(&document, Some(&policy)); + assert_eq!( + scan.found.len(), + usize::from(edited), + "nonce {nonce:?}: should edit only on the exact listed value" + ); + if !edited { + assert_eq!(scan.refused, vec![Reason::CspStyleHash], "nonce {nonce:?}"); + } + } + } + + #[test] + fn style_text_inside_inline_svg_is_markup_whose_references_are_decoded() { + let uri = "data:image/svg+xml,%3Csvg%3E&"; + let open = [String::from("img-src data:")]; + let in_svg = format!( + "" + ); + let scan = scan_html(&in_svg, Some(&open)); + assert!( + scan.found.is_empty(), + "should not rewrite a decoded reference" + ); + assert_eq!(scan.refused, vec![Reason::CharacterReference]); + + let in_html = format!(""); + let scan = scan_html(&in_html, Some(&open)); + assert_eq!( + texts(&in_html, &scan), + vec![uri], + "an HTML style's text is raw, so its `&` is literal" + ); + + let script = format!( + "" + ); + let scan = scan_html(&script, Some(&open)); + assert_eq!( + texts(&script, &scan), + vec![PNG], + "should keep scanning after an SVG script" + ); + } + + #[test] + fn only_url_attributes_are_rewritten() { + let document = format!( + "\ + \"{PNG}\"\ +
" + ); + let scan = scan_html(&document, None); + let found: Vec = scan.found.iter().map(|found| found.start).collect(); + let lazy = document.find("data-lazy-src=\"").expect("lazy") + "data-lazy-src=\"".len(); + let background = document.find("data-bg=\"").expect("bg") + "data-bg=\"".len(); + let object = document.find("data=\"data:").expect("object") + "data=\"".len(); + assert_eq!( + found, + vec![lazy, background, object], + "should rewrite lazy, background and object URLs, never text attributes" + ); + } + + #[test] + fn policies_are_read_from_both_policy_headers() { + let mut headers = http::HeaderMap::new(); + headers.append( + "content-security-policy", + http::HeaderValue::from_static("img-src data:"), + ); + headers.append( + "content-security-policy-report-only", + http::HeaderValue::from_static("style-src 'sha256-abc'"), + ); + assert_eq!( + content_security_policies(&headers), + Some(vec![ + "img-src data:".to_owned(), + "style-src 'sha256-abc'".to_owned() + ]) + ); + } + + #[test] + fn stylesheets_find_url_and_image_set_media_outside_comments() { + let document = format!( + ".a{{background:url({PNG})}}/* url({PNG}) */.b{{background:url( \"{PNG}\" )}}\ + .c{{background-image:image-set(\"{PNG}\" 1x, url({PNG}) 2x)}}\ + .d{{background:url(data:image/png;base64,iVBO\\52w0KGgo=)}}.e{{content:\"{PNG}\"}}" + ); + let scan = scan_stylesheet(&document); + assert_eq!( + texts(&document, &scan), + vec![PNG; 4], + "should find each media URL" + ); + assert_eq!( + scan.refused, + vec![Reason::CssEscape], + "should refuse the escaped URL" + ); + } + + #[test] + fn outermost_inline_svgs_are_found_with_exact_ranges() { + let svg = + ""; + let document = format!("

a

{svg}

b

"); + let scan = scan_html(&document, None); + assert_eq!(scan.svgs.len(), 1, "should find one outermost element"); + let (start, end) = scan.svgs[0]; + assert_eq!(&document[start..end], svg, "should cover the exact bytes"); + } + + #[test] + fn self_closing_svg_attributes_are_scanned_without_whole_asset_signing() { + let document = format!(""); + let scan = scan_html(&document, Some(&[])); + assert_eq!(texts(&document, &scan), [PNG]); + assert!(scan.svgs.is_empty()); + assert!(scan.refused.is_empty()); + } + + #[test] + fn original_svg_provenance_protects_attributes_css_and_nested_svg_ranges() { + for provenance in [ + CARRIER, + "", + "https://example.com/m", + "", + "", + "QUJD", + ] { + let svg = format!( + "{ROOT}{provenance}\ + \ + " + ); + let document = format!("{svg}"); + let scan = scan_html(&document, Some(&[])); + assert_eq!(texts(&document, &scan), [PNG, PNG], "{provenance}"); + assert!(scan.svgs.is_empty(), "must not resign {provenance}"); + assert_eq!( + scan.refused, + [Reason::SvgExistingProvenance], + "{provenance}" + ); + } + } + + #[test] + fn unsigned_svg_descendants_remain_editable_without_a_false_caption_match() { + let document = format!( + "{ROOT}Signed with C2PA\ + \ + " + ); + let scan = scan_html(&document, Some(&[])); + assert_eq!(texts(&document, &scan), [PNG; 3]); + assert_eq!(scan.svgs, [(0, document.len())]); + assert!(scan.refused.is_empty()); + } + + #[test] + fn incomplete_or_uninspectable_svg_ranges_fail_closed_before_child_edits() { + for svg in [ + format!("{ROOT}{CARRIER}"), + format!("{ROOT}"), + format!("{ROOT}

breakout

"), + format!( + "{ROOT}{}", + "".repeat(10_000) + ), + format!(""), + ] { + let document = format!("{svg}"); + let scan = scan_html(&document, Some(&[])); + assert_eq!(texts(&document, &scan), [PNG], "{svg}"); + assert!(scan.svgs.is_empty(), "must not resign {svg}"); + assert_eq!(scan.refused.len(), 1); + } + } + + const ROOT: &str = ""; + const BODY: &str = ""; + const CARRIER: &str = "QUJD+/=="; + + #[test] + fn the_writer_s_carrier_after_the_root_start_tag_keeps_the_svg() { + let original = format!("{ROOT}{BODY}"); + let signed = format!("{ROOT}{CARRIER}{BODY}"); + assert_eq!( + verify_svg_carrier(original.as_bytes(), signed.as_bytes()), + Ok(()), + "should accept the writer's carrier" + ); + let declared = format!("\n{ROOT}{BODY}"); + let signed = format!("\n{ROOT}{CARRIER}{BODY}"); + assert_eq!( + verify_svg_carrier(declared.as_bytes(), signed.as_bytes()), + Ok(()), + "should find the root after a declaration and a comment" + ); + } + + #[test] + fn a_carrier_the_source_held_is_replaced_and_nothing_else() { + let old_element = "T0xE"; + for old in [old_element, ""] { + let original = format!("{ROOT}{old}{BODY}"); + let signed = format!("{ROOT}{CARRIER}{BODY}"); + assert_eq!( + verify_svg_carrier(original.as_bytes(), signed.as_bytes()), + Ok(()), + "should accept replacing {old}" + ); + } + } + + #[test] + fn any_other_change_to_the_svg_is_refused() { + let original = format!("{ROOT}{BODY}"); + let altered = [ + format!("{ROOT}{CARRIER}{BODY}").replace("currentColor", "#000"), + format!("{ROOT}{}{CARRIER}", BODY.replace("", "")) + "", + format!("{ROOT}{BODY}"), + format!("{ROOT}{}{BODY}", CARRIER.replace("QUJD+/==", "QU JD")), + format!("{ROOT} {CARRIER}{BODY}"), + format!("{ROOT}{BODY}"), + original.clone(), + ]; + for signed in altered { + assert_eq!( + verify_svg_carrier(original.as_bytes(), signed.as_bytes()), + Err(Reason::InlineSvgAltered), + "should refuse {signed}" + ); + } + } + + #[test] + fn splice_replaces_exact_ranges_within_budget() { + let document = ""; + let edits = vec![(10, 11, "xx".to_owned()), (23, 24, "y".to_owned())]; + assert_eq!( + splice(document, &edits, 100).as_deref(), + Some("") + ); + assert_eq!( + splice(document, &edits, document.len()), + None, + "should honor the budget" + ); + } +} diff --git a/crates/provenance/encypher/src/lib.rs b/crates/provenance/encypher/src/lib.rs index 4923b1a518..e5ec65909b 100644 --- a/crates/provenance/encypher/src/lib.rs +++ b/crates/provenance/encypher/src/lib.rs @@ -1,4 +1,4 @@ -//! Encypher text provenance for article pages, `provenance.encypher`. +//! Encypher provenance for article pages and their media, `provenance.encypher`. //! //! A serve middleware finds the article in each HTML page a `[[serve]]` entry //! names it for, sends its visible text to the Encypher signing API, and @@ -6,16 +6,36 @@ //! cached in the runtime KV store, bound to a short-lived server policy. //! Every failure passes the page through unchanged. //! +//! Media is signed as its exact bytes through the Encypher media endpoint, +//! under the same policy and worker token: images, audio, video and fonts +//! core captures on the publisher and asset proxy paths, through the +//! module's media signer; media `data:` URIs in the same pages, and their +//! inline SVG as the standalone SVG document proven to parse to the tree the +//! browser built, which is the original bytes where they already are one; +//! and media `data:` URIs in the stylesheets under each `path` a +//! `[[serve]]` entry names `provenance.encypher.stylesheet` for, which the +//! publisher asserts no page loads with `integrity`. Stylesheets under no +//! such path are left as they are. A media item that cannot be signed is +//! delivered as it was and reported unsigned, never as signed. +//! //! The middleware's preparation applies the request gates with no I/O: only //! a `GET` of a `200` page on the configured domain, whose origin did not //! mark it as one reader's, is held. The signing work runs in the stream //! processor's asynchronous finish, which core awaits at the end of the //! document. The module requires no permission and reads no gated value. It -//! sends the visible text of the page's article and the page address without -//! its query. A page whose origin set a cookie or forbade shared caching is -//! never sent, but the module cannot tell a personalized article that carries -//! neither, so a deployment places it only on public article routes and has -//! its origin mark every personalized response private. +//! sends the visible text of the page's article, the page address without +//! its query, and the bytes of the media it signs. A page whose origin set a +//! cookie or forbade shared caching is never sent, but the module cannot +//! tell a personalized article that carries neither, so a deployment places +//! it only on public article routes and has its origin mark every +//! personalized response private. +//! +//! Independently of signing admission, the applicable page middleware protects +//! original provenance-bearing or structurally uncertain inline SVG ranges. +//! Core holds the bounded original before its own rewrites, keeps those ranges +//! opaque through fetch/serve changes and restores their exact bytes before +//! publishing a template or reader response. The same classifier decides both +//! this protection and the module's descendant-edit refusals. use std::any::Any; use std::sync::Arc; @@ -37,12 +57,19 @@ use trusted_server_core::settings::{IntegrationConfig, Settings}; mod api; mod boundary; +mod data_uri; mod embed; +mod embedded; +mod media_api; mod prepared_probe; mod processor; +mod svg_equivalence; mod text; -use processor::{PageRequest, Signer, document_processor, is_excluded_path}; +use processor::{ + EncypherMedia, PageRequest, Signer, document_processor, is_excluded_path, is_excluded_prefix, + stylesheet_processor, +}; /// The id the module's registration and its API backend carry. const INTEGRATION_ID: &str = "provenance_encypher"; @@ -51,6 +78,13 @@ const INTEGRATION_ID: &str = "provenance_encypher"; /// entry names its middleware by, from this crate's folder. pub const MODULE: &str = "provenance.encypher"; +/// The name a `[[serve]]` entry for stylesheets writes, which signs the media +/// `data:` URIs a stylesheet embeds. +pub const STYLESHEET_MIDDLEWARE: &str = "provenance.encypher.stylesheet"; + +/// The media type [`STYLESHEET_MIDDLEWARE`] works on. +const STYLESHEET_MEDIA_TYPE: &str = "text/css"; + /// `[provenance.encypher]`, the module's settings. #[derive(Debug, Clone, Deserialize, Validate)] #[serde(deny_unknown_fields)] @@ -65,11 +99,13 @@ pub struct EncypherConfig { /// The name of the secret-store entry holding the `cwk_` worker /// credential, resolved to the credential itself when settings load. pub worker_token: Redacted, - /// Optional article selector override: `.class`, `[attr=value]` or a tag - /// name. At most 91 characters, so `override:{selector}` fits the API's - /// 100-character `boundary_selector` limit. + /// Optional article selector override: one unescaped `.class`, an exact + /// `[attr=value]` (also single- or double-quoted), or an ASCII tag name. + /// Empty means no override; a valid selector absent from a page keeps + /// the detection fallback. At most 91 characters, so + /// `override:{selector}` fits the API's 100-character limit. #[serde(default)] - #[validate(length(max = 91))] + #[validate(length(max = 91), custom(function = validate_article_selector))] pub article_selector: String, /// Minimum visible article text, in Unicode scalars. #[serde(default = "default_min_text_length")] @@ -102,8 +138,44 @@ pub struct EncypherConfig { #[serde(default = "default_negative_ttl_seconds")] #[validate(range(min = 60, max = 86_400))] pub negative_ttl_seconds: u64, + /// Largest media item signed, in bytes: a captured image or a decoded + /// embedded item. Larger media is delivered unsigned. Bounded by what the + /// endpoint embeds as source data when the source has no safe address. + #[serde(default = "default_max_media_bytes")] + #[validate(range(min = 1024, max = 8_000_000))] + pub max_media_bytes: usize, + /// Maximum uncached media-signing calls in one HTML or stylesheet pass. + /// Cached assets do not consume calls. The document's existing time and + /// output bounds still apply independently. + #[serde(default = "default_max_media_sign_calls_per_document")] + #[validate(range(min = 1, max = 128))] + pub max_media_sign_calls_per_document: usize, + /// Largest media signed through the prepared lane, in bytes: hashed in + /// bounded chunks where it is delivered, never uploaded or held whole, + /// and given a remote manifest its bytes are left unchanged by. `0`, the + /// default, keeps the lane off, so no deployment starts hashing large + /// files on its own; turning it on also needs the signed probe release + /// and the prepared endpoints. Otherwise at least 16 MiB, and at most the + /// prepared service's largest source. Media between `max_media_bytes` + /// and this is the lane's. A bounded example is `1073741824` (1 GiB) + /// with `max_large_media_ms = 60000`. + #[serde(default = "default_max_large_media_bytes")] + #[validate(custom(function = validate_max_large_media_bytes))] + pub max_large_media_bytes: u64, + /// Longest one large source may take, in milliseconds: core's hash of + /// the whole source and the module's probe and API calls together. A + /// source not signed in time is delivered unsigned. + #[serde(default = "default_max_large_media_ms")] + #[validate(range(min = 5_000, max = 600_000))] + pub max_large_media_ms: u64, } +/// The prepared service's largest source, in bytes: the largest integer a +/// JSON number carries exactly, less the room it keeps for a manifest. +const MAX_PREPARED_SOURCE_BYTES: u64 = 9_007_199_246_352_383; +/// The smallest non-zero `max_large_media_bytes`, above every captured size. +const MIN_LARGE_MEDIA_BYTES: u64 = 16 * 1024 * 1024; + impl IntegrationConfig for EncypherConfig {} fn default_min_text_length() -> usize { @@ -134,6 +206,38 @@ fn default_negative_ttl_seconds() -> u64 { 300 } +fn default_max_media_bytes() -> usize { + 5_000_000 +} + +fn default_max_media_sign_calls_per_document() -> usize { + 8 +} + +fn default_max_large_media_bytes() -> u64 { + 0 +} + +fn default_max_large_media_ms() -> u64 { + 60_000 +} + +fn validate_max_large_media_bytes(value: u64) -> Result<(), ValidationError> { + if value == 0 || (MIN_LARGE_MEDIA_BYTES..=MAX_PREPARED_SOURCE_BYTES).contains(&value) { + Ok(()) + } else { + Err(ValidationError::new("encypher_max_large_media_bytes")) + } +} + +fn validate_article_selector(value: &str) -> Result<(), ValidationError> { + if value.is_empty() || boundary::is_valid_selector(value) { + Ok(()) + } else { + Err(ValidationError::new("encypher_article_selector")) + } +} + fn validate_api_base_url(value: &str) -> Result<(), ValidationError> { let parsed = url::Url::parse(value).map_err(|_| ValidationError::new("encypher_api_base_url"))?; @@ -187,9 +291,16 @@ fn always_in_use(_table: &serde_json::Map) -> bool { true } -/// The page change the module supplies, as a `[[serve]]` entry names it. -const SUPPLIED_MIDDLEWARE: &[SuppliedMiddleware] = - &[SuppliedMiddleware::on_html(MODULE, MiddlewarePhase::Serve)]; +/// The changes the module supplies, as `[[serve]]` entries name them: the +/// page's article and embedded media, and a stylesheet's embedded media. +const SUPPLIED_MIDDLEWARE: &[SuppliedMiddleware] = &[ + SuppliedMiddleware::on_html(MODULE, MiddlewarePhase::Serve), + SuppliedMiddleware::on( + STYLESHEET_MIDDLEWARE, + MiddlewarePhase::Serve, + STYLESHEET_MEDIA_TYPE, + ), +]; /// The builder a deployment installs to offer this module. #[must_use] @@ -220,12 +331,47 @@ fn require_an_entry(settings: &Settings) -> Result<(), Report Result<(), Report> { + let unscoped = settings.serve.entries().iter().any(|entry| { + entry + .middleware + .iter() + .any(|name| name == STYLESHEET_MIDDLEWARE) + && entry + .path + .as_deref() + .is_none_or(|path| path.is_empty() || path == "/") + }); + if !unscoped { + return Ok(()); + } + Err(Report::new(TrustedServerError::Settings { + message: format!( + "a [[serve]] entry names `{STYLESHEET_MIDDLEWARE}` for every path. Signing a \ + stylesheet's embedded media changes its bytes and breaks a page that loads it \ + with `integrity`, so give the entry a `path` for the stylesheets no page loads \ + with `integrity`" + ), + })) +} + /// The module's settings when a section selects it. fn config(settings: &Settings) -> Result, Report> { let Some(config) = settings.module_config::(MODULE)? else { return Ok(None); }; require_an_entry(settings)?; + require_scoped_stylesheet_entries(settings)?; Ok(Some(config)) } @@ -240,8 +386,8 @@ pub fn validate(settings: &Settings) -> Result> Ok(config(settings)?.is_some()) } -/// Registers the middleware when a section selects the module and its worker -/// token is not blank. +/// Registers the middleware and the media signer when a section selects the +/// module and its worker token is not blank. /// /// # Errors /// @@ -260,17 +406,23 @@ pub fn register( let max_document_bytes = config .max_document_bytes .min(settings.publisher.max_buffered_body_bytes); + let signer = Arc::new(Signer::new(config, max_document_bytes)); Ok(Some( IntegrationRegistration::builder(INTEGRATION_ID) .without_js() .with_middleware(Arc::new(Encypher { - signer: Arc::new(Signer::new(config, max_document_bytes)), + signer: Arc::clone(&signer), })) + .with_middleware(Arc::new(EncypherStylesheet { + signer: Arc::clone(&signer), + })) + .with_media_signer(Arc::new(EncypherMedia::new(signer))) .build(), )) } -/// The middleware, which signs the article of each admitted page. +/// The middleware, which signs the article and the embedded media of each +/// admitted page. pub struct Encypher { signer: Arc, } @@ -293,6 +445,10 @@ impl Middleware for Encypher { media_type == HTML_MEDIA_TYPE } + fn source_protection(&self) -> Option { + Some(embedded::source_protection) + } + /// Every path but an API, feed, admin or well-known path, or an asset, /// which is never an article. fn handles_path(&self, path: &str) -> bool { @@ -311,38 +467,112 @@ impl Middleware for Encypher { call: ModuleCall<'a>, context: &'a MiddlewareContext<'a>, ) -> Option> { - let admission = admit(&self.signer, &call, context); + let admission = admit(&call, context, |page| self.signer.admit(page)); Some(Box::pin(async move { Ok(Box::new(admission) as Box) })) } + /// Holds the admitted page, with the security policies its response + /// carries, which decide whether inline style media may be edited. fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { let Some(page) = context.take_prepared::(MODULE).flatten() else { return MiddlewareAction::pass(); }; + let policies = context + .response_headers + .and_then(embedded::content_security_policies); + MiddlewareAction { + stream: Some(Box::new(document_processor(&self.signer, page, policies))), + ..MiddlewareAction::pass() + } + } +} + +/// The stylesheet middleware, which signs the media `data:` URIs of each +/// admitted stylesheet. +pub struct EncypherStylesheet { + signer: Arc, +} + +impl std::fmt::Debug for EncypherStylesheet { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EncypherStylesheet") + .finish_non_exhaustive() + } +} + +impl Middleware for EncypherStylesheet { + fn middleware_id(&self) -> &'static str { + STYLESHEET_MIDDLEWARE + } + + fn handles_media_type(&self, media_type: &str) -> bool { + media_type == STYLESHEET_MEDIA_TYPE + } + + /// Every path but an API, feed, admin or well-known path. A `.css` + /// suffix is what a stylesheet has. + fn handles_path(&self, path: &str) -> bool { + !is_excluded_prefix(path) + } + + /// Serve only, as for pages. + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Serve] + } + + /// Applies the request gates, with no I/O, and keeps the stylesheet to + /// sign. + fn prepare<'a>( + &'a self, + call: ModuleCall<'a>, + context: &'a MiddlewareContext<'a>, + ) -> Option> { + let admission = admit(&call, context, |page| self.signer.admit_stylesheet(page)); + Some(Box::pin(async move { + Ok(Box::new(admission) as Box) + })) + } + + fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { + let Some(page) = context + .take_prepared::(STYLESHEET_MIDDLEWARE) + .flatten() + else { + return MiddlewareAction::pass(); + }; MiddlewareAction { - stream: Some(Box::new(document_processor(&self.signer, page))), + stream: Some(Box::new(stylesheet_processor(&self.signer, page))), ..MiddlewareAction::pass() } } } -/// The page a document is the body of, when the gates admit it. -fn admit(signer: &Signer, call: &ModuleCall<'_>, context: &MiddlewareContext<'_>) -> Admission { +/// The document a request is for, when `gate` admits it. +fn admit( + call: &ModuleCall<'_>, + context: &MiddlewareContext<'_>, + gate: impl FnOnce(PageRequest) -> Result, +) -> Admission { let request = ModuleRequest::from_module_context(call).ok()?; + let removed_prefix = request.removed_prefix().unwrap_or_default(); let page = PageRequest { method: request.method().clone(), + // The address the reader asked for: a host that serves several + // publishers by path signs each page for its own address. page_url: format!( - "{}://{}{}", + "{}://{}{removed_prefix}{}", request.scheme(), request.host(), request.path() ), + removed_prefix: removed_prefix.to_owned(), response_status: context.status.as_u16(), origin_response_is_private: context.origin_private, }; - match signer.admit(page) { + match gate(page) { Ok(page) => Some(page), Err(gate) => { log::debug!("encypher: document not held, gate={}", gate.code()); diff --git a/crates/provenance/encypher/src/media_api.rs b/crates/provenance/encypher/src/media_api.rs new file mode 100644 index 0000000000..2d8d7de9aa --- /dev/null +++ b/crates/provenance/encypher/src/media_api.rs @@ -0,0 +1,831 @@ +//! Wire contract of the Encypher media signing endpoint, +//! `POST /api/v1/public/cdn/media/sign`. +//! +//! The request is `multipart/form-data` with a `metadata` JSON part and the +//! exact `source` bytes. Neither core nor this module transforms media, so no +//! `final` part is sent and the execution record lists no operations. A `200` +//! answer is the exact signed bytes, described by `X-Encypher-*` headers that +//! [`verify_signed_response`] checks before any byte reaches a reader. + +use bytes::{BufMut as _, Bytes, BytesMut}; +use http::{HeaderMap, HeaderValue}; +use serde::ser::SerializeSeq as _; +use serde::{Deserialize, Serialize, Serializer}; +use sha2::{Digest as _, Sha256}; +use trusted_server_core::middleware::media_type_of; + +use crate::api::valid_policy_digest; + +/// The media signing route, below the API origin. +pub(crate) const MEDIA_SIGN_PATH: &str = "/api/v1/public/cdn/media/sign"; +/// The request header carrying the replay key the endpoint requires. +pub(crate) const IDEMPOTENCY_KEY_HEADER: &str = "idempotency-key"; +/// The whole multipart body the endpoint accepts. +pub(crate) const MAX_REQUEST_BYTES: usize = 100_000_000; +/// What signing may add to the bytes: the manifest store with its +/// certificate chain and the embedded source data. +pub(crate) const MAX_MANIFEST_OVERHEAD_BYTES: usize = 10 * 1024 * 1024; +/// A failure body is only read for its code. +pub(crate) const MAX_ERROR_BODY_BYTES: usize = 4096; +/// Longest manifest instance or document identifier accepted. +const MAX_IDENTIFIER_BYTES: usize = 256; +/// Longest URL the endpoint accepts or returns. +const MAX_URL_BYTES: usize = 2048; +/// Longest failure code kept for the log. +const MAX_ERROR_CODE_BYTES: usize = 64; + +const POLICY_DIGEST_HEADER: &str = "x-encypher-policy-digest"; +const SIGNED_SHA256_HEADER: &str = "x-encypher-signed-sha256"; +const INSTANCE_ID_HEADER: &str = "x-encypher-manifest-instance-id"; +const DISPOSITION_HEADER: &str = "x-encypher-manifest-disposition"; +const MANIFEST_URL_HEADER: &str = "x-encypher-manifest-url"; +const MANIFEST_SHA256_HEADER: &str = "x-encypher-manifest-sha256"; +const LINK_REQUIRED_HEADER: &str = "x-encypher-manifest-link-required"; +const VERIFICATION_URL_HEADER: &str = "x-encypher-verification-url"; +const DOCUMENT_ID_HEADER: &str = "x-encypher-document-id"; + +/// Every signing header the endpoint answers with, in the order a cached +/// answer stores them. +pub(crate) const SIGNING_HEADERS: [&str; 10] = [ + "content-type", + POLICY_DIGEST_HEADER, + SIGNED_SHA256_HEADER, + INSTANCE_ID_HEADER, + DISPOSITION_HEADER, + MANIFEST_URL_HEADER, + MANIFEST_SHA256_HEADER, + LINK_REQUIRED_HEADER, + VERIFICATION_URL_HEADER, + DOCUMENT_ID_HEADER, +]; + +/// The `metadata` part of one media signing request. +#[derive(Debug, Serialize)] +pub(crate) struct MediaMetadata<'a> { + /// The reader-facing absolute URL the media is delivered on or in. + pub(crate) page_url: &'a str, + /// The server policy the edge holds, which the endpoint refuses when + /// stale. + pub(crate) expected_policy_digest: &'a str, + pub(crate) source: SourceMetadata<'a>, + pub(crate) execution_record: ExecutionRecord<'a>, + pub(crate) delivery: Delivery, +} + +/// What the edge says about the captured source bytes. +#[derive(Debug, Serialize)] +pub(crate) struct SourceMetadata<'a> { + /// A canonical `https` address of the source, or `null` where there is + /// none: embedded media, or an address with a query or credentials, + /// which is never stripped to make one. Without it the endpoint binds the + /// source by embedding its bytes. + pub(crate) reference: Option<&'a str>, + /// The exact hostname the source was fetched from, which the endpoint + /// holds the reference and the claim's managed origins against, or + /// `null` only for media embedded in a document, which was fetched from + /// no host of its own. Never a scheme, port, path or query. + pub(crate) origin_host: Option<&'a str>, + /// The declared media type, a hint the endpoint checks against the bytes. + pub(crate) mime: &'a str, + pub(crate) sha256: &'a str, + pub(crate) byte_length: usize, + /// The source is a platform image optimizer's answer: the endpoint + /// refuses it unless the bytes carry an embedded manifest, which it + /// validates as it validates any ingredient's. Sent only when true. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub(crate) require_embedded_manifest: bool, +} + +/// What the edge did to the source before it was delivered: nothing. +#[derive(Debug, Serialize)] +pub(crate) struct ExecutionRecord<'a> { + pub(crate) source_sha256: &'a str, + pub(crate) final_sha256: &'a str, + /// The exact source was captured and no operation was applied. + pub(crate) complete: bool, + pub(crate) operations: NoOperations, +} + +/// The empty operation list of media delivered as it was captured. +#[derive(Debug)] +pub(crate) struct NoOperations; + +impl Serialize for NoOperations { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_seq(Some(0))?.end() + } +} + +/// How the signed representation reaches readers. +#[derive(Debug, Serialize)] +pub(crate) struct Delivery { + /// Whether every response of the representation can carry a `Link` + /// header naming a remote manifest. + pub(crate) http_link_header: bool, +} + +impl<'a> MediaMetadata<'a> { + /// Metadata for `source` delivered unmodified: no `final` part, an empty + /// and complete execution record. + pub(crate) fn unmodified( + page_url: &'a str, + expected_policy_digest: &'a str, + source: SourceMetadata<'a>, + http_link_header: bool, + ) -> Self { + let sha256 = source.sha256; + Self { + page_url, + expected_policy_digest, + source, + execution_record: ExecutionRecord { + source_sha256: sha256, + final_sha256: sha256, + complete: true, + operations: NoOperations, + }, + delivery: Delivery { http_link_header }, + } + } +} + +/// A built multipart request body and its `Content-Type`. +#[derive(Debug)] +pub(crate) struct Multipart { + pub(crate) content_type: String, + pub(crate) body: Bytes, +} + +/// Builds the `multipart/form-data` body with the `metadata` and `source` +/// parts, with a boundary that occurs in neither. +pub(crate) fn multipart(metadata: &[u8], source: &[u8]) -> Multipart { + let seed = sha256_hex(metadata); + let mut attempt = 0_u32; + let boundary = loop { + let candidate = format!( + "encypher-media-{}-{attempt}", + seed.get(..32).unwrap_or(&seed) + ); + let delimiter = format!("--{candidate}"); + if !contains(metadata, delimiter.as_bytes()) && !contains(source, delimiter.as_bytes()) { + break candidate; + } + attempt = attempt.saturating_add(1); + }; + let metadata_head = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"metadata\"\r\n\ + Content-Type: application/json\r\n\r\n" + ); + let source_head = format!( + "\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"source\"; \ + filename=\"source\"\r\nContent-Type: application/octet-stream\r\n\r\n" + ); + let closing = format!("\r\n--{boundary}--\r\n"); + let mut body = BytesMut::with_capacity( + metadata_head.len() + metadata.len() + source_head.len() + source.len() + closing.len(), + ); + body.put_slice(metadata_head.as_bytes()); + body.put_slice(metadata); + body.put_slice(source_head.as_bytes()); + body.put_slice(source); + body.put_slice(closing.as_bytes()); + Multipart { + content_type: format!("multipart/form-data; boundary={boundary}"), + body: body.freeze(), + } +} + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +/// Lowercase hex SHA-256 of `bytes`. +pub(crate) fn sha256_hex(bytes: &[u8]) -> String { + hex_lower(&Sha256::digest(bytes)) +} + +fn hex_lower(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from(DIGITS[usize::from(byte >> 4)])); + out.push(char::from(DIGITS[usize::from(byte & 0x0f)])); + } + out +} + +/// The bare media type an answer must carry for media declared as `declared`, +/// with the common `image/jpg` spelling read as `image/jpeg`, and the +/// `audio/x-aac` and `audio/aacp` spellings of raw AAC as `audio/aac`. +pub(crate) fn canonical_media_type(declared: &str) -> String { + let media_type = media_type_of(declared); + match media_type.as_str() { + "image/jpg" => "image/jpeg".to_owned(), + "audio/x-aac" | "audio/aacp" => "audio/aac".to_owned(), + _ => media_type, + } +} + +/// Whether `reference` may be sent as the source's address: canonical +/// `https` on the default port, with a path, no credentials, query or +/// fragment, on `host`. Anything else is sent as no reference. +pub(crate) fn safe_source_reference(reference: &str, host: &str) -> bool { + if reference.len() > MAX_URL_BYTES || !reference.is_ascii() { + return false; + } + let Ok(parsed) = url::Url::parse(reference) else { + return false; + }; + parsed.scheme() == "https" + && parsed.as_str() == reference + && parsed.username().is_empty() + && parsed.password().is_none() + && parsed.port().is_none() + && parsed.query().is_none() + && parsed.fragment().is_none() + && parsed.path() != "/" + && parsed.host_str() == Some(host) +} + +/// Whether `host` is one canonical hostname alone: no scheme, user +/// information, port, path or query, in lower case. +pub(crate) fn valid_origin_host(host: &str) -> bool { + !host.is_empty() + && host.len() <= 253 + && url::Url::parse(&format!("https://{host}/")).is_ok_and(|parsed| { + parsed.host_str() == Some(host) + && parsed.port().is_none() + && parsed.username().is_empty() + && parsed.path() == "/" + }) +} + +/// Where the signed bytes' manifest lives. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Disposition { + /// Inside the signed bytes. + Embedded, + /// At [`SignedMedia::manifest_url`], named by the bytes or a `Link`. + External, +} + +/// What a signing answer must match. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Expected<'a> { + pub(crate) policy_digest: &'a str, + /// The bare media type the source was delivered as. + pub(crate) media_type: &'a str, + /// SHA-256 of the bytes as they were before signing. + pub(crate) unsigned_sha256: &'a str, + /// Whether every response of the representation can carry a `Link`. + pub(crate) link_deliverable: bool, +} + +/// A verified signing answer. +#[derive(Debug, Clone)] +pub(crate) struct SignedMedia { + pub(crate) bytes: Bytes, + pub(crate) content_type: HeaderValue, + pub(crate) instance_id: String, + pub(crate) document_id: String, + pub(crate) verification_url: String, + pub(crate) disposition: Disposition, + pub(crate) manifest_url: Option, +} + +impl SignedMedia { + /// The `Link` header value naming a remote manifest, when there is one. + pub(crate) fn link(&self) -> Option { + self.manifest_url + .as_deref() + .map(|url| format!("<{url}>; rel=\"c2pa-manifest\"")) + } +} + +/// Why a `200` answer is not accepted as signed media. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResponseError { + /// The answer's media type is not the one the source was delivered as. + ContentType, + /// The answer was signed under another policy. + PolicyDigest, + /// The body is not the bytes the signed digest names. + SignedDigest, + /// A header is missing, repeated or malformed. + Malformed, + /// The manifest is remote and needs a `Link` the delivery cannot carry. + LinkUndeliverable, + /// The body carries no manifest and no reference to one. + Unsigned, +} + +/// The one value of `name`, when it appears exactly once as visible ASCII. +fn single<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + let mut values = headers.get_all(name).iter(); + let value = values.next()?; + if values.next().is_some() { + return None; + } + value.to_str().ok() +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_IDENTIFIER_BYTES + && value.bytes().all(|byte| byte.is_ascii_graphic()) +} + +/// Whether `value` is a canonical absolute `https` URL that is safe inside a +/// `Link` header's angle brackets. +fn valid_https_url(value: &str) -> bool { + value.len() <= MAX_URL_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'<' | b'>' | b'"')) + && url::Url::parse(value).is_ok_and(|parsed| { + parsed.scheme() == "https" && parsed.host_str().is_some() && parsed.as_str() == value + }) +} + +/// Checks a `200` signing answer against what was sent, and keeps it only +/// when it is signed media this delivery can carry. +/// +/// # Errors +/// +/// Returns the first [`ResponseError`] the answer shows. +pub(crate) fn verify_signed_response( + headers: &HeaderMap, + body: Bytes, + expected: &Expected<'_>, +) -> Result { + let content_type = single(headers, "content-type").ok_or(ResponseError::Malformed)?; + if canonical_media_type(content_type) != expected.media_type { + return Err(ResponseError::ContentType); + } + let policy_digest = single(headers, POLICY_DIGEST_HEADER).ok_or(ResponseError::Malformed)?; + if !valid_policy_digest(policy_digest) { + return Err(ResponseError::Malformed); + } + if policy_digest != expected.policy_digest { + return Err(ResponseError::PolicyDigest); + } + let signed_sha256 = single(headers, SIGNED_SHA256_HEADER).ok_or(ResponseError::Malformed)?; + if !valid_policy_digest(signed_sha256) { + return Err(ResponseError::Malformed); + } + if body.is_empty() || sha256_hex(&body) != signed_sha256 { + return Err(ResponseError::SignedDigest); + } + let instance_id = single(headers, INSTANCE_ID_HEADER) + .filter(|value| valid_identifier(value)) + .ok_or(ResponseError::Malformed)?; + let document_id = single(headers, DOCUMENT_ID_HEADER) + .filter(|value| valid_identifier(value)) + .ok_or(ResponseError::Malformed)?; + let verification_url = single(headers, VERIFICATION_URL_HEADER) + .filter(|value| valid_https_url(value)) + .ok_or(ResponseError::Malformed)?; + let link_required = match single(headers, LINK_REQUIRED_HEADER) { + Some("true") => true, + Some("false") => false, + _ => return Err(ResponseError::Malformed), + }; + let manifest_url = headers.get(MANIFEST_URL_HEADER).map(|_| { + single(headers, MANIFEST_URL_HEADER) + .filter(|value| valid_https_url(value)) + .ok_or(ResponseError::Malformed) + }); + let changed = signed_sha256 != expected.unsigned_sha256; + let disposition = match single(headers, DISPOSITION_HEADER) { + Some("embedded") => { + // The manifest store is in the bytes, so they changed, hold the + // `c2pa` label and point nowhere else. These are consistency + // checks against a wrong answer, not a signature check: the + // signature is trusted through the authenticated endpoint and + // the digest binding above. + if manifest_url.is_some() || link_required { + return Err(ResponseError::Malformed); + } + if !changed || !contains(&body, b"c2pa") { + return Err(ResponseError::Unsigned); + } + Disposition::Embedded + } + Some("external") => { + if manifest_url.is_none() { + return Err(ResponseError::Malformed); + } + single(headers, MANIFEST_SHA256_HEADER) + .filter(|value| valid_policy_digest(value)) + .ok_or(ResponseError::Malformed)?; + if link_required && !expected.link_deliverable { + return Err(ResponseError::LinkUndeliverable); + } + // A remote manifest no `Link` names must be named by the bytes. + if !link_required && !changed { + return Err(ResponseError::Unsigned); + } + Disposition::External + } + _ => return Err(ResponseError::Malformed), + }; + let manifest_url = manifest_url.transpose()?.map(str::to_owned); + let content_type = HeaderValue::from_str(content_type).map_err(|_| ResponseError::Malformed)?; + Ok(SignedMedia { + bytes: body, + content_type, + instance_id: instance_id.to_owned(), + document_id: document_id.to_owned(), + verification_url: verification_url.to_owned(), + disposition, + manifest_url, + }) +} + +/// The failure code of an error answer, kept only as a short identifier. +pub(crate) fn error_code(body: &[u8]) -> Option { + #[derive(Deserialize)] + struct ErrorBody { + error: ErrorDetail, + } + #[derive(Deserialize)] + struct ErrorDetail { + code: String, + } + if body.len() > MAX_ERROR_BODY_BYTES { + return None; + } + let parsed: ErrorBody = serde_json::from_slice(body).ok()?; + let code = parsed.error.code; + (!code.is_empty() + && code.len() <= MAX_ERROR_CODE_BYTES + && code + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')) + .then_some(code) +} + +#[cfg(test)] +mod tests { + use http::HeaderName; + use serde_json::json; + + use super::*; + + const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const UNSIGNED: &[u8] = b"\x89PNG\r\n\x1a\nunsigned"; + const SIGNED: &[u8] = b"\x89PNG\r\n\x1a\nunsigned-caBXjumbc2pa-manifest"; + + fn expected(unsigned_sha256: &str, link_deliverable: bool) -> Expected<'_> { + Expected { + policy_digest: DIGEST, + media_type: "image/png", + unsigned_sha256, + link_deliverable, + } + } + + fn embedded_headers(body: &[u8]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for (name, value) in [ + ("content-type", "image/png".to_owned()), + (POLICY_DIGEST_HEADER, DIGEST.to_owned()), + (SIGNED_SHA256_HEADER, sha256_hex(body)), + (INSTANCE_ID_HEADER, "xmp:iid:0001".to_owned()), + (DISPOSITION_HEADER, "embedded".to_owned()), + (LINK_REQUIRED_HEADER, "false".to_owned()), + ( + VERIFICATION_URL_HEADER, + "https://verify.example.com/doc-1".to_owned(), + ), + (DOCUMENT_ID_HEADER, "doc-1".to_owned()), + ] { + headers.insert( + HeaderName::from_static(name), + HeaderValue::from_str(&value).expect("should build header"), + ); + } + headers + } + + fn external_headers(body: &[u8], link_required: bool) -> HeaderMap { + let mut headers = embedded_headers(body); + headers.insert(DISPOSITION_HEADER, HeaderValue::from_static("external")); + headers.insert( + MANIFEST_URL_HEADER, + HeaderValue::from_static("https://api.example.com/manifests/doc-1"), + ); + headers.insert( + MANIFEST_SHA256_HEADER, + HeaderValue::from_str(DIGEST).expect("should build header"), + ); + headers.insert( + LINK_REQUIRED_HEADER, + HeaderValue::from_static(if link_required { "true" } else { "false" }), + ); + headers + } + + #[test] + fn metadata_matches_the_frozen_wire_shape() { + let sha = sha256_hex(UNSIGNED); + let metadata = MediaMetadata::unmodified( + "https://publisher.example.com/news/story", + DIGEST, + SourceMetadata { + reference: None, + origin_host: None, + mime: "image/png", + sha256: &sha, + byte_length: UNSIGNED.len(), + require_embedded_manifest: false, + }, + false, + ); + let value = serde_json::to_value(&metadata).expect("should serialize metadata"); + assert_eq!( + value, + json!({ + "page_url": "https://publisher.example.com/news/story", + "expected_policy_digest": DIGEST, + "source": { + "reference": null, + "origin_host": null, + "mime": "image/png", + "sha256": sha, + "byte_length": UNSIGNED.len(), + }, + "execution_record": { + "source_sha256": sha, + "final_sha256": sha, + "complete": true, + "operations": [], + }, + "delivery": { "http_link_header": false }, + }), + "should send an unmodified, complete record with a null reference" + ); + } + + #[test] + fn an_optimizer_answer_requires_its_embedded_manifest_on_the_wire() { + let sha = sha256_hex(SIGNED); + let metadata = MediaMetadata::unmodified( + "https://publisher.example.com/images/photo.jpg", + DIGEST, + SourceMetadata { + reference: None, + origin_host: Some("origin.example.com"), + mime: "image/png", + sha256: &sha, + byte_length: SIGNED.len(), + require_embedded_manifest: true, + }, + true, + ); + let value = serde_json::to_value(&metadata).expect("should serialize metadata"); + assert_eq!( + value["source"], + json!({ + "reference": null, + "origin_host": "origin.example.com", + "mime": "image/png", + "sha256": sha, + "byte_length": SIGNED.len(), + "require_embedded_manifest": true, + }), + "should require the optimizer's embedded manifest and name no address" + ); + assert_eq!( + value["execution_record"], + json!({ + "source_sha256": sha, + "final_sha256": sha, + "complete": true, + "operations": [], + }), + "should report no operation of the edge's own" + ); + } + + #[test] + fn multipart_carries_exact_source_bytes_under_an_absent_boundary() { + let source = b"--encypher-media-binary\r\n\x00\xff"; + let built = multipart(br#"{"page_url":"x"}"#, source); + let boundary = built + .content_type + .strip_prefix("multipart/form-data; boundary=") + .expect("should declare a boundary"); + let body = built.body.as_ref(); + let source_start = body + .windows(4) + .position(|window| window == b"\r\n\r\n") + .and_then(|first| { + body[first + 4..] + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|second| first + 4 + second + 4) + }) + .expect("should find the source part body"); + let tail = format!("\r\n--{boundary}--\r\n"); + assert!(body.ends_with(tail.as_bytes()), "should close the body"); + assert_eq!( + &body[source_start..body.len() - tail.len()], + source, + "should carry the exact source bytes" + ); + assert!( + body.windows(9).any(|window| window == b"name=\"sou"), + "should name the source part" + ); + assert!( + !body.windows(7).any(|window| window == b"\"final\""), + "should send no final part for unmodified media" + ); + } + + #[test] + fn an_embedded_answer_is_accepted_only_with_matching_bytes_and_policy() { + let unsigned = sha256_hex(UNSIGNED); + let signed = verify_signed_response( + &embedded_headers(SIGNED), + Bytes::from_static(SIGNED), + &expected(&unsigned, false), + ) + .expect("should accept a verified embedded answer"); + assert_eq!(signed.bytes.as_ref(), SIGNED, "should keep the exact bytes"); + assert_eq!( + signed.disposition, + Disposition::Embedded, + "should be embedded" + ); + assert_eq!(signed.link(), None, "should need no Link"); + + let tampered = verify_signed_response( + &embedded_headers(SIGNED), + Bytes::from_static(b"\x89PNG\r\n\x1a\nc2pa-other"), + &expected(&unsigned, false), + ); + assert_eq!( + tampered.expect_err("should refuse a body the digest does not name"), + ResponseError::SignedDigest + ); + + let mut stale = embedded_headers(SIGNED); + stale.insert( + POLICY_DIGEST_HEADER, + HeaderValue::from_str(&"b".repeat(64)).expect("should build header"), + ); + assert_eq!( + verify_signed_response( + &stale, + Bytes::from_static(SIGNED), + &expected(&unsigned, false) + ) + .expect_err("should refuse another policy"), + ResponseError::PolicyDigest + ); + + let mut retyped = embedded_headers(SIGNED); + retyped.insert("content-type", HeaderValue::from_static("image/jpeg")); + assert_eq!( + verify_signed_response( + &retyped, + Bytes::from_static(SIGNED), + &expected(&unsigned, false) + ) + .expect_err("should refuse a changed media type"), + ResponseError::ContentType + ); + } + + #[test] + fn unchanged_bytes_are_never_accepted_as_signed() { + let unsigned = sha256_hex(UNSIGNED); + assert_eq!( + verify_signed_response( + &embedded_headers(UNSIGNED), + Bytes::from_static(UNSIGNED), + &expected(&unsigned, false), + ) + .expect_err("should refuse an unsigned embedded answer"), + ResponseError::Unsigned + ); + assert_eq!( + verify_signed_response( + &external_headers(UNSIGNED, false), + Bytes::from_static(UNSIGNED), + &expected(&unsigned, true), + ) + .expect_err("should refuse a remote manifest nothing names"), + ResponseError::Unsigned + ); + } + + #[test] + fn a_remote_manifest_needs_a_deliverable_link() { + let unsigned = sha256_hex(UNSIGNED); + assert_eq!( + verify_signed_response( + &external_headers(UNSIGNED, true), + Bytes::from_static(UNSIGNED), + &expected(&unsigned, false), + ) + .expect_err("should refuse a Link the delivery cannot carry"), + ResponseError::LinkUndeliverable + ); + let signed = verify_signed_response( + &external_headers(UNSIGNED, true), + Bytes::from_static(UNSIGNED), + &expected(&unsigned, true), + ) + .expect("should accept a remote manifest the delivery links"); + assert_eq!( + signed.link().as_deref(), + Some("; rel=\"c2pa-manifest\""), + "should name the manifest in a Link" + ); + } + + #[test] + fn repeated_or_malformed_signing_headers_are_refused() { + let unsigned = sha256_hex(UNSIGNED); + let mut repeated = embedded_headers(SIGNED); + repeated.append(INSTANCE_ID_HEADER, HeaderValue::from_static("xmp:iid:0002")); + let mut bad_url = embedded_headers(SIGNED); + bad_url.insert( + VERIFICATION_URL_HEADER, + HeaderValue::from_static("http://verify.example.com/doc-1"), + ); + let mut bad_disposition = embedded_headers(SIGNED); + bad_disposition.insert(DISPOSITION_HEADER, HeaderValue::from_static("sidecar")); + for headers in [repeated, bad_url, bad_disposition] { + assert_eq!( + verify_signed_response( + &headers, + Bytes::from_static(SIGNED), + &expected(&unsigned, false) + ) + .expect_err("should refuse malformed headers"), + ResponseError::Malformed + ); + } + } + + #[test] + fn common_misspellings_name_the_media_type_the_service_lists() { + for (declared, canonical) in [ + ("image/jpg", "image/jpeg"), + ("audio/x-aac", "audio/aac"), + ("Audio/AACP; codecs=aac", "audio/aac"), + ("audio/aac", "audio/aac"), + ("audio/mp4", "audio/mp4"), + ] { + assert_eq!(canonical_media_type(declared), canonical, "{declared}"); + } + } + + #[test] + fn source_references_are_sent_only_when_canonical_and_on_the_claim_host() { + let host = "publisher.example.com"; + assert!( + safe_source_reference("https://publisher.example.com/images/a.png", host), + "should send a canonical https address" + ); + for unsafe_reference in [ + "https://publisher.example.com/images/a.png?token=secret", + "https://user:pass@publisher.example.com/images/a.png", + "https://publisher.example.com/images/a.png#frag", + "http://publisher.example.com/images/a.png", + "https://publisher.example.com:8443/images/a.png", + "https://origin.example.com/images/a.png", + "https://PUBLISHER.example.com/images/a.png", + "https://publisher.example.com/", + ] { + assert!( + !safe_source_reference(unsafe_reference, host), + "should never send {unsafe_reference}" + ); + } + } + + #[test] + fn error_codes_are_read_as_short_identifiers_only() { + let body = serde_json::to_vec(&json!({ + "success": false, + "error": {"code": "TRUSTED_SERVER_POLICY_CHANGED", "message": "stale"}, + "correlation_id": "c-1", + })) + .expect("should serialize"); + assert_eq!( + error_code(&body).as_deref(), + Some("TRUSTED_SERVER_POLICY_CHANGED") + ); + let injected = + serde_json::to_vec(&json!({"error": {"code": "a b\nc"}})).expect("should serialize"); + assert_eq!(error_code(&injected), None, "should drop an unsafe code"); + } +} diff --git a/crates/provenance/encypher/src/prepared_probe.rs b/crates/provenance/encypher/src/prepared_probe.rs index 478b13450f..2521442616 100644 --- a/crates/provenance/encypher/src/prepared_probe.rs +++ b/crates/provenance/encypher/src/prepared_probe.rs @@ -69,7 +69,7 @@ const PRODUCTION_RELEASE_KEY: [u8; 32] = [ /// The lowest probe release this build runs. Raise it when Encypher /// withdraws a release, so a compromised API cannot downgrade the edge to it. -const PRODUCTION_RELEASE_FLOOR: u64 = 1; +const PRODUCTION_RELEASE_FLOOR: u64 = 8; /// A release artifact is about 800 KB; anything over this is not one. const MAX_ARTIFACT_BYTES: usize = 4 * 1024 * 1024; @@ -128,6 +128,7 @@ impl ProbeDescriptor { .unwrap_or_default() } + #[cfg(test)] /// The descriptor as the signing API receives it back. #[must_use] pub(crate) fn to_json(&self) -> Value { @@ -332,7 +333,10 @@ impl fmt::Display for ReleaseError { write!(formatter, "probe release {release} was withdrawn") } Self::MalformedSignature { release } => { - write!(formatter, "probe release {release} carries a malformed signature") + write!( + formatter, + "probe release {release} carries a malformed signature" + ) } Self::Unsigned { release } => write!( formatter, @@ -368,15 +372,21 @@ impl fmt::Display for ReleaseError { formatter, "the probe release reports ABI {reported}, not its descriptor's" ), - Self::PlannerMismatch => formatter - .write_str("the probe release does not report its descriptor's planner"), + Self::PlannerMismatch => { + formatter.write_str("the probe release does not report its descriptor's planner") + } } } } impl std::error::Error for ReleaseError {} -/// Bounds on one call. Exceeding any of them abandons the call. +/// Bounds on one call. +/// +/// Fuel, the total read and the result abandon the call when exceeded. A +/// read over the per-read bound is answered with status 5, and growth past +/// the memory bound fails inside the release, which then answers as it +/// handles an allocation failure. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct ProbeLimits { /// Linear memory one instance may grow to. @@ -392,15 +402,16 @@ pub(crate) struct ProbeLimits { } impl Default for ProbeLimits { - /// The client contract's limits: 1 GiB of memory, 2x10^11 instructions, - /// 64 MiB per read and per result, and at most 1 GiB read per call. + /// Leave room for the host, parsed JSON and the signed module on + /// 128 MiB-class runtimes. Media payload hashing stays outside WASM. + /// Larger structure or result disclosures are refused explicitly. fn default() -> Self { Self { - memory_bytes: 1 << 30, - fuel_per_call: 200_000_000_000, - max_read_bytes: 64 * 1024 * 1024, - max_total_read_bytes: 1 << 30, - max_result_bytes: 64 * 1024 * 1024, + memory_bytes: 32 * 1024 * 1024, + fuel_per_call: 200_000_000, + max_read_bytes: 8 * 1024 * 1024, + max_total_read_bytes: 64 * 1024 * 1024, + max_result_bytes: 4 * 1024 * 1024, } } } @@ -482,7 +493,11 @@ impl fmt::Display for ProbeCallError { match self { Self::Source(error) => write!(formatter, "the probe's source failed a read: {error}"), Self::Refused(refusal) => { - write!(formatter, "the probe refused: {}: {}", refusal.code, refusal.message) + write!( + formatter, + "the probe refused: {}: {}", + refusal.code, refusal.message + ) } Self::Failed(failure) => failure.fmt(formatter), } @@ -541,19 +556,28 @@ impl fmt::Display for ProbeFailure { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Instantiate(error) => { - write!(formatter, "the probe release could not be instantiated: {error}") + write!( + formatter, + "the probe release could not be instantiated: {error}" + ) } Self::ArgumentTooLarge => { formatter.write_str("a probe argument exceeds a 32-bit length") } - Self::Trap(error) => write!(formatter, "the probe release stopped with an error: {error}"), + Self::Trap(error) => write!( + formatter, + "the probe release stopped with an error: {error}" + ), Self::FuelExhausted => formatter.write_str("the probe release ran out of fuel"), Self::ReadBudgetExceeded { budget } => write!( formatter, "the probe release read more than its {budget} byte budget" ), Self::UnknownStatus(status) => { - write!(formatter, "the probe release returned unknown status {status}") + write!( + formatter, + "the probe release returned unknown status {status}" + ) } Self::ResultTooLarge { bytes, limit } => write!( formatter, @@ -563,7 +587,10 @@ impl fmt::Display for ProbeFailure { formatter.write_str("the probe release's result lies outside its memory") } Self::MalformedResult(error) => { - write!(formatter, "the probe release returned a malformed result: {error}") + write!( + formatter, + "the probe release returned a malformed result: {error}" + ) } } } @@ -676,9 +703,9 @@ impl ProbeModule { arg: &Value, source: &S, ) -> Result> { - let mut instance = self - .instantiate(source.size()) - .map_err(|error| ProbeCallError::Failed(ProbeFailure::Instantiate(error.to_string())))?; + let mut instance = self.instantiate(source.size()).map_err(|error| { + ProbeCallError::Failed(ProbeFailure::Instantiate(error.to_string())) + })?; let (op_pointer, op_length) = instance .put(op.as_bytes()) .map_err(ProbeCallError::Failed)?; @@ -774,7 +801,9 @@ impl ProbeModule { ); store.limiter(|state| &mut state.limits); store.set_fuel(self.limits.fuel_per_call)?; - let instance = self.linker.instantiate_and_start(&mut store, &self.module)?; + let instance = self + .linker + .instantiate_and_start(&mut store, &self.module)?; let memory = instance .get_memory(&store, "memory") .ok_or_else(|| wasmi::Error::new("the release exports no memory"))?; @@ -832,6 +861,10 @@ fn refusal(value: &Value) -> Result { } /// A call stopped by the interpreter: out of fuel, or any other trap. +#[allow( + clippy::needless_pass_by_value, + reason = "used directly as a Result error mapper" +)] fn stopped(error: wasmi::Error) -> ProbeCallError { ProbeCallError::Failed(ProbeFailure::stopped(&error)) } @@ -866,6 +899,10 @@ impl wasmi::errors::HostError for ReadRequest {} /// `env.host_read`. Refuses a read over the per-read bound or past the /// source with status 5, and suspends the call for any other. +#[allow( + clippy::needless_pass_by_value, + reason = "wasmi's host import ABI owns its Caller" +)] fn host_read( caller: Caller<'_, HostState>, offset: u64, diff --git a/crates/provenance/encypher/src/prepared_probe/tests.rs b/crates/provenance/encypher/src/prepared_probe/tests.rs index f0edea026e..a9516890d3 100644 --- a/crates/provenance/encypher/src/prepared_probe/tests.rs +++ b/crates/provenance/encypher/src/prepared_probe/tests.rs @@ -17,8 +17,8 @@ use futures::executor::block_on; use serde_json::json; use super::{ - MAX_ARTIFACT_BYTES, PRODUCTION_RELEASE_KEY, ProbeCallError, ProbeDescriptor, ProbeFailure, - ProbeLimits, ProbeModule, ProbeSource, ReleaseAuthority, ReleaseError, + MAX_ARTIFACT_BYTES, ProbeCallError, ProbeDescriptor, ProbeFailure, ProbeLimits, ProbeModule, + ProbeSource, ReleaseAuthority, ReleaseError, }; use crate::media_api::sha256_hex; @@ -34,12 +34,15 @@ const READ_AT: usize = 2048; const STATUS_AT: usize = 3072; const UNKNOWN_AT: usize = 4096; const READ_FAILED_AT: usize = 5120; +/// WebAssembly page size, and the stand-in's initial linear memory. +const PAGE_BYTES: usize = 64 * 1024; +const INITIAL_PAGES: usize = 2; const STAND_IN: &str = r#" (module (import "env" "host_read" (func $host_read (param i64 i32 i32) (result i32))) @EXTRA_IMPORT@ - (memory (export "memory") 2) + (memory (export "memory") @INITIAL_PAGES@) (global $heap (mut i32) (i32.const 16384)) (global $result_ptr (mut i32) (i32.const 0)) (global $result_len (mut i32) (i32.const 0)) @@ -96,9 +99,28 @@ const STAND_IN: &str = r#" (br_if $again (i32.eqz (call $host_read (i64.const 0) (i32.const 4) (i32.const @HEAD_SLOT@))))) (return (call $read_failed)))) - ;; "Grow": 100 pages more memory; reports 0 when refused + ;; "Grow": memory to one page past the bound; reports 0 when refused (if (i32.eq (local.get $kind) (i32.const 71)) - (then (return (call $report (i32.add (i32.const 1) (memory.grow (i32.const 100))))))) + (then (return (call $report + (i32.add (i32.const 1) (memory.grow (i32.const @OVER_PAGES@))))))) + ;; "allocate": memory up to exactly the bound; reports 0 when refused + (if (i32.eq (local.get $kind) (i32.const 97)) + (then (return (call $report + (i32.add (i32.const 1) (memory.grow (i32.const @FIT_PAGES@))))))) + ;; "Beyond": one byte more than the per-read bound + (if (i32.eq (local.get $kind) (i32.const 66)) + (then (return (call $report + (call $host_read (i64.const 0) (i32.const @READ_BOUND_PLUS_ONE@) (i32.const 0)))))) + ;; "bound": exactly the per-read bound, into more than linear memory + (if (i32.eq (local.get $kind) (i32.const 98)) + (then (return (call $report + (call $host_read (i64.const 0) (i32.const @READ_BOUND@) (i32.const 0)))))) + ;; "flood": per-read-bound reads forever, ignoring every status + (if (i32.eq (local.get $kind) (i32.const 102)) + (then + (loop $again + (drop (call $host_read (i64.const 0) (i32.const @READ_BOUND@) (i32.const 0))) + (br $again)))) ;; "echo": the argument as received (if (i32.eq (local.get $kind) (i32.const 101)) (then (return (call $answer (local.get $arg) (local.get $arg_len) (i32.const 0))))) @@ -135,6 +157,9 @@ fn stand_in(abi: u32, planner: &str, extra_import: bool) -> Vec { } else { "" }; + let edge = ProbeLimits::default(); + let fit_pages = edge.memory_bytes / PAGE_BYTES - INITIAL_PAGES; + let read_bound = edge.max_read_bytes; let text = STAND_IN .replace("@EXTRA_IMPORT@", extra) .replace("@ABI@", &abi.to_string()) @@ -155,7 +180,12 @@ fn stand_in(abi: u32, planner: &str, extra_import: bool) -> Vec { .replace("@UNKNOWN_LEN@", &UNKNOWN_JSON.len().to_string()) .replace("@READ_FAILED_AT@", &READ_FAILED_AT.to_string()) .replace("@READ_FAILED_JSON@", &wat_string(READ_FAILED_JSON)) - .replace("@READ_FAILED_LEN@", &READ_FAILED_JSON.len().to_string()); + .replace("@READ_FAILED_LEN@", &READ_FAILED_JSON.len().to_string()) + .replace("@INITIAL_PAGES@", &INITIAL_PAGES.to_string()) + .replace("@FIT_PAGES@", &fit_pages.to_string()) + .replace("@OVER_PAGES@", &(fit_pages + 1).to_string()) + .replace("@READ_BOUND@", &read_bound.to_string()) + .replace("@READ_BOUND_PLUS_ONE@", &(read_bound + 1).to_string()); wat::parse_str(&text).expect("should assemble the stand-in release") } @@ -163,7 +193,13 @@ fn release_key() -> SigningKey { SigningKey::from_bytes(&[7; 32]) } -fn signed(key: &SigningKey, abi: u64, release: u64, planner: &str, sha256: &str) -> ProbeDescriptor { +fn signed( + key: &SigningKey, + abi: u64, + release: u64, + planner: &str, + sha256: &str, +) -> ProbeDescriptor { let mut descriptor = ProbeDescriptor { abi, planner_id: planner.to_owned(), @@ -184,8 +220,13 @@ fn authority(floor: u64, revoked: Vec) -> ReleaseAuthority { fn loaded(limits: ProbeLimits) -> ProbeModule { let artifact = stand_in(1, &planner(), false); let descriptor = signed(&release_key(), 1, 3, &planner(), &sha256_hex(&artifact)); - block_on(ProbeModule::load(&authority(1, Vec::new()), &descriptor, &artifact, limits)) - .expect("should load the signed stand-in") + block_on(ProbeModule::load( + &authority(1, Vec::new()), + &descriptor, + &artifact, + limits, + )) + .expect("should load the signed stand-in") } fn load_error(artifact: &[u8], descriptor: &ProbeDescriptor) -> ReleaseError { @@ -231,6 +272,7 @@ impl Future for YieldOnce { /// An in-memory source whose every read first suspends. struct AsyncSource { bytes: Vec, + size: u64, fail: bool, reads: RefCell>, suspensions: Cell, @@ -240,6 +282,7 @@ impl AsyncSource { fn new(bytes: &[u8]) -> Self { Self { bytes: bytes.to_vec(), + size: bytes.len() as u64, fail: false, reads: RefCell::new(Vec::new()), suspensions: Cell::new(0), @@ -253,6 +296,15 @@ impl AsyncSource { } } + /// A source that claims `size` bytes and holds none, for reads the host + /// must refuse before asking it. + fn claiming(size: u64) -> Self { + Self { + size, + ..Self::new(&[]) + } + } + fn reads(&self) -> Vec<(u64, usize)> { self.reads.borrow().clone() } @@ -262,10 +314,14 @@ impl ProbeSource for AsyncSource { type Error = VersionChanged; fn size(&self) -> u64 { - self.bytes.len() as u64 + self.size } - async fn read_exact_at(&self, offset: u64, destination: &mut [u8]) -> Result<(), VersionChanged> { + async fn read_exact_at( + &self, + offset: u64, + destination: &mut [u8], + ) -> Result<(), VersionChanged> { self.suspensions.set(self.suspensions.get() + 1); YieldOnce::default().await; self.reads.borrow_mut().push((offset, destination.len())); @@ -278,29 +334,57 @@ impl ProbeSource for AsyncSource { } } +/// Release 8 as the production signing service advertised it, signed by +/// Encypher's production release key. Only the public descriptor is kept; +/// its artifact is not part of this repository. +const PRODUCTION_RELEASE_8: &str = r#"{ + "abi": 1, + "planner_id": "0685f74f2b47b171a62be7d8f38224c85b5fdc1fc849d316e8c93910406ece04", + "release": 8, + "sha256": "45c8e965b33e6763a52a9164392fb584f1dd6d993e9583a9f51945caa4ebed59", + "signature": "Mge6x1crHLQtuvn0eDjtapacOVTiEfU7/GOoxRd09e2bFGZaiSn9CvBlf/hSZs6Auvb2aRhWDVeOqnw+iTMMCw==" +}"#; + #[test] -fn production_authority_pins_the_published_release_key() { - let hex: String = PRODUCTION_RELEASE_KEY - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); +fn production_authority_accepts_only_releases_encypher_signed() { + let authority = ReleaseAuthority::production(); + let published: serde_json::Value = + serde_json::from_str(PRODUCTION_RELEASE_8).expect("should parse the fixture"); + let published = ProbeDescriptor::parse(&published).expect("should be a descriptor"); assert_eq!( - hex, "9ae0bf4aea5140008d2f48e6c08691360a7fe23e0d36f24522c770b7646b75d9", - "should pin the key the client contract publishes" + authority.select(std::slice::from_ref(&published)), + Ok(&published), + "should accept the release Encypher published" ); - let authority = ReleaseAuthority::production(); - let descriptor = signed(&release_key(), 1, 3, &planner(), &"b".repeat(64)); + + let mut retargeted = published.clone(); + retargeted.sha256 = "b".repeat(64); assert_eq!( - authority.verify(&descriptor), - Err(ReleaseError::Unsigned { release: 3 }), - "production should refuse a release signed by any other key" + authority.verify(&retargeted), + Err(ReleaseError::Unsigned { release: 8 }), + "should refuse the published signature over another artifact" + ); + let foreign = signed( + &release_key(), + 1, + 8, + &published.planner_id, + &published.sha256, + ); + assert_eq!( + authority.verify(&foreign), + Err(ReleaseError::Unsigned { release: 8 }), + "should refuse the same release signed by any other key" ); } #[test] fn descriptors_parse_only_in_the_contract_shape() { let descriptor = signed(&release_key(), 1, 3, &planner(), &"b".repeat(64)); - assert_eq!(ProbeDescriptor::parse(&descriptor.to_json()), Some(descriptor.clone())); + assert_eq!( + ProbeDescriptor::parse(&descriptor.to_json()), + Some(descriptor.clone()) + ); assert_eq!( descriptor.canonical_json(), format!( @@ -324,7 +408,11 @@ fn descriptors_parse_only_in_the_contract_shape() { let mut textual = descriptor.to_json(); textual["release"] = json!("3"); for malformed in [&upper, &short, &unsigned, &textual] { - assert_eq!(ProbeDescriptor::parse(malformed), None, "should refuse {malformed}"); + assert_eq!( + ProbeDescriptor::parse(malformed), + None, + "should refuse {malformed}" + ); } let list = json!([descriptor.to_json(), upper, "noise"]); assert_eq!(ProbeDescriptor::parse_list(Some(&list)), vec![descriptor]); @@ -339,10 +427,22 @@ fn verification_refuses_every_tampered_descriptor() { let mut retargeted = good.clone(); retargeted.sha256 = "d".repeat(64); - assert_eq!(authority.verify(&retargeted), Err(ReleaseError::Unsigned { release: 3 })); + assert_eq!( + authority.verify(&retargeted), + Err(ReleaseError::Unsigned { release: 3 }) + ); - let foreign = signed(&SigningKey::from_bytes(&[9; 32]), 1, 3, &planner(), &"b".repeat(64)); - assert_eq!(authority.verify(&foreign), Err(ReleaseError::Unsigned { release: 3 })); + let foreign = signed( + &SigningKey::from_bytes(&[9; 32]), + 1, + 3, + &planner(), + &"b".repeat(64), + ); + assert_eq!( + authority.verify(&foreign), + Err(ReleaseError::Unsigned { release: 3 }) + ); for signature in ["not base64!", "AAAA", &STANDARD.encode([0_u8; 63])] { let mut garbled = good.clone(); @@ -355,7 +455,10 @@ fn verification_refuses_every_tampered_descriptor() { } let future_abi = signed(&release_key(), 2, 3, &planner(), &"b".repeat(64)); - assert_eq!(authority.verify(&future_abi), Err(ReleaseError::AbiUnsupported { abi: 2 })); + assert_eq!( + authority.verify(&future_abi), + Err(ReleaseError::AbiUnsupported { abi: 2 }) + ); assert_eq!( authority.verify(&future_abi).map_err(|error| error.code()), Err("PROBE_UPGRADE_REQUIRED") @@ -364,11 +467,17 @@ fn verification_refuses_every_tampered_descriptor() { let old = signed(&release_key(), 1, 1, &planner(), &"b".repeat(64)); assert_eq!( authority.verify(&old), - Err(ReleaseError::BelowFloor { release: 1, floor: 2 }) + Err(ReleaseError::BelowFloor { + release: 1, + floor: 2 + }) ); let withdrawn = signed(&release_key(), 1, 4, &planner(), &"C".repeat(64)); - assert_eq!(authority.verify(&withdrawn), Err(ReleaseError::Revoked { release: 4 })); + assert_eq!( + authority.verify(&withdrawn), + Err(ReleaseError::Revoked { release: 4 }) + ); } #[test] @@ -403,7 +512,10 @@ fn loading_refuses_artifacts_that_are_not_the_signed_release() { let mut tampered = artifact.clone(); let last = tampered.len() - 1; tampered[last] ^= 1; - assert_eq!(load_error(&tampered, &descriptor), ReleaseError::DigestMismatch); + assert_eq!( + load_error(&tampered, &descriptor), + ReleaseError::DigestMismatch + ); let oversized = vec![0_u8; MAX_ARTIFACT_BYTES + 1]; let oversized_descriptor = signed(&release_key(), 1, 3, &planner(), &sha256_hex(&oversized)); @@ -433,7 +545,10 @@ fn loading_refuses_artifacts_that_are_not_the_signed_release() { fn loading_refuses_a_signed_release_that_breaks_the_abi() { let importing = stand_in(1, &planner(), true); let descriptor = signed(&release_key(), 1, 3, &planner(), &sha256_hex(&importing)); - assert_eq!(load_error(&importing, &descriptor), ReleaseError::ImportsMismatch); + assert_eq!( + load_error(&importing, &descriptor), + ReleaseError::ImportsMismatch + ); let misreporting = stand_in(2, &planner(), false); let descriptor = signed(&release_key(), 1, 3, &planner(), &sha256_hex(&misreporting)); @@ -443,7 +558,13 @@ fn loading_refuses_a_signed_release_that_breaks_the_abi() { ); let other_planner = stand_in(1, &"f".repeat(64), false); - let descriptor = signed(&release_key(), 1, 3, &planner(), &sha256_hex(&other_planner)); + let descriptor = signed( + &release_key(), + 1, + 3, + &planner(), + &sha256_hex(&other_planner), + ); assert_eq!( load_error(&other_planner, &descriptor), ReleaseError::PlannerMismatch @@ -464,10 +585,17 @@ fn reads_are_awaited_in_order_and_the_call_resumes_without_replay() { vec![(0, 4), (4, 4)], "each read should reach the source once, in order" ); - assert_eq!(source.suspensions.get(), 2, "each read should suspend the call"); + assert_eq!( + source.suspensions.get(), + 2, + "each read should suspend the call" + ); let again = block_on(probe.call("read", &json!({}), &source)).expect("should read again"); - assert_eq!(again, value, "a second call should start from a fresh instance"); + assert_eq!( + again, value, + "a second call should start from a fresh instance" + ); assert_eq!(source.reads().len(), 4); } @@ -476,8 +604,8 @@ fn the_argument_reaches_the_release_intact() { let probe = loaded(ProbeLimits::default()); let arg = json!({"plan": {"items": [1, 2, 3]}, "text": "caf\u{e9} \"quoted\""}); - let echoed = block_on(probe.call_without_source::("echo", &arg)) - .expect("should echo"); + let echoed = + block_on(probe.call_without_source::("echo", &arg)).expect("should echo"); assert_eq!(echoed, arg); } @@ -493,28 +621,55 @@ fn reads_outside_the_source_or_memory_are_refused_without_asking_the_source() { let memory = block_on(probe.call("memory", &json!({}), &source)).expect("should answer"); assert_eq!(memory, json!({"status": 4})); - assert!(source.reads().is_empty(), "no refused read should reach the source"); + assert!( + source.reads().is_empty(), + "no refused read should reach the source" + ); } #[test] -fn a_read_over_the_per_read_bound_is_refused_with_status_5() { - let probe = loaded(ProbeLimits { - max_read_bytes: 3, - ..ProbeLimits::default() - }); - let source = AsyncSource::new(b"abcdefgh"); +fn the_edge_per_read_bound_admits_its_size_and_refuses_one_byte_more() { + let probe = loaded(ProbeLimits::default()); + let bound = u64::from(ProbeLimits::default().max_read_bytes); + let source = AsyncSource::claiming(bound + 1); + + let beyond = block_on(probe.call("Beyond", &json!({}), &source)).expect("should answer"); + assert_eq!( + beyond, + json!({"status": 5}), + "a read over the bound should be refused" + ); + + let at_bound = block_on(probe.call("bound", &json!({}), &source)).expect("should answer"); + assert_eq!( + at_bound, + json!({"status": 4}), + "a read at the bound should pass the bound and stop only at linear memory" + ); + assert!( + source.reads().is_empty(), + "neither read should reach the source" + ); +} + +#[test] +fn the_edge_total_read_budget_stops_a_release_that_keeps_reading() { + let probe = loaded(ProbeLimits::default()); + let source = AsyncSource::claiming(u64::from(ProbeLimits::default().max_read_bytes)); - let error = block_on(probe.call("read", &json!({}), &source)).expect_err("should refuse"); + let error = block_on(probe.call("flood", &json!({}), &source)).expect_err("should stop"); assert!( - matches!(&error, ProbeCallError::Refused(refusal) if refusal.code == "READ_FAILED"), - "the release should see the refusal: {error}" + matches!( + error, + ProbeCallError::Failed(ProbeFailure::ReadBudgetExceeded { .. }) + ), + "the read budget, not fuel, should stop the release: {error}" ); - assert!(source.reads().is_empty()); } #[test] -fn the_total_read_budget_abandons_the_call() { +fn the_read_budget_counts_every_admitted_read() { let probe = loaded(ProbeLimits { max_total_read_bytes: 8, ..ProbeLimits::default() @@ -538,34 +693,52 @@ fn a_failed_read_abandons_the_call_with_the_source_error() { let error = block_on(probe.call("read", &json!({}), &source)).expect_err("should fail"); assert!(matches!(error, ProbeCallError::Source(VersionChanged))); - assert_eq!(source.reads(), vec![(0, 4)], "no read should follow the failure"); + assert_eq!( + source.reads(), + vec![(0, 4)], + "no read should follow the failure" + ); } #[test] -fn fuel_memory_and_result_bounds_stop_the_release() { - let fuel_bound = loaded(ProbeLimits { - fuel_per_call: 1_000_000, - ..ProbeLimits::default() - }); - let spin = block_on(fuel_bound.call_without_source::("spin", &json!({}))); +fn edge_fuel_stops_a_release_that_never_returns() { + let probe = loaded(ProbeLimits::default()); + + let spin = block_on(probe.call_without_source::("spin", &json!({}))); + assert!(matches!( spin, Err(ProbeCallError::Failed(ProbeFailure::FuelExhausted)) )); +} - let memory_bound = loaded(ProbeLimits { - memory_bytes: 1024 * 1024, - ..ProbeLimits::default() - }); - let refused = block_on(memory_bound.call_without_source::("Grow", &json!({}))) +#[test] +fn edge_memory_grows_to_its_bound_and_no_further() { + let probe = loaded(ProbeLimits::default()); + + let to_bound = block_on(probe.call_without_source::("allocate", &json!({}))) .expect("should answer"); - assert_eq!(refused, json!({"status": 0}), "growth past the bound should fail"); - let unbounded = loaded(ProbeLimits::default()); - let grown = block_on(unbounded.call_without_source::("Grow", &json!({}))) + assert_eq!( + to_bound, + json!({"status": 3}), + "growth to exactly the bound should succeed" + ); + + let past_bound = block_on(probe.call_without_source::("Grow", &json!({}))) .expect("should answer"); - assert_eq!(grown, json!({"status": 3}), "growth within the bound should succeed"); + assert_eq!( + past_bound, + json!({"status": 0}), + "growth one page past the bound should fail inside the release" + ); +} + +#[test] +fn an_oversized_result_is_refused() { + let probe = loaded(ProbeLimits::default()); + + let huge = block_on(probe.call_without_source::("huge", &json!({}))); - let huge = block_on(unbounded.call_without_source::("huge", &json!({}))); assert!(matches!( huge, Err(ProbeCallError::Failed(ProbeFailure::ResultTooLarge { .. })) @@ -577,7 +750,10 @@ fn traps_and_error_objects_are_reported_as_such() { let probe = loaded(ProbeLimits::default()); let trap = block_on(probe.call_without_source::("x", &json!({}))); - assert!(matches!(trap, Err(ProbeCallError::Failed(ProbeFailure::Trap(_))))); + assert!(matches!( + trap, + Err(ProbeCallError::Failed(ProbeFailure::Trap(_))) + )); let unknown = block_on(probe.call_without_source::("unknown", &json!({}))) .expect_err("should refuse"); diff --git a/crates/provenance/encypher/src/processor.rs b/crates/provenance/encypher/src/processor.rs index 57af424567..3604e0388f 100644 --- a/crates/provenance/encypher/src/processor.rs +++ b/crates/provenance/encypher/src/processor.rs @@ -1,19 +1,24 @@ -//! Per-document signing flow: gates, hold, KV cache, sign call, outcomes, logging. +//! Per-document signing flow: gates, hold, KV cache, sign calls, outcomes, logging. //! //! A document moves through four phases: //! //! 1. **Admit** (the middleware's preparation, with no I/O): the request //! gates decide whether the document is held at all. //! 2. **Extract** (synchronous): at end of input the held document's article -//! boundary, visible text and text hash are computed. +//! boundary, visible text and text hash are computed, and its embedded +//! media found. //! 3. **Resolve** (the asynchronous finish): policy and plan caches, the policy -//! and sign calls, and cache writes. On runtimes with a timer every call is -//! raced against `max_hold_ms`, keeping a reserve for the skip write. +//! and sign calls, and cache writes, then each embedded media item signed +//! through [`media`]. On runtimes with a timer every call is raced against +//! one `max_hold_ms` budget, keeping a reserve for the skip write. //! 4. **Render** (synchronous): the plan's markers and the manifest meta tag are -//! applied to the held document. +//! applied to the held document, then each signed media item replaces its +//! exact bytes. //! -//! Every module failure emits the held document unchanged. +//! Every module failure emits the held document unchanged, or, for one +//! media item, leaves that item as written and reports it unsigned. +use std::borrow::Cow; use std::io; use std::pin::pin; use std::sync::Arc; @@ -29,6 +34,7 @@ use serde::{Deserialize, Serialize}; use crate::api::{self, ApiError, EmbeddingPlan, SignRequest}; use crate::boundary::{self, Boundary}; +use crate::embedded; use crate::{EncypherConfig, INTEGRATION_ID, embed, text}; use trusted_server_core::integrations::ensure_integration_backend_with_timeout; use trusted_server_core::platform::{ @@ -36,6 +42,11 @@ use trusted_server_core::platform::{ }; use trusted_server_core::streaming_processor::{AsyncFinish, StreamProcessor}; +mod media; +mod prepared; + +pub(crate) use media::EncypherMedia; + const SIGN_PATH: &str = "/api/v1/public/cdn/sign"; const POLICY_PATH: &str = "/api/v1/public/cdn/trusted-server/policy"; const WORKER_TOKEN_HEADER: &str = "x-encypher-worker-token"; @@ -63,7 +74,8 @@ const SKIP_PATH_PREFIXES: &[&str] = &[ "/sitemap", "/.well-known/", ]; -/// Asset suffixes never signed (ported from the Fastly worker's `shouldSkipPath`). +/// Asset suffixes the article path never signs (ported from the Fastly +/// worker's `shouldSkipPath`). Media at these paths is signed by [`media`]. const SKIP_PATH_SUFFIXES: &[&str] = &[ ".xml", ".json", ".rss", ".atom", ".css", ".js", ".woff", ".woff2", ".png", ".jpg", ".gif", ".svg", ".ico", @@ -103,19 +115,24 @@ pub(crate) struct PageRequest { /// The reader's request method. pub(crate) method: Method, /// The scheme, host and path the reader asked for, without the query, - /// which is the address the article is signed for. + /// which is the address the article is signed for. On a host that + /// serves several publishers by path it includes the publisher's prefix. pub(crate) page_url: String, + /// The path prefix request addressing removed, such as + /// `/www.example.com`, or empty. The path gates read the publisher's own + /// path, without it. + pub(crate) removed_prefix: String, /// The status of the response the document is the body of. pub(crate) response_status: u16, /// Whether the origin marked its response as one reader's. pub(crate) origin_response_is_private: bool, } -/// Reason code for a held document's outcome. Sign failures are also stored -/// as the reason of a negative (`skip`) cache entry. +/// Reason code for a held document's or a media item's outcome. Sign +/// failures are also stored as the reason of a negative (`skip`) cache entry. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -enum Reason { +pub(crate) enum Reason { Signed, CacheHit, DocumentTooLarge, @@ -148,10 +165,62 @@ enum Reason { NoPlan, InvalidPlan, AlignmentFailed, + /// The media is over `max_media_bytes`. + MediaTooLarge, + /// The media is delivered outside the claimed domain. + OutsideClaim, + /// Core named no single hostname the source was fetched from. + InvalidOriginHost, + /// The captured bytes are not the bytes core hashed. + SourceDigestMismatch, + /// A `data:` URI's payload does not decode. + MalformedDataUri, + /// The endpoint answered `409` to a reused replay key. + IdempotencyConflict, + /// The endpoint no longer holds the result for the replay key. + ResultPurged, + /// The endpoint refused the request body's size. + PayloadTooLarge, + /// The signed bytes are of another media type than the source. + SignedTypeMismatch, + /// The signed bytes are not the bytes the answer names. + SignedDigestMismatch, + /// A signing header is missing, repeated or malformed. + InvalidSignedResponse, + /// The manifest is remote and needs a `Link` the delivery cannot carry. + ManifestUndeliverable, + /// The answer carries no manifest and no reference to one. + UnsignedResponse, + /// The response's policies are not visible to the module, so an inline + /// style edit could break a style hash. + CspUnknown, + /// A page policy admits the inline style by hash. + CspStyleHash, + /// The element carries a subresource integrity value. + IntegrityAttribute, + /// The URI is written with an HTML character reference. + CharacterReference, + /// The URI is written with a CSS escape. + CssEscape, + /// No standalone SVG document is proven to parse to the tree the browser + /// built from the inline SVG. + InlineSvgNotEquivalent, + /// The inline SVG carries provenance of its own and is not, as written, + /// a standalone SVG equivalent to the browser's tree, so it is never + /// rewritten. + SvgProvenanceNotEquivalent, + /// Original SVG provenance protects the entire range from inline edits + /// or replacement with a new, unchained signature. + SvgExistingProvenance, + /// The signer changed the inline SVG beyond adding its manifest. + InlineSvgAltered, + /// The document's media made its most endpoint calls; the item is signed + /// on a later view. + Deferred, } impl Reason { - fn code(self) -> &'static str { + pub(crate) fn code(self) -> &'static str { match self { Self::Signed => "signed", Self::CacheHit => "cache_hit", @@ -185,6 +254,29 @@ impl Reason { Self::NoPlan => "no_plan", Self::InvalidPlan => "invalid_plan", Self::AlignmentFailed => "alignment_failed", + Self::MediaTooLarge => "media_too_large", + Self::OutsideClaim => "outside_claim", + Self::InvalidOriginHost => "invalid_origin_host", + Self::SourceDigestMismatch => "source_digest_mismatch", + Self::MalformedDataUri => "malformed_data_uri", + Self::IdempotencyConflict => "idempotency_conflict", + Self::ResultPurged => "result_purged", + Self::PayloadTooLarge => "payload_too_large", + Self::SignedTypeMismatch => "signed_type_mismatch", + Self::SignedDigestMismatch => "signed_digest_mismatch", + Self::InvalidSignedResponse => "invalid_signed_response", + Self::ManifestUndeliverable => "manifest_undeliverable", + Self::UnsignedResponse => "unsigned_response", + Self::CspUnknown => "csp_unknown", + Self::CspStyleHash => "csp_style_hash", + Self::IntegrityAttribute => "integrity_attribute", + Self::CharacterReference => "character_reference", + Self::CssEscape => "css_escape", + Self::InlineSvgNotEquivalent => "inline_svg_not_equivalent", + Self::SvgProvenanceNotEquivalent => "svg_provenance_not_equivalent", + Self::SvgExistingProvenance => "svg_existing_provenance", + Self::InlineSvgAltered => "inline_svg_altered", + Self::Deferred => "deferred", } } } @@ -292,6 +384,7 @@ pub(crate) struct Signer { config: EncypherConfig, max_document_bytes: usize, sign_url: String, + media_sign_url: String, policy_url: String, policy_key: String, policy_skip_key: String, @@ -308,6 +401,11 @@ impl Signer { /// document. pub(crate) fn new(config: EncypherConfig, max_document_bytes: usize) -> Self { let sign_url = format!("{}{SIGN_PATH}", config.api_base_url.trim_end_matches('/')); + let media_sign_url = format!( + "{}{}", + config.api_base_url.trim_end_matches('/'), + crate::media_api::MEDIA_SIGN_PATH + ); let policy_url = format!("{}{POLICY_PATH}", config.api_base_url.trim_end_matches('/')); let context_hash = text::content_hash_hex(&format!( "{}\n{}\n{}", @@ -323,6 +421,7 @@ impl Signer { config, max_document_bytes, sign_url, + media_sign_url, policy_url, policy_key: format!("{POLICY_KEY_PREFIX}:{cache_context}"), policy_skip_key: format!("{POLICY_SKIP_KEY_PREFIX}:{cache_context}"), @@ -332,16 +431,58 @@ impl Signer { } } -/// Holds the document of an admitted page until it ends. -pub(crate) fn document_processor(signer: &Arc, request: PageRequest) -> DocumentProcessor { +/// What a held document is, which decides what in it is signed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Surface { + /// An HTML page: its article's text, then its embedded media. + Page, + /// A stylesheet: the media its `url()`s embed. + Stylesheet, +} + +/// Holds the document of an admitted page until it ends. `policies` are the +/// response's `Content-Security-Policy` values, `None` when core cannot +/// show them, which decide whether inline style media may be edited. +pub(crate) fn document_processor( + signer: &Arc, + request: PageRequest, + policies: Option>, +) -> DocumentProcessor { + held_processor(signer, request, Surface::Page, policies) +} + +/// Holds an admitted stylesheet until it ends. +pub(crate) fn stylesheet_processor( + signer: &Arc, + request: PageRequest, +) -> DocumentProcessor { + held_processor(signer, request, Surface::Stylesheet, None) +} + +fn held_processor( + signer: &Arc, + request: PageRequest, + surface: Surface, + policies: Option>, +) -> DocumentProcessor { DocumentProcessor { signer: Arc::clone(signer), request, + surface, + policies, held: Vec::new(), state: DocumentState::Holding, } } +/// What a held document's signing works for. +struct Target<'a> { + request: &'a PageRequest, + surface: Surface, + /// The response's policy values, `None` when core cannot show them. + policies: Option<&'a [String]>, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DocumentState { /// Accumulating the document. @@ -358,6 +499,8 @@ enum DocumentState { pub(crate) struct DocumentProcessor { signer: Arc, request: PageRequest, + surface: Surface, + policies: Option>, held: Vec, state: DocumentState, } @@ -396,31 +539,40 @@ impl StreamProcessor for DocumentProcessor { if self.state != DocumentState::Complete { return None; } - let extracted = std::str::from_utf8(held) - .map_err(|_| Reason::NotUtf8) - .and_then(|html| Ok((html, self.signer.extract(html)?))); - let (html, article) = match extracted { - Ok(extracted) => extracted, - Err(reason) => { + let Ok(document) = std::str::from_utf8(held) else { + log_outcome(false, Reason::NotUtf8); + return None; + }; + let (article, media) = match self.surface { + Surface::Page => ( + Some(self.signer.extract(document)), + embedded::scan_html(document, self.policies.as_deref()), + ), + Surface::Stylesheet => (None, embedded::scan_stylesheet(document)), + }; + let article = match article { + Some(Ok(article)) => Some(article), + Some(Err(reason)) => { log_outcome(false, reason); - return None; + None } + None => None, }; + if article.is_none() && media.is_empty() { + return None; + } let signer = &*self.signer; - let request = &self.request; + let target = Target { + request: &self.request, + surface: self.surface, + policies: self.policies.as_deref(), + }; let deadline = Duration::from_millis(signer.config.max_hold_ms).saturating_add(BACKSTOP_MARGIN); Some(AsyncFinish::new(deadline, async move { - match signer.resolve(request, services, html, &article).await { - Ok((signed, reason)) => { - log_outcome(true, reason); - signed.into_bytes() - } - Err(reason) => { - log_outcome(false, reason); - held.to_vec() - } - } + signer + .resolve_document(&target, services, document, article, media) + .await })) } } @@ -497,21 +649,38 @@ fn log_outcome(signed: bool, reason: Reason) { ); } -/// Whether `path` is one this module never signs: an API, feed, admin or -/// well-known path, or an asset. +/// Whether `path` is one the article path never signs: an API, feed, admin +/// or well-known path, or an asset. pub(crate) fn is_excluded_path(path: &str) -> bool { let lower = path.to_ascii_lowercase(); - SKIP_PATH_PREFIXES - .iter() - .any(|prefix| lower.starts_with(prefix)) + is_excluded_prefix(&lower) || SKIP_PATH_SUFFIXES .iter() .any(|suffix| lower.ends_with(suffix)) } +/// Whether `path` is an API, feed, admin or well-known path, where no media +/// is signed either. Asset suffixes are not excluded: media is. +pub(crate) fn is_excluded_prefix(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + SKIP_PATH_PREFIXES + .iter() + .any(|prefix| lower.starts_with(prefix)) +} + impl Signer { - /// Apply the request gates. Only an admitted document is held. + /// Apply the request gates to a page. Only an admitted document is held. pub(crate) fn admit(&self, request: PageRequest) -> Result { + self.gate(request, true) + } + + /// Apply the request gates to a stylesheet, whose `.css` path the page + /// gates exclude. + pub(crate) fn admit_stylesheet(&self, request: PageRequest) -> Result { + self.gate(request, false) + } + + fn gate(&self, request: PageRequest, page: bool) -> Result { if request.method != Method::GET { return Err(Gate::Method); } @@ -523,15 +692,31 @@ impl Signer { } let page_url = url::Url::parse(&request.page_url).map_err(|_| Gate::InvalidPageUrl)?; let host = page_url.host_str().ok_or(Gate::InvalidPageUrl)?; - if host.strip_prefix("www.").unwrap_or(host) != self.config.domain { + if !self.claims_host(host) { return Err(Gate::HostMismatch); } - if is_excluded_path(page_url.path()) { + // The publisher's own path, as its site names it. + let path = page_url.path(); + let path = path + .strip_prefix(request.removed_prefix.as_str()) + .filter(|rest| rest.starts_with('/')) + .unwrap_or(path); + let excluded = if page { + is_excluded_path(path) + } else { + is_excluded_prefix(path) + }; + if excluded { return Err(Gate::ExcludedPath); } Ok(request) } + /// Whether `host` is the claimed domain or its `www.` name. + pub(crate) fn claims_host(&self, host: &str) -> bool { + host.strip_prefix("www.").unwrap_or(host) == self.config.domain + } + /// Find the article and its signable text, with no I/O. fn extract(&self, html: &str) -> Result { let boundary = boundary::find_article_boundary(html, Some(&self.config.article_selector)) @@ -569,22 +754,105 @@ impl Signer { Deadline::new(services.timer(), SKIP_WRITE_RESERVE) } - /// Resolve the plan for `article` and render it into `html`. - async fn resolve( + /// Sign what a held document carries: the article's text first, as the + /// page path always has, then each embedded media item, within one + /// `max_hold_ms` budget. Returns the bytes to emit. + async fn resolve_document( &self, - request: &PageRequest, + target: &Target<'_>, services: &RuntimeServices, - html: &str, - article: &Article, - ) -> Result<(String, Reason), Reason> { - let kv = services.kv_store(); + document: &str, + article: Option
, + media: embedded::Scan, + ) -> Vec { + let request = target.request; let mut work = Deadline::new( services.timer(), Duration::from_millis(self.config.max_hold_ms).saturating_sub(SKIP_WRITE_RESERVE), ); work.start().await; + let policy_digest = match self + .policy_digest(&request.page_url, services, services.kv_store(), &mut work) + .await + { + Ok(policy_digest) => policy_digest, + Err(reason) => { + if article.is_some() { + log_outcome(false, reason); + } + media::log_unsigned(&media, reason); + return document.as_bytes().to_vec(); + } + }; + let mut output = Cow::Borrowed(document); + if let Some(article) = &article { + match self + .resolve( + request, + services, + document, + article, + &policy_digest, + &mut work, + ) + .await + { + Ok((signed, reason)) => { + log_outcome(true, reason); + output = Cow::Owned(signed); + } + Err(reason) => log_outcome(false, reason), + } + } + if media.is_empty() { + return output.into_owned().into_bytes(); + } + // Markers go into text and the manifest meta tag into the head, never + // into an attribute, a style or an SVG, so a rescan finds the same + // media at its new offsets. + let media = match (&output, target.surface) { + (Cow::Owned(signed), Surface::Page) => embedded::scan_html(signed, target.policies), + _ => media, + }; + // The text plan may come from the cached policy, but media is signed + // or reused only under the policy the service answers now, so a + // revoked credential cannot reuse a cached media result. + let media_policy = match self + .request_policy(&request.page_url, services, &mut work) + .await + { + Ok(policy_digest) => policy_digest, + Err(reason) => { + media::log_unsigned(&media, reason); + return output.into_owned().into_bytes(); + } + }; + let embedded = media::EmbeddedJob { + request, + surface: target.surface, + policy_digest: &media_policy, + }; + match self + .sign_embedded(services, &embedded, &output, &media, &mut work) + .await + { + Some(signed) => signed.into_bytes(), + None => output.into_owned().into_bytes(), + } + } - let policy_digest = self.policy_digest(request, services, kv, &mut work).await?; + /// Resolve the plan for `article` under `policy_digest` and render it + /// into `html`. + async fn resolve( + &self, + request: &PageRequest, + services: &RuntimeServices, + html: &str, + article: &Article, + policy_digest: &str, + work: &mut Deadline<'_>, + ) -> Result<(String, Reason), Reason> { + let kv = services.kv_store(); let suffix = format!( "{}:{policy_digest}:{}", self.cache_context, @@ -592,12 +860,12 @@ impl Signer { ); let plan_key = format!("{PLAN_KEY_PREFIX}:{suffix}"); let skip_key = format!("{PLAN_SKIP_KEY_PREFIX}:{suffix}"); - let outcome = match self.read::(kv, &plan_key, &mut work).await { + let outcome = match self.read::(kv, &plan_key, work).await { Ok(Some(CacheEntry::Plan { plan, manifest_url, .. })) => { if api::validate_plan(&plan, manifest_url.as_deref(), article.scalars).is_err() { - self.delete_invalid_entry(kv, &plan_key, &mut work).await; + self.delete_invalid_entry(kv, &plan_key, work).await; return Err(Reason::CachedPlanInvalid); } return render( @@ -611,21 +879,21 @@ impl Signer { .map_err(render_reason); } Ok(Some(CacheEntry::Skip { .. })) => { - self.delete_invalid_entry(kv, &plan_key, &mut work).await; + self.delete_invalid_entry(kv, &plan_key, work).await; return Err(Reason::CachedEntryInvalid); } Err(Reason::Timeout) => Err(Reason::Timeout), Err(reason) => return Err(reason), - Ok(None) => match self.read::(kv, &skip_key, &mut work).await { + Ok(None) => match self.read::(kv, &skip_key, work).await { Ok(Some(CacheEntry::Skip { .. })) => return Err(Reason::CachedSkip), Ok(Some(CacheEntry::Plan { .. })) => { - self.delete_invalid_entry(kv, &skip_key, &mut work).await; + self.delete_invalid_entry(kv, &skip_key, work).await; return Err(Reason::CachedEntryInvalid); } Err(Reason::Timeout) => Err(Reason::Timeout), Err(reason) => return Err(reason), Ok(None) => self - .request_plan(request, services, html, article, &policy_digest, &mut work) + .request_plan(request, services, html, article, policy_digest, work) .await .and_then(|(plan, manifest_url)| { api::validate_plan(&plan, manifest_url.as_deref(), article.scalars) @@ -651,14 +919,8 @@ impl Signer { expires_at: Self::expires_at(self.config.plan_ttl_seconds), ttl_less: false, }; - self.write( - kv, - &plan_key, - entry, - self.config.plan_ttl_seconds, - &mut work, - ) - .await; + self.write(kv, &plan_key, entry, self.config.plan_ttl_seconds, work) + .await; Ok((signed, Reason::Signed)) } Err(reason) => { @@ -674,7 +936,7 @@ impl Signer { async fn policy_digest( &self, - request: &PageRequest, + page_url: &str, services: &RuntimeServices, kv: &dyn PlatformKvStore, work: &mut Deadline<'_>, @@ -709,7 +971,7 @@ impl Signer { Ok(None) => {} } - let reason = match self.request_policy(request, services, work).await { + let reason = match self.request_policy(page_url, services, work).await { Ok(policy_digest) => { let entry = PolicyEntry::Digest { policy_digest: policy_digest.clone(), @@ -758,13 +1020,12 @@ impl Signer { async fn request_policy( &self, - request: &PageRequest, + page_url: &str, services: &RuntimeServices, work: &mut Deadline<'_>, ) -> Result { let mut url = url::Url::parse(&self.policy_url).map_err(|_| Reason::RequestBuild)?; - url.query_pairs_mut() - .append_pair("page_url", &request.page_url); + url.query_pairs_mut().append_pair("page_url", page_url); let body = self .fetch( services, @@ -834,7 +1095,15 @@ impl Signer { Ok((plan, parsed.manifest_url)) } - /// One bounded HTTP call; never retried. + /// The worker token header value, marked sensitive so no log shows it. + fn worker_token(&self) -> Result { + let mut token = HeaderValue::from_str(self.config.worker_token.expose().trim()) + .map_err(|_| Reason::RequestBuild)?; + token.set_sensitive(true); + Ok(token) + } + + /// One bounded JSON HTTP call; never retried. async fn fetch( &self, services: &RuntimeServices, @@ -844,36 +1113,17 @@ impl Signer { max_response_bytes: usize, work: &mut Deadline<'_>, ) -> Result { - let mut token = HeaderValue::from_str(self.config.worker_token.expose().trim()) - .map_err(|_| Reason::RequestBuild)?; - token.set_sensitive(true); let http_request = Request::builder() .method(method) .uri(url) .header(header::CONTENT_TYPE, "application/json") - .header(WORKER_TOKEN_HEADER, token) + .header(WORKER_TOKEN_HEADER, self.worker_token()?) .body(body) .map_err(|_| Reason::RequestBuild)?; - let backend = ensure_integration_backend_with_timeout( - services, - &self.config.api_base_url, - INTEGRATION_ID, - Duration::from_millis(self.config.timeout_ms), - ) - .map_err(|_| Reason::Backend)?; - let response_limit = max_response_bytes.saturating_add(1); - let platform_request = PlatformHttpRequest::new(http_request, backend) - .with_cache_bypass() - .with_max_response_body_bytes(response_limit); - let response = match work - .race(services.http_client().send(platform_request)) - .await - { - None => return Err(Reason::Timeout), - Some(Err(_)) => return Err(Reason::HttpError), - Some(Ok(response)) => response.response, - }; + let response = self + .send_api(services, http_request, response_limit, work) + .await?; let status = response.status(); if status != StatusCode::OK { return Err(match status.as_u16() { @@ -896,6 +1146,35 @@ impl Signer { } } + /// Send one request to the API backend, bounded by the first-byte + /// timeout and `work`, reading at most `response_limit` body bytes. + async fn send_api( + &self, + services: &RuntimeServices, + http_request: Request, + response_limit: usize, + work: &mut Deadline<'_>, + ) -> Result, Reason> { + let backend = ensure_integration_backend_with_timeout( + services, + &self.config.api_base_url, + INTEGRATION_ID, + Duration::from_millis(self.config.timeout_ms), + ) + .map_err(|_| Reason::Backend)?; + let platform_request = PlatformHttpRequest::new(http_request, backend) + .with_cache_bypass() + .with_max_response_body_bytes(response_limit); + match work + .race(services.http_client().send(platform_request)) + .await + { + None => Err(Reason::Timeout), + Some(Err(_)) => Err(Reason::HttpError), + Some(Ok(response)) => Ok(response.response), + } + } + /// Read a fresh entry. Expired entries are misses; in TTL-less write mode /// they are also deleted, best effort, so the store cleans itself. async fn read( @@ -1510,6 +1789,7 @@ mod tests { PageRequest { method: Method::GET, page_url: PAGE_URL.to_owned(), + removed_prefix: String::new(), response_status: 200, origin_response_is_private: false, } @@ -1567,7 +1847,7 @@ mod tests { return html.to_owned(); }; futures::executor::block_on(async { - let mut processor = document_processor(factory, request); + let mut processor = document_processor(factory, request, None); let (first, second) = html.as_bytes().split_at(html.len() / 2); let mut output = processor .process_chunk(first, false) @@ -2327,8 +2607,19 @@ mod tests { let http = Arc::new(StubHttpClient::new()); let services = services(http, &kv); - let result = - futures::executor::block_on(signer.resolve(&request(), &services, ARTICLE, &article)); + let result = futures::executor::block_on(async { + let mut work = Deadline::Unbounded; + signer + .resolve( + &request(), + &services, + ARTICLE, + &article, + POLICY_DIGEST, + &mut work, + ) + .await + }); assert_eq!( result.expect_err("alignment should fail"), @@ -2859,6 +3150,14 @@ mod tests { }), Gate::ExcludedPath, ), + ( + ("excluded path under an addressing prefix", &signer, |r| { + r.page_url = + "https://publisher.example.com/www.site.example/wp-admin/post".to_owned(); + r.removed_prefix = "/www.site.example".to_owned(); + }), + Gate::ExcludedPath, + ), ( ("asset suffix", &signer, |r| { r.page_url = "https://publisher.example.com/feed.XML".to_owned(); diff --git a/crates/provenance/encypher/src/processor/media.rs b/crates/provenance/encypher/src/processor/media.rs new file mode 100644 index 0000000000..842acd75ec --- /dev/null +++ b/crates/provenance/encypher/src/processor/media.rs @@ -0,0 +1,893 @@ +//! Media signing through the Encypher media endpoint. +//! +//! One source's exact bytes are sent with the server policy the edge holds, +//! and the answer is kept only after [`media_api::verify_signed_response`] +//! accepts it. Results are cached in the runtime KV store under a namespace +//! that binds the credential context, the policy digest, the delivery +//! address, the source reference, its declared and verified type, its bytes' +//! digest and whether a `Link` can be delivered, so a changed policy, +//! credential or source never reuses another's signature. The replay key the +//! endpoint requires is derived from the same namespace, so a repeated call +//! is answered with the identical bytes. +//! +//! Two callers use it: core's binary delivery seam, through [`EncypherMedia`], +//! and the page and stylesheet processors for embedded `data:` URIs and +//! inline SVG, through [`Signer::sign_embedded`]. Core keeps the signed +//! representations it delivers under [`MediaSigner::namespace`], which the +//! module answers only after a policy check no older than +//! `policy_ttl_seconds`, so a revoked credential or changed policy stops +//! every kept copy from being served. + +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, header}; +use serde::{Deserialize, Serialize}; +use trusted_server_core::media_signing::{ + LargeMediaJob, LargeMediaSigning, MediaJob, MediaNamespacing, MediaSigner, MediaSigning, + MediaSigningFailure, SignedMedia as DeliveredMedia, +}; +use trusted_server_core::middleware::ResponseHeader; +use trusted_server_core::platform::RuntimeServices; + +use super::{ + Deadline, Expiring, PageRequest, Reason, SKIP_WRITE_RESERVE, Signer, Surface, + WORKER_TOKEN_HEADER, is_excluded_prefix, unix_now, +}; +use crate::MODULE; +use crate::data_uri::DataUri; +use crate::embedded::{self, Scan}; +use crate::media_api::{ + self, Disposition, Expected, MediaMetadata, ResponseError, SignedMedia, SourceMetadata, + canonical_media_type, safe_source_reference, sha256_hex, valid_origin_host, +}; +use crate::svg_equivalence; + +/// Verified signed media. Entries carry their own `expires_at`. +const MEDIA_KEY_PREFIX: &str = "encypher:media:v1"; +/// Media failures, separate so a delayed failure cannot replace a result. +const MEDIA_SKIP_KEY_PREFIX: &str = "encypher:media-skip:v1"; +/// A fresh replay-key salt, written when the endpoint no longer holds or +/// refuses the result for the previous key. +const MEDIA_REPLAY_KEY_PREFIX: &str = "encypher:media-replay:v1"; +const SVG_MEDIA_TYPE: &str = "image/svg+xml"; + +/// The media types the signer signs: images, audio, video and fonts, +/// whatever the path they are delivered on. Whether a format is signable is +/// decided by its verified bytes at the endpoint, which answers an +/// unsupported one `422`, delivered unsigned. +const SIGNABLE_TYPE_PREFIXES: &[&str] = &["image/", "audio/", "video/", "font/"]; + +/// The media type of one segment of a fragmented ISO BMFF stream, as ISO/IEC +/// 14496-12 registers it. A segment's provenance is bound through its +/// stream's init segment, as with C2PA verifiable segment info, so it is +/// never signed alone and reaches the reader as the origin sent it. +const MEDIA_SEGMENT_TYPE: &str = "video/iso.segment"; + +const INSTANCE_ID_HEADER: HeaderName = HeaderName::from_static("x-encypher-manifest-instance-id"); +const DOCUMENT_ID_HEADER: HeaderName = HeaderName::from_static("x-encypher-document-id"); +const VERIFICATION_URL_HEADER: HeaderName = HeaderName::from_static("x-encypher-verification-url"); + +/// One media source to sign, as it is delivered. +struct MediaSource<'a> { + /// The reader-facing address the media is delivered on or in. + page_url: &'a str, + /// A safe canonical address of the source, or none. + reference: Option<&'a str>, + /// The hostname the source was fetched from, or none for media embedded + /// in a document. + origin_host: Option<&'a str>, + /// The declared type, sent as a hint. + mime: &'a str, + /// The bare type the signed bytes must keep. + media_type: &'a str, + bytes: &'a [u8], + sha256: &'a str, + /// Whether every response of the representation can carry a `Link`. + link_deliverable: bool, + /// The bytes are the platform image optimizer's answer, so the endpoint + /// must refuse them unless they carry an intact embedded manifest, the + /// optimizer's own record of its input and what it did. + require_embedded_manifest: bool, +} + +/// A cached media result. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum MediaEntry { + /// The verified answer: its base64 bytes and its signing headers, which + /// are verified again on every read. + Signed { + bytes: String, + headers: Vec<(String, String)>, + expires_at: u64, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + ttl_less: bool, + }, + Skip { + reason: Reason, + expires_at: u64, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + ttl_less: bool, + }, + Replay { + salt: String, + expires_at: u64, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + ttl_less: bool, + }, +} + +impl Expiring for MediaEntry { + fn expires_at(&self) -> u64 { + match self { + Self::Signed { expires_at, .. } + | Self::Skip { expires_at, .. } + | Self::Replay { expires_at, .. } => *expires_at, + } + } + + fn ttl_less(&self) -> bool { + match self { + Self::Signed { ttl_less, .. } + | Self::Skip { ttl_less, .. } + | Self::Replay { ttl_less, .. } => *ttl_less, + } + } + + fn mark_ttl_less(&mut self) { + match self { + Self::Signed { ttl_less, .. } + | Self::Skip { ttl_less, .. } + | Self::Replay { ttl_less, .. } => *ttl_less = true, + } + } +} + +impl MediaEntry { + fn signed(signed: &SignedMedia, headers: &HeaderMap, expires_at: u64) -> Self { + let headers = media_api::SIGNING_HEADERS + .iter() + .filter_map(|name| { + let value = headers.get(*name)?.to_str().ok()?; + Some(((*name).to_owned(), value.to_owned())) + }) + .collect(); + Self::Signed { + bytes: STANDARD.encode(&signed.bytes), + headers, + expires_at, + ttl_less: false, + } + } +} + +/// A cached answer, verified as if it had just arrived. +fn verify_cached( + bytes: &str, + headers: &[(String, String)], + expected: &Expected<'_>, +) -> Result { + let body = STANDARD + .decode(bytes) + .map_err(|_| Reason::CachedEntryInvalid)?; + let mut map = HeaderMap::with_capacity(headers.len()); + for (name, value) in headers { + let name = + HeaderName::from_bytes(name.as_bytes()).map_err(|_| Reason::CachedEntryInvalid)?; + let value = HeaderValue::from_str(value).map_err(|_| Reason::CachedEntryInvalid)?; + map.append(name, value); + } + media_api::verify_signed_response(&map, Bytes::from(body), expected) + .map_err(|_| Reason::CachedEntryInvalid) +} + +fn response_reason(error: ResponseError) -> Reason { + match error { + ResponseError::ContentType => Reason::SignedTypeMismatch, + ResponseError::PolicyDigest => Reason::PolicyDigestMismatch, + ResponseError::SignedDigest => Reason::SignedDigestMismatch, + ResponseError::Malformed => Reason::InvalidSignedResponse, + ResponseError::LinkUndeliverable => Reason::ManifestUndeliverable, + ResponseError::Unsigned => Reason::UnsignedResponse, + } +} + +/// The reason for an error answer, by its status and failure code. +fn status_reason(status: StatusCode, code: Option<&str>) -> Reason { + match status.as_u16() { + 403 | 404 => Reason::Forbidden, + 409 if code == Some("SIGNING_IDEMPOTENCY_CONFLICT") => Reason::IdempotencyConflict, + 409 => Reason::PolicyChanged, + 410 => Reason::ResultPurged, + 413 => Reason::PayloadTooLarge, + 422 => Reason::Unprocessable, + 429 => Reason::RateLimited, + 500..=599 => Reason::ServerError, + _ => Reason::UnexpectedStatus, + } +} + +fn log_media(signed: bool, reason: Reason) { + log::info!( + "encypher: media outcome={} reason={}", + if signed { "signed" } else { "unsigned" }, + reason.code() + ); +} + +/// Reports every media item of `scan` unsigned for `reason`. +pub(super) fn log_unsigned(scan: &Scan, reason: Reason) { + for refused in &scan.refused { + log_media(false, *refused); + } + for _ in 0..scan.found.len() + scan.svgs.len() { + log_media(false, reason); + } +} + +/// The document an embedded media pass works for. +pub(super) struct EmbeddedJob<'a> { + pub(super) request: &'a PageRequest, + pub(super) surface: Surface, + pub(super) policy_digest: &'a str, +} + +/// One item's outcome in an embedded pass: signed, from the cache or fresh, +/// or unsigned with why. +type Outcome = Result; + +impl Signer { + fn max_media_bytes(&self) -> usize { + self.config.max_media_bytes + } + + /// The cache namespace of `source` under `policy_digest`: every input the + /// signed result depends on. + fn media_namespace(&self, source: &MediaSource<'_>, policy_digest: &str) -> String { + sha256_hex( + format!( + "{}\n{policy_digest}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + self.cache_context, + source.page_url, + source.reference.unwrap_or_default(), + source.origin_host.unwrap_or_default(), + source.mime, + source.media_type, + source.sha256, + source.link_deliverable, + source.require_embedded_manifest + ) + .as_bytes(), + ) + } + + /// Sign one source, from the cache when a verified result is held. + /// Returns the result and whether it was signed now or read back. A call + /// to the endpoint is made only while `calls` is below + /// `self.config.max_media_sign_calls_per_document`, and counted there. + async fn sign_media( + &self, + services: &RuntimeServices, + source: &MediaSource<'_>, + policy_digest: &str, + work: &mut Deadline<'_>, + calls: &mut usize, + ) -> Result<(SignedMedia, Reason), Reason> { + if source.bytes.len() > self.max_media_bytes() { + return Err(Reason::MediaTooLarge); + } + // Once the budget is spent no call is made and no skip written, so + // the reserve stays for the document's own writes. + if work.expired() { + return Err(Reason::Timeout); + } + let kv = services.kv_store(); + let namespace = self.media_namespace(source, policy_digest); + let key = format!("{MEDIA_KEY_PREFIX}:{namespace}"); + let skip_key = format!("{MEDIA_SKIP_KEY_PREFIX}:{namespace}"); + let expected = Expected { + policy_digest, + media_type: source.media_type, + unsigned_sha256: source.sha256, + link_deliverable: source.link_deliverable, + }; + let outcome = match self.read::(kv, &key, work).await { + Ok(Some(MediaEntry::Signed { bytes, headers, .. })) => { + return match verify_cached(&bytes, &headers, &expected) { + Ok(signed) => Ok((signed, Reason::CacheHit)), + Err(reason) => { + self.delete_invalid_entry(kv, &key, work).await; + Err(reason) + } + }; + } + Ok(Some(_)) => { + self.delete_invalid_entry(kv, &key, work).await; + return Err(Reason::CachedEntryInvalid); + } + Err(Reason::Timeout) => Err(Reason::Timeout), + Err(reason) => return Err(reason), + Ok(None) => match self.read::(kv, &skip_key, work).await { + Ok(Some(MediaEntry::Skip { .. })) => return Err(Reason::CachedSkip), + Ok(Some(_)) => { + self.delete_invalid_entry(kv, &skip_key, work).await; + return Err(Reason::CachedEntryInvalid); + } + Err(Reason::Timeout) => Err(Reason::Timeout), + Err(reason) => return Err(reason), + Ok(None) if *calls >= self.config.max_media_sign_calls_per_document => { + return Err(Reason::Deferred); + } + Ok(None) => { + *calls += 1; + self.request_media(services, source, policy_digest, &namespace, &expected, work) + .await + } + }, + }; + match outcome { + Ok((signed, headers)) => { + // A result write that runs out of time is abandoned; the media + // is still signed. + let entry = MediaEntry::signed( + &signed, + &headers, + Self::expires_at(self.config.plan_ttl_seconds), + ); + self.write(kv, &key, entry, self.config.plan_ttl_seconds, work) + .await; + Ok((signed, Reason::Signed)) + } + Err(reason @ (Reason::ResultPurged | Reason::IdempotencyConflict)) => { + // The next view asks again under a new replay key; no skip, + // and never a retry within this view. + let entry = MediaEntry::Replay { + salt: format!("{}", unix_now()), + expires_at: Self::expires_at(self.config.plan_ttl_seconds), + ttl_less: false, + }; + let replay_key = format!("{MEDIA_REPLAY_KEY_PREFIX}:{namespace}"); + self.write( + kv, + &replay_key, + entry, + self.config.plan_ttl_seconds, + &mut Self::skip_deadline(services), + ) + .await; + Err(reason) + } + Err(reason) => { + let mut deadline = Self::skip_deadline(services); + if matches!(reason, Reason::PolicyChanged | Reason::PolicyDigestMismatch) { + self.evict_policy(kv, &mut deadline).await; + } + let ttl = self.config.negative_ttl_seconds; + let entry = MediaEntry::Skip { + reason, + expires_at: Self::expires_at(ttl), + ttl_less: false, + }; + self.write(kv, &skip_key, entry, ttl, &mut deadline).await; + Err(reason) + } + } + } + + /// Call the media endpoint once with the source's exact bytes. Every + /// failure maps to a reason code. + async fn request_media( + &self, + services: &RuntimeServices, + source: &MediaSource<'_>, + policy_digest: &str, + namespace: &str, + expected: &Expected<'_>, + work: &mut Deadline<'_>, + ) -> Result<(SignedMedia, HeaderMap), Reason> { + let kv = services.kv_store(); + let replay_key = format!("{MEDIA_REPLAY_KEY_PREFIX}:{namespace}"); + let salt = match self.read::(kv, &replay_key, work).await { + Ok(Some(MediaEntry::Replay { salt, .. })) => salt, + Ok(Some(_)) => { + self.delete_invalid_entry(kv, &replay_key, work).await; + return Err(Reason::CachedEntryInvalid); + } + Ok(None) => String::new(), + Err(reason) => return Err(reason), + }; + let idempotency_key = sha256_hex(format!("{namespace}\n{salt}").as_bytes()); + let metadata = serde_json::to_vec(&MediaMetadata::unmodified( + source.page_url, + policy_digest, + SourceMetadata { + reference: source.reference, + origin_host: source.origin_host, + mime: source.mime, + sha256: source.sha256, + byte_length: source.bytes.len(), + require_embedded_manifest: source.require_embedded_manifest, + }, + source.link_deliverable, + )) + .map_err(|_| Reason::RequestBuild)?; + let multipart = media_api::multipart(&metadata, source.bytes); + if multipart.body.len() > media_api::MAX_REQUEST_BYTES { + return Err(Reason::PayloadTooLarge); + } + let http_request = Request::builder() + .method(Method::POST) + .uri(&self.media_sign_url) + .header(header::CONTENT_TYPE, multipart.content_type) + .header(WORKER_TOKEN_HEADER, self.worker_token()?) + .header(media_api::IDEMPOTENCY_KEY_HEADER, idempotency_key) + .body(EdgeBody::from_bytes(multipart.body)) + .map_err(|_| Reason::RequestBuild)?; + let max_body = source + .bytes + .len() + .saturating_add(media_api::MAX_MANIFEST_OVERHEAD_BYTES); + let response_limit = max_body.saturating_add(1); + let response = self + .send_api(services, http_request, response_limit, work) + .await?; + let status = response.status(); + let headers = response.headers().clone(); + if status != StatusCode::OK { + let code = work + .race( + response + .into_body() + .into_bytes_bounded(media_api::MAX_ERROR_BODY_BYTES), + ) + .await + .and_then(Result::ok) + .and_then(|body| media_api::error_code(&body)); + log::info!( + "encypher: media sign answered status={} code={}", + status.as_u16(), + code.as_deref().unwrap_or("none") + ); + return Err(status_reason(status, code.as_deref())); + } + let body = match work + .race(response.into_body().into_bytes_bounded(response_limit)) + .await + { + None => return Err(Reason::Timeout), + Some(Err(_)) => return Err(Reason::ResponseTooLarge), + Some(Ok(body)) => body, + }; + if body.len() > max_body { + return Err(Reason::ResponseTooLarge); + } + let signed = + media_api::verify_signed_response(&headers, body, expected).map_err(response_reason)?; + Ok((signed, headers)) + } + + /// Sign the editable media embedded in `document`: each `data:` URI + /// found, then each inline SVG last as the equivalent standalone document. + /// The scan excludes entire original provenance-bearing or uncertain SVG + /// ranges before either pass, so child signing cannot invalidate them. + /// Returns the new document, or `None` when nothing in it was signed. + pub(super) async fn sign_embedded( + &self, + services: &RuntimeServices, + job: &EmbeddedJob<'_>, + document: &str, + scan: &Scan, + work: &mut Deadline<'_>, + ) -> Option { + for reason in &scan.refused { + log_media(false, *reason); + } + let mut outcomes: Vec = Vec::new(); + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + let mut signed_sources: Vec<(String, String, Result)> = Vec::new(); + let mut calls = 0_usize; + for found in &scan.found { + let Some(uri) = document + .get(found.start..found.end) + .and_then(DataUri::parse) + else { + continue; + }; + let Ok(bytes) = uri.decode() else { + outcomes.push(Err(Reason::MalformedDataUri)); + continue; + }; + let media_type = uri.media_type(); + let sha256 = sha256_hex(&bytes); + let cached = signed_sources + .iter() + .find(|(sha, kind, _)| *sha == sha256 && *kind == media_type) + .map(|(_, _, result)| result.clone()); + let result = match cached { + Some(result) => result.map(|signed| (signed, Reason::CacheHit)), + None => { + let source = MediaSource { + page_url: &job.request.page_url, + reference: None, + origin_host: None, + mime: uri.declared_type(), + media_type: &media_type, + bytes: &bytes, + sha256: &sha256, + link_deliverable: false, + require_embedded_manifest: false, + }; + let result = self + .sign_media(services, &source, job.policy_digest, work, &mut calls) + .await + .and_then(|(signed, reason)| { + if media_type == SVG_MEDIA_TYPE { + embedded::verify_svg_carrier(&bytes, &signed.bytes)?; + } + Ok((signed, reason)) + }); + signed_sources.push(( + sha256.clone(), + media_type.clone(), + result + .as_ref() + .map(|(signed, _)| signed.clone()) + .map_err(|reason| *reason), + )); + result + } + }; + match result { + Ok((signed, reason)) => { + edits.push((found.start, found.end, uri.encode(&signed.bytes))); + outcomes.push(Ok(reason)); + } + Err(reason) => outcomes.push(Err(reason)), + } + } + let mut output = None; + if !edits.is_empty() { + output = embedded::splice(document, &edits, self.max_document_bytes); + if output.is_none() { + revoke(&mut outcomes); + } + } + if job.surface == Surface::Page { + let current = output.as_deref().unwrap_or(document); + let svgs = if output.is_some() { + embedded::scan_html(current, None).svgs + } else { + scan.svgs.clone() + }; + let mut svg_outcomes = Vec::with_capacity(svgs.len()); + let mut svg_edits = Vec::new(); + for (start, end) in svgs { + let Some(fragment) = current.get(start..end) else { + continue; + }; + match self + .sign_inline_svg(services, job, fragment, work, &mut calls) + .await + { + Ok((signed, reason)) => { + svg_edits.push((start, end, signed)); + svg_outcomes.push(Ok(reason)); + } + Err(reason) => svg_outcomes.push(Err(reason)), + } + } + if !svg_edits.is_empty() { + match embedded::splice(current, &svg_edits, self.max_document_bytes) { + Some(signed) => output = Some(signed), + None => revoke(&mut svg_outcomes), + } + } + outcomes.extend(svg_outcomes); + } + for outcome in &outcomes { + match outcome { + Ok(reason) => log_media(true, *reason), + Err(reason) => log_media(false, *reason), + } + } + output + } + + /// Sign one inline SVG as the standalone document + /// [`signable_svg`](svg_equivalence::signable_svg) proves equivalent to + /// the browser's tree: the original bytes where they already are one, + /// else the browser's tree serialized as XML. Those bytes are hashed, + /// sent, cached and compared with the answer, and the result is kept + /// only when the signer added its manifest and changed nothing else. + async fn sign_inline_svg( + &self, + services: &RuntimeServices, + job: &EmbeddedJob<'_>, + fragment: &str, + work: &mut Deadline<'_>, + calls: &mut usize, + ) -> Result<(String, Reason), Reason> { + let svg = svg_equivalence::signable_svg(fragment, self.max_media_bytes())?; + let bytes = svg.as_bytes(); + let sha256 = sha256_hex(bytes); + let source = MediaSource { + page_url: &job.request.page_url, + reference: None, + origin_host: None, + mime: SVG_MEDIA_TYPE, + media_type: SVG_MEDIA_TYPE, + bytes, + sha256: &sha256, + link_deliverable: false, + require_embedded_manifest: false, + }; + let (signed, reason) = self + .sign_media(services, &source, job.policy_digest, work, calls) + .await?; + embedded::verify_svg_carrier(bytes, &signed.bytes)?; + let signed = + String::from_utf8(signed.bytes.to_vec()).map_err(|_| Reason::InlineSvgAltered)?; + Ok((signed, reason)) + } + + /// The namespace core keeps this credential's signed representations + /// under: the module version, the policy digest the service answers now, + /// and the credential context hash. Never a secret. The policy is asked + /// for the claim's root, being the policy of the credential whatever the + /// asset, and asked live on every call, never from the article path's + /// cache, so a revoked credential or changed policy stops every kept copy + /// at once. + async fn delivery_namespace(&self, services: &RuntimeServices) -> Result { + let mut work = Deadline::new( + services.timer(), + Duration::from_millis(self.config.max_hold_ms).saturating_sub(SKIP_WRITE_RESERVE), + ); + work.start().await; + let claim_root = format!("https://{}/", self.config.domain); + let policy_digest = self + .request_policy(&claim_root, services, &mut work) + .await?; + Ok(self.kept_namespace(&policy_digest)) + } + + /// The namespace of signed representations made under `policy_digest` + /// with this credential, as [`Self::delivery_namespace`] answers it. + pub(super) fn kept_namespace(&self, policy_digest: &str) -> String { + format!("{NAMESPACE_VERSION}:{policy_digest}:{}", self.cache_context) + } + + /// Sign one binary representation core captured, for every response of + /// it: the reader-facing address must be on the claimed domain, the + /// bytes the ones core hashed. + async fn sign_delivery( + &self, + services: &RuntimeServices, + job: &MediaJob<'_>, + ) -> Result { + let reader_url = url::Url::parse(job.reader_url).map_err(|_| Reason::OutsideClaim)?; + let host = reader_url.host_str().ok_or(Reason::OutsideClaim)?; + if !matches!(reader_url.scheme(), "https" | "http") + || !self.claims_host(host) + || is_excluded_prefix(reader_url.path()) + { + return Err(Reason::OutsideClaim); + } + if job.source.len() > self.max_media_bytes() { + return Err(Reason::MediaTooLarge); + } + let sha256 = sha256_hex(job.source); + if sha256 != job.source_sha256 { + return Err(Reason::SourceDigestMismatch); + } + // The host core fetched from, which the endpoint checks the claim's + // managed origins and the reference against. + let origin_host = job + .source_origin_host + .filter(|host| valid_origin_host(host)) + .ok_or(Reason::InvalidOriginHost)?; + let media_type = canonical_media_type(job.content_type); + // No address serves the optimizer's answer, so it is bound by its + // own bytes and never by the address the optimizer read. + let reference = job.source_reference.filter(|reference| { + !job.optimizer_output && safe_source_reference(reference, &self.config.domain) + }); + let mut work = Deadline::new( + services.timer(), + Duration::from_millis(self.config.max_hold_ms).saturating_sub(SKIP_WRITE_RESERVE), + ); + work.start().await; + // Asked live, as for the namespace: a cached digest could outlive a + // revocation and reuse a signed result under it. + let policy_digest = self + .request_policy(job.reader_url, services, &mut work) + .await?; + let source = MediaSource { + page_url: job.reader_url, + reference, + origin_host: Some(origin_host), + mime: job.content_type, + media_type: &media_type, + bytes: job.source, + sha256: &sha256, + link_deliverable: true, + require_embedded_manifest: job.optimizer_output, + }; + // One representation, one call at most. + let mut calls = 0_usize; + let (signed, reason) = self + .sign_media(services, &source, &policy_digest, &mut work, &mut calls) + .await?; + if media_type == SVG_MEDIA_TYPE && signed.disposition == Disposition::Embedded { + embedded::verify_svg_carrier(job.source, &signed.bytes)?; + } + let mut headers: Vec = Vec::with_capacity(4); + if signed.disposition == Disposition::External + && let Some(link) = signed.link() + { + headers.push(( + header::LINK, + HeaderValue::from_str(&link).map_err(|_| Reason::InvalidSignedResponse)?, + )); + } + for (name, value) in [ + (INSTANCE_ID_HEADER, &signed.instance_id), + (DOCUMENT_ID_HEADER, &signed.document_id), + (VERIFICATION_URL_HEADER, &signed.verification_url), + ] { + headers.push(( + name, + HeaderValue::from_str(value).map_err(|_| Reason::InvalidSignedResponse)?, + )); + } + log_media(true, reason); + Ok(DeliveredMedia { + bytes: signed.bytes, + content_type: signed.content_type, + headers, + // The policy this representation was signed under, so core keeps + // it where only that policy finds it. + namespace: self.kept_namespace(&policy_digest), + }) + } +} + +/// Marks every signed outcome of a batch whose output would exceed the +/// document budget as unsigned: none of its edits is applied. +fn revoke(outcomes: &mut [Outcome]) { + for outcome in outcomes.iter_mut() { + if outcome.is_ok() { + *outcome = Err(Reason::OutputTooLarge); + } + } +} + +/// The version of the module's signed representations, part of every +/// namespace core keeps them under. +const NAMESPACE_VERSION: &str = concat!("media-v1:", env!("CARGO_PKG_VERSION")); + +/// The module's binary media signer, which core runs on publisher and asset +/// proxy responses of the media it captures. +pub(crate) struct EncypherMedia { + signer: Arc, +} + +impl EncypherMedia { + pub(crate) fn new(signer: Arc) -> Self { + Self { signer } + } +} + +impl std::fmt::Debug for EncypherMedia { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EncypherMedia") + .finish_non_exhaustive() + } +} + +impl MediaSigner for EncypherMedia { + fn signer_id(&self) -> &'static str { + MODULE + } + + /// Every path but an API, feed, admin or well-known path: media is + /// recognized by the origin's media type, not by a suffix. + fn handles_path(&self, path: &str) -> bool { + !is_excluded_prefix(path) + } + + fn handles_media_type(&self, media_type: &str) -> bool { + media_type != MEDIA_SEGMENT_TYPE + && SIGNABLE_TYPE_PREFIXES + .iter() + .any(|prefix| media_type.starts_with(prefix) && media_type.len() > prefix.len()) + } + + fn max_source_bytes(&self) -> usize { + self.signer.max_media_bytes() + } + + /// Larger media, up to this, is signed through the prepared lane: hashed + /// where it is delivered and never uploaded. + fn max_large_source_bytes(&self) -> u64 { + self.signer.max_large_media_bytes() + } + + /// The one budget for a large source: core's whole-source hash and the + /// prepared lane together. + fn max_large_media_duration(&self) -> Duration { + self.signer.max_large_media_duration() + } + + fn sign_large<'a>( + &'a self, + services: &'a RuntimeServices, + job: &'a LargeMediaJob<'a>, + ) -> LargeMediaSigning<'a> { + Box::pin(async move { + match self.signer.sign_large(services, job).await { + Ok(signed) => { + log_media(true, Reason::Signed); + Ok(signed) + } + Err(refusal) => { + // A source with its own embedded credentials is delivered + // as it is: its provenance is kept, it is not unsigned + // by failure and not newly signed. + log::info!( + "encypher: media outcome={} lane=large reason={}", + if refusal.retains_provenance() { + "retained" + } else { + "unsigned" + }, + refusal.code() + ); + Err(MediaSigningFailure { + reason: refusal.code(), + }) + } + } + }) + } + + fn namespace<'a>(&'a self, services: &'a RuntimeServices) -> MediaNamespacing<'a> { + Box::pin(async move { + self.signer + .delivery_namespace(services) + .await + .map_err(|reason| { + log_media(false, reason); + MediaSigningFailure { + reason: reason.code(), + } + }) + }) + } + + fn sign<'a>( + &'a self, + services: &'a RuntimeServices, + job: &'a MediaJob<'a>, + ) -> MediaSigning<'a> { + Box::pin(async move { + self.signer + .sign_delivery(services, job) + .await + .map_err(|reason| { + log_media(false, reason); + MediaSigningFailure { + reason: reason.code(), + } + }) + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/provenance/encypher/src/processor/media/tests.rs b/crates/provenance/encypher/src/processor/media/tests.rs new file mode 100644 index 0000000000..fb8de7cbfa --- /dev/null +++ b/crates/provenance/encypher/src/processor/media/tests.rs @@ -0,0 +1,1188 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use edgezero_core::key_value_store::KvPage; +use futures::executor::block_on; +use serde_json::json; +use trusted_server_core::platform::test_support::{ + StubHttpClient, build_services_with_http_client, +}; +use trusted_server_core::platform::{KvError, PlatformHttpClient, PlatformKvStore}; + +use super::*; +use crate::EncypherConfig; + +const TOKEN: &str = "cwk_test_worker_token_value"; +const POLICY_DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const PAGE_URL: &str = "https://publisher.example.com/news/story"; +const SIGN_URL: &str = "https://api.example.com/api/v1/public/cdn/media/sign"; +const SOURCE: &[u8] = b"\x89PNG\r\n\x1a\n-source-pixels"; +const SIGNED: &[u8] = b"\x89PNG\r\n\x1a\n-source-pixels-caBX-jumb-c2pa-manifest"; +const MANIFEST: &str = "QUJD"; + +/// A runtime KV store held in memory, recording deletes. +#[derive(Default)] +struct MemoryKv { + entries: Mutex>, + deletes: Mutex>, +} + +impl MemoryKv { + fn keys_with(&self, prefix: &str) -> Vec { + self.entries + .lock() + .expect("should lock entries") + .keys() + .filter(|key| key.starts_with(prefix)) + .cloned() + .collect() + } + + fn get(&self, key: &str) -> Option { + self.entries + .lock() + .expect("should lock entries") + .get(key) + .cloned() + } + + fn set(&self, key: &str, value: Bytes) { + self.entries + .lock() + .expect("should lock entries") + .insert(key.to_owned(), value); + } +} + +#[async_trait::async_trait(?Send)] +impl PlatformKvStore for MemoryKv { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + Ok(self.get(key)) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.set(key, value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + self.set(key, value); + Ok(()) + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.entries + .lock() + .expect("should lock entries") + .remove(key); + self.deletes + .lock() + .expect("should lock deletes") + .push(key.to_owned()); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } +} + +fn signer() -> Arc { + let config: EncypherConfig = serde_json::from_value(json!({ + "api_base_url": "https://api.example.com", + "domain": "publisher.example.com", + "worker_token": TOKEN, + })) + .expect("should parse config"); + Arc::new(Signer::new(config, 5_000_000)) +} + +fn services(http: &Arc, kv: &Arc) -> RuntimeServices { + build_services_with_http_client(Arc::clone(http) as Arc) + .with_kv_store(Arc::clone(kv) as Arc) +} + +/// The signing headers of an embedded answer for `body`. +fn answer(body: &[u8], content_type: &str) -> Vec<(String, String)> { + [ + ("content-type", content_type.to_owned()), + ("x-encypher-policy-digest", POLICY_DIGEST.to_owned()), + ("x-encypher-signed-sha256", sha256_hex(body)), + ("x-encypher-manifest-instance-id", "xmp:iid:0001".to_owned()), + ("x-encypher-manifest-disposition", "embedded".to_owned()), + ("x-encypher-manifest-link-required", "false".to_owned()), + ( + "x-encypher-verification-url", + "https://verify.example.com/doc-1".to_owned(), + ), + ("x-encypher-document-id", "doc-1".to_owned()), + ] + .into_iter() + .map(|(name, value)| (name.to_owned(), value)) + .collect() +} + +/// The signing headers of a remote-manifest answer that needs a `Link`. +fn external_answer(body: &[u8]) -> Vec<(String, String)> { + let mut headers: Vec<(String, String)> = answer(body, "image/png") + .into_iter() + .filter(|(name, _)| { + name != "x-encypher-manifest-disposition" && name != "x-encypher-manifest-link-required" + }) + .collect(); + for (name, value) in [ + ("x-encypher-manifest-disposition", "external"), + ("x-encypher-manifest-link-required", "true"), + ( + "x-encypher-manifest-url", + "https://api.example.com/manifests/doc-1", + ), + ("x-encypher-manifest-sha256", POLICY_DIGEST), + ] { + headers.push((name.to_owned(), value.to_owned())); + } + headers +} + +fn png_source(sha256: &str) -> MediaSource<'_> { + MediaSource { + page_url: PAGE_URL, + reference: None, + origin_host: None, + mime: "image/png", + media_type: "image/png", + bytes: SOURCE, + sha256, + link_deliverable: false, + require_embedded_manifest: false, + } +} + +fn sign( + signer: &Signer, + services: &RuntimeServices, + source: &MediaSource<'_>, +) -> Result<(SignedMedia, Reason), Reason> { + block_on(async { + let mut work = Deadline::Unbounded; + let mut calls = 0; + signer + .sign_media(services, source, POLICY_DIGEST, &mut work, &mut calls) + .await + }) +} + +#[test] +fn a_document_obeys_its_configured_call_limit_for_distinct_assets() { + for limit in [3, 12] { + let http = Arc::new(StubHttpClient::new()); + let images: Vec> = (0..=limit) + .map(|index| { + let mut bytes = SOURCE.to_vec(); + bytes.push(u8::try_from(index).expect("should fit")); + bytes + }) + .collect(); + for image in images.iter().take(limit) { + let mut signed = image.clone(); + signed.extend_from_slice(b"-c2pa"); + let headers = answer(&signed, "image/png"); + http.push_response_with_headers(200, signed, headers); + } + let kv = Arc::new(MemoryKv::default()); + let document: String = images + .iter() + .map(|image| { + format!( + "", + STANDARD.encode(image) + ) + }) + .collect(); + let mut config = signer().config.clone(); + config.max_media_sign_calls_per_document = limit; + let signer = Signer::new(config, 5_000_000); + let services = services(&http, &kv); + let request = page_request(); + let scan = embedded::scan_html(&document, None); + let output = block_on(async { + let mut work = Deadline::Unbounded; + signer + .sign_embedded( + &services, + &embedded_job(&request, Surface::Page), + &document, + &scan, + &mut work, + ) + .await + }) + .expect("should sign the items within the configured limit"); + + assert_eq!( + http.recorded_request_methods().len(), + limit, + "should stop calling at the configured bound" + ); + for image in images.iter().take(limit) { + let mut signed = image.clone(); + signed.extend_from_slice(b"-c2pa"); + assert!( + output.contains(&STANDARD.encode(&signed)), + "should deliver every signed distinct asset within the limit" + ); + } + let last = images.last().expect("should have a last image"); + assert!( + output.contains(&STANDARD.encode(last)), + "should leave the deferred item as written" + ); + assert_eq!( + kv.keys_with(MEDIA_SKIP_KEY_PREFIX).len(), + 0, + "a deferred item should not be negatively cached" + ); + } +} + +fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) +} + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +#[test] +fn signs_exact_bytes_once_then_serves_the_verified_cached_answer() { + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let signer = signer(); + let sha = sha256_hex(SOURCE); + + let (first, reason) = sign(&signer, &services, &png_source(&sha)).expect("should sign"); + + assert_eq!(reason, Reason::Signed); + assert_eq!(first.bytes.as_ref(), SIGNED, "should keep the signed bytes"); + assert_eq!(http.recorded_request_methods(), ["POST"]); + assert_eq!(http.recorded_request_uris(), [SIGN_URL]); + let headers = &http.recorded_request_headers()[0]; + let key = header(headers, "idempotency-key").expect("should send a replay key"); + assert!( + key.len() == 64 && key.bytes().all(|byte| byte.is_ascii_hexdigit()), + "should derive a hex replay key: {key}" + ); + assert!( + header(headers, "x-encypher-worker-token").is_some(), + "should authenticate with the worker token" + ); + assert!( + header(headers, "content-type") + .is_some_and(|value| value.starts_with("multipart/form-data; boundary=")), + "should send multipart" + ); + let body = &http.recorded_request_bodies()[0]; + assert!(contains(body, SOURCE), "should send the exact source bytes"); + assert!( + contains(body, b"\"reference\":null"), + "should send no reference for media without a safe address" + ); + assert!( + contains(body, b"\"origin_host\":null"), + "should send no origin host for media fetched from no host" + ); + assert!( + contains( + body, + format!("\"expected_policy_digest\":\"{POLICY_DIGEST}\"").as_bytes() + ), + "should send the held policy digest" + ); + + let (second, reason) = sign(&signer, &services, &png_source(&sha)).expect("should sign"); + assert_eq!(reason, Reason::CacheHit); + assert_eq!( + second.bytes.as_ref(), + SIGNED, + "should replay identical bytes" + ); + assert_eq!( + http.recorded_request_methods().len(), + 1, + "should make no second call" + ); +} + +#[test] +fn a_stale_policy_evicts_the_digest_and_caches_a_skip() { + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers( + 409, + serde_json::to_vec(&json!({ + "success": false, + "error": {"code": "TRUSTED_SERVER_POLICY_CHANGED", "message": "stale"}, + })) + .expect("should encode"), + vec![("content-type", "application/json")], + ); + let kv = Arc::new(MemoryKv::default()); + let signer = signer(); + kv.set(&signer.policy_key, Bytes::from_static(b"{}")); + let services = services(&http, &kv); + let sha = sha256_hex(SOURCE); + + assert_eq!( + sign(&signer, &services, &png_source(&sha)).expect_err("should not sign"), + Reason::PolicyChanged + ); + assert!( + kv.get(&signer.policy_key).is_none(), + "should evict the policy" + ); + assert_eq!( + kv.keys_with(MEDIA_SKIP_KEY_PREFIX).len(), + 1, + "should cache a skip" + ); + assert_eq!( + sign(&signer, &services, &png_source(&sha)).expect_err("should not sign"), + Reason::CachedSkip + ); + assert_eq!( + http.recorded_request_methods().len(), + 1, + "should not call again" + ); +} + +#[test] +fn a_purged_result_is_asked_for_again_under_a_new_key_on_the_next_view() { + for (status, code) in [ + (410, "SIGNING_RESULT_PURGED"), + (409, "SIGNING_IDEMPOTENCY_CONFLICT"), + ] { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + status, + serde_json::to_vec(&json!({"success": false, "error": {"code": code}})) + .expect("should encode"), + ); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let signer = signer(); + let sha = sha256_hex(SOURCE); + + let first = sign(&signer, &services, &png_source(&sha)).expect_err("should not sign"); + assert!( + matches!(first, Reason::ResultPurged | Reason::IdempotencyConflict), + "{code}: should report {first:?}" + ); + assert_eq!( + http.recorded_request_methods().len(), + 1, + "{code}: should not retry" + ); + assert!( + kv.keys_with(MEDIA_SKIP_KEY_PREFIX).is_empty(), + "{code}: no skip" + ); + + let (_, reason) = sign(&signer, &services, &png_source(&sha)).expect("should sign"); + assert_eq!(reason, Reason::Signed); + let headers = http.recorded_request_headers(); + assert_ne!( + header(&headers[0], "idempotency-key"), + header(&headers[1], "idempotency-key"), + "{code}: should mint a new replay key" + ); + } +} + +#[test] +fn a_tampered_cached_answer_is_refused_and_evicted() { + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let signer = signer(); + let sha = sha256_hex(SOURCE); + sign(&signer, &services, &png_source(&sha)).expect("should sign"); + let key = kv.keys_with(MEDIA_KEY_PREFIX)[0].clone(); + let mut entry: serde_json::Value = + serde_json::from_slice(&kv.get(&key).expect("should hold the answer")).expect("JSON"); + entry["bytes"] = json!(STANDARD.encode(b"\x89PNG\r\n\x1a\nother-c2pa")); + kv.set(&key, Bytes::from(serde_json::to_vec(&entry).expect("JSON"))); + + assert_eq!( + sign(&signer, &services, &png_source(&sha)).expect_err("should refuse"), + Reason::CachedEntryInvalid + ); + assert!(kv.get(&key).is_none(), "should evict the tampered entry"); +} + +#[test] +fn unsigned_or_mismatched_answers_are_never_used_and_are_negatively_cached() { + let other_policy = "b".repeat(64); + let cases = [ + ( + SOURCE.to_vec(), + answer(SOURCE, "image/png"), + Reason::UnsignedResponse, + ), + ( + b"\x89PNG\r\n\x1a\nswapped-c2pa".to_vec(), + answer(SIGNED, "image/png"), + Reason::SignedDigestMismatch, + ), + ( + SIGNED.to_vec(), + answer(SIGNED, "image/jpeg"), + Reason::SignedTypeMismatch, + ), + ( + SIGNED.to_vec(), + answer(SIGNED, "image/png") + .into_iter() + .map(|(name, value)| { + if name == "x-encypher-policy-digest" { + (name, other_policy.clone()) + } else { + (name, value) + } + }) + .collect(), + Reason::PolicyDigestMismatch, + ), + ( + SIGNED.to_vec(), + external_answer(SIGNED), + Reason::ManifestUndeliverable, + ), + ( + SIGNED.to_vec(), + answer(SIGNED, "image/png") + .into_iter() + .map(|(name, value)| { + if name == "x-encypher-verification-url" { + ( + name, + "http://localhost:9000/api/v1/verify/cdnmedia_dev".to_owned(), + ) + } else { + (name, value) + } + }) + .collect(), + Reason::InvalidSignedResponse, + ), + ]; + for (body, headers, expected) in cases { + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers(200, body, headers); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let sha = sha256_hex(SOURCE); + + assert_eq!( + sign(&signer(), &services, &png_source(&sha)).expect_err("should not sign"), + expected + ); + assert!( + kv.keys_with(MEDIA_KEY_PREFIX).is_empty(), + "{expected:?}: should cache no answer" + ); + assert_eq!( + kv.keys_with(MEDIA_SKIP_KEY_PREFIX).len(), + 1, + "{expected:?}: should cache the failure" + ); + } +} + +fn embedded_job<'a>(request: &'a PageRequest, surface: Surface) -> EmbeddedJob<'a> { + EmbeddedJob { + request, + surface, + policy_digest: POLICY_DIGEST, + } +} + +fn page_request() -> PageRequest { + PageRequest { + method: Method::GET, + page_url: PAGE_URL.to_owned(), + removed_prefix: String::new(), + response_status: 200, + origin_response_is_private: false, + } +} + +fn sign_document(services: &RuntimeServices, document: &str, surface: Surface) -> Option { + sign_document_with_policies(services, document, surface, None) +} + +fn sign_document_with_policies( + services: &RuntimeServices, + document: &str, + surface: Surface, + policies: Option<&[String]>, +) -> Option { + let request = page_request(); + let scan = match surface { + Surface::Page => embedded::scan_html(document, policies), + Surface::Stylesheet => embedded::scan_stylesheet(document), + }; + block_on(async { + let mut work = Deadline::Unbounded; + signer() + .sign_embedded( + services, + &embedded_job(&request, surface), + document, + &scan, + &mut work, + ) + .await + }) +} + +#[test] +fn embedded_data_uris_are_replaced_by_their_signed_bytes_once_per_source() { + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + let kv = Arc::new(MemoryKv::default()); + let source = format!("data:image/png;base64,{}", STANDARD.encode(SOURCE)); + let signed = format!("data:image/png;base64,{}", STANDARD.encode(SIGNED)); + let document = format!( + "

x

a" + ); + + let output = sign_document(&services(&http, &kv), &document, Surface::Page) + .expect("should sign the page's media"); + + assert_eq!( + output, + format!( + "

x

a" + ), + "should replace each URI and change nothing else" + ); + assert_eq!( + http.recorded_request_methods(), + ["POST"], + "should sign one source once" + ); +} + +#[test] +fn a_failed_embedded_signature_leaves_the_uri_as_written() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 422, + b"{\"error\":{\"code\":\"UNSUPPORTED_MEDIA_TYPE\"}}".to_vec(), + ); + let kv = Arc::new(MemoryKv::default()); + let document = format!( + "", + STANDARD.encode(SOURCE) + ); + + assert_eq!( + sign_document(&services(&http, &kv), &document, Surface::Page), + None, + "should leave the page as written" + ); +} + +#[test] +fn stylesheet_media_is_signed_in_place() { + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + let kv = Arc::new(MemoryKv::default()); + let document = format!( + ".hero{{background:url('data:image/png;base64,{}') no-repeat}}", + STANDARD.encode(SOURCE) + ); + + let output = sign_document(&services(&http, &kv), &document, Surface::Stylesheet) + .expect("should sign the stylesheet's media"); + + assert_eq!( + output, + format!( + ".hero{{background:url('data:image/png;base64,{}') no-repeat}}", + STANDARD.encode(SIGNED) + ) + ); +} + +#[test] +fn inline_svg_is_signed_as_its_exact_bytes_and_kept_only_with_the_writer_s_carrier_alone() { + let root = ""; + let body = ""; + let svg = format!("{root}{body}"); + let document = format!("

x

{svg}

y

"); + let signed_svg = format!("{root}{MANIFEST}{body}"); + let restyled = signed_svg.replace("currentColor", "#000"); + for (answer_svg, expected) in [ + ( + signed_svg.clone(), + Some(format!("

x

{signed_svg}

y

")), + ), + (restyled, None), + ] { + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers( + 200, + answer_svg.clone().into_bytes(), + answer(answer_svg.as_bytes(), "image/svg+xml"), + ); + let kv = Arc::new(MemoryKv::default()); + + let output = sign_document(&services(&http, &kv), &document, Surface::Page); + + assert_eq!( + output, expected, + "should keep only a pure manifest insertion" + ); + assert!( + contains(&http.recorded_request_bodies()[0], svg.as_bytes()), + "should send the element's exact bytes" + ); + } +} + +#[test] +fn inline_svg_the_browser_reads_differently_is_signed_as_its_parsed_tree() { + // The browser adds no namespace and keeps the first `viewBox`; what is + // hashed, sent, compared and placed is that tree as standalone XML. + let original = ""; + let serialized = crate::svg_equivalence::signable_svg(original, 1024 * 1024) + .expect("should have an equivalent standalone document") + .into_owned(); + assert_ne!(serialized, original, "should not sign the bytes as written"); + let document = roxmltree::Document::parse(&serialized).expect("should be standalone XML"); + assert_eq!( + document.root_element().attribute("viewBox"), + Some("0 0 2 2"), + "should keep the browser's first viewBox" + ); + // The writer's carrier goes right after the root start tag, whose + // attribute values escape any `>`. + let (root, body) = + serialized.split_at(serialized.find('>').expect("should close the root") + 1); + let signed_svg = format!("{root}{MANIFEST}{body}"); + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers( + 200, + signed_svg.clone().into_bytes(), + answer(signed_svg.as_bytes(), "image/svg+xml"), + ); + let kv = Arc::new(MemoryKv::default()); + + let output = sign_document( + &services(&http, &kv), + &format!("

x

{original}

y

"), + Surface::Page, + ); + + assert_eq!( + output, + Some(format!("

x

{signed_svg}

y

")), + "should place the signed serialization where the element was" + ); + let sent = &http.recorded_request_bodies()[0]; + assert!( + contains(sent, serialized.as_bytes()), + "should send the serialized tree" + ); + assert!( + !contains(sent, original.as_bytes()), + "should not send bytes the browser reads differently" + ); +} + +#[test] +fn unsigned_self_closing_svgs_never_add_whole_asset_signing_calls() { + let http = Arc::new(StubHttpClient::new()); + let kv = Arc::new(MemoryKv::default()); + assert_eq!( + sign_document(&services(&http, &kv), "", Surface::Page), + None + ); + assert!(http.recorded_request_methods().is_empty()); + + let source = format!("data:image/png;base64,{}", STANDARD.encode(SOURCE)); + let signed = format!("data:image/png;base64,{}", STANDARD.encode(SIGNED)); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + assert_eq!( + sign_document( + &services(&http, &kv), + &format!(""), + Surface::Page, + ), + Some(format!("")) + ); + assert_eq!(http.recorded_request_methods(), ["POST"]); +} + +#[test] +fn original_svg_provenance_makes_no_child_or_whole_asset_signing_calls() { + let source = format!("data:image/png;base64,{}", STANDARD.encode(SOURCE)); + for provenance in [ + MANIFEST, + "", + "https://example.com/m", + "", + "", + ] { + let http = Arc::new(StubHttpClient::new()); + let kv = Arc::new(MemoryKv::default()); + let document = format!( + "\ + {provenance}" + ); + assert_eq!( + sign_document_with_policies(&services(&http, &kv), &document, Surface::Page, Some(&[]),), + None, + "must preserve the entire original range for {provenance}" + ); + assert!(http.recorded_request_methods().is_empty(), "{provenance}"); + } +} + +#[test] +fn signing_an_outside_image_cannot_change_original_svg_provenance_ranges() { + let source = format!("data:image/png;base64,{}", STANDARD.encode(SOURCE)); + let signed = format!("data:image/png;base64,{}", STANDARD.encode(SIGNED)); + let svg = format!( + "{MANIFEST}\ + \ + " + ); + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + let kv = Arc::new(MemoryKv::default()); + let document = format!("{svg}"); + assert_eq!( + sign_document_with_policies(&services(&http, &kv), &document, Surface::Page, Some(&[]),), + Some(format!("{svg}")), + "the rescan after outside edits must still preserve every SVG byte" + ); + assert_eq!(http.recorded_request_methods(), ["POST"]); +} + +#[test] +fn an_unsigned_svg_still_signs_its_attribute_css_and_then_the_whole_asset() { + let source = format!("data:image/png;base64,{}", STANDARD.encode(SOURCE)); + let signed = format!("data:image/png;base64,{}", STANDARD.encode(SIGNED)); + let root = ""; + let body = format!( + "Signed with C2PA\ + " + ); + let source_svg = format!("{root}{body}"); + let child_signed_svg = source_svg.replace(&source, &signed); + let signed_svg = format!("{root}{MANIFEST}{}", body.replace(&source, &signed)); + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + http.push_response_with_headers( + 200, + signed_svg.as_bytes().to_vec(), + answer(signed_svg.as_bytes(), "image/svg+xml"), + ); + let kv = Arc::new(MemoryKv::default()); + assert_eq!( + sign_document_with_policies(&services(&http, &kv), &source_svg, Surface::Page, Some(&[]),), + Some(signed_svg) + ); + assert_eq!(http.recorded_request_methods(), ["POST", "POST"]); + let requests = http.recorded_request_bodies(); + assert!(contains(&requests[0], SOURCE)); + assert!(contains(&requests[1], child_signed_svg.as_bytes())); + assert!(!contains(&requests[1], source_svg.as_bytes())); +} + +#[test] +fn inline_svg_that_breaks_out_is_reported_without_a_call() { + let http = Arc::new(StubHttpClient::new()); + let kv = Arc::new(MemoryKv::default()); + + let output = sign_document( + &services(&http, &kv), + "

x

", + Surface::Page, + ); + + assert_eq!(output, None); + assert!( + http.recorded_request_methods().is_empty(), + "should not call" + ); +} + +fn job<'a>( + reader_url: &'a str, + source: &'a Bytes, + sha256: &'a str, + reference: Option<&'a str>, + origin_host: Option<&'a str>, +) -> MediaJob<'a> { + MediaJob { + reader_url, + content_type: "image/png", + source, + source_sha256: sha256, + source_reference: reference, + source_origin_host: origin_host, + optimizer_output: false, + } +} + +fn deliver( + services: &RuntimeServices, + job: &MediaJob<'_>, +) -> Result { + let media = EncypherMedia::new(signer()); + block_on(media.sign(services, job)) +} + +const ORIGIN: Option<&str> = Some("origin.example.com"); + +#[test] +fn delivered_media_outside_the_claim_with_other_bytes_or_no_origin_host_is_refused() { + let http = Arc::new(StubHttpClient::new()); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let source = Bytes::from_static(SOURCE); + let sha = sha256_hex(SOURCE); + let reader = "https://publisher.example.com/a.png"; + + let outside = deliver( + &services, + &job("https://cdn.example.org/a.png", &source, &sha, None, ORIGIN), + ) + .expect_err("should refuse another host"); + assert_eq!(outside.reason, "outside_claim"); + let other = "0".repeat(64); + let mismatch = deliver(&services, &job(reader, &source, &other, None, ORIGIN)) + .expect_err("should refuse other bytes"); + assert_eq!(mismatch.reason, "source_digest_mismatch"); + for host in [ + None, + Some(""), + Some("origin.example.com:8443"), + Some("user@origin.example.com"), + Some("origin.example.com/images"), + Some("Origin.example.com"), + ] { + let refused = deliver(&services, &job(reader, &source, &sha, None, host)) + .expect_err("should refuse a missing or malformed origin host"); + assert_eq!(refused.reason, "invalid_origin_host", "{host:?}"); + } + assert!( + http.recorded_request_methods().is_empty(), + "should not call" + ); +} + +#[test] +fn delivered_media_carries_a_link_for_a_remote_manifest_and_only_a_safe_reference() { + for (reference, origin_host, expected_reference, expected_origin) in [ + ( + Some("https://publisher.example.com/images/a.png"), + "publisher.example.com", + "\"reference\":\"https://publisher.example.com/images/a.png\"", + "\"origin_host\":\"publisher.example.com\"", + ), + ( + Some("https://origin.example.com/images/a.png"), + "origin.example.com", + "\"reference\":null", + "\"origin_host\":\"origin.example.com\"", + ), + // A fetched address with a query is no reference, but its host is + // still the origin's. + ( + None, + "origin.example.com", + "\"reference\":null", + "\"origin_host\":\"origin.example.com\"", + ), + ] { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&json!({"policy_digest": POLICY_DIGEST})).expect("JSON"), + ); + http.push_response_with_headers(200, SOURCE.to_vec(), external_answer(SOURCE)); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let source = Bytes::from_static(SOURCE); + let sha = sha256_hex(SOURCE); + + let delivered = deliver( + &services, + &job( + "https://publisher.example.com/images/a.png", + &source, + &sha, + reference, + Some(origin_host), + ), + ) + .expect("should sign"); + + assert_eq!( + delivered.bytes.as_ref(), + SOURCE, + "should deliver the answer's bytes" + ); + assert_eq!(delivered.content_type, "image/png"); + assert_eq!( + delivered.namespace, + format!( + "{NAMESPACE_VERSION}:{POLICY_DIGEST}:{}", + signer().cache_context + ), + "should name the policy it was signed under for core's kept copy" + ); + let named = |name: &str| { + delivered + .headers + .iter() + .find(|(header, _)| header.as_str() == name) + .map(|(_, value)| value.to_str().expect("text").to_owned()) + }; + assert_eq!( + named("link").as_deref(), + Some("; rel=\"c2pa-manifest\""), + "should name the remote manifest on every response" + ); + assert_eq!(named("x-encypher-document-id").as_deref(), Some("doc-1")); + let body = &http.recorded_request_bodies()[1]; + assert!( + contains(body, expected_reference.as_bytes()), + "should send {expected_reference}" + ); + assert!( + contains(body, expected_origin.as_bytes()), + "should send {expected_origin}" + ); + assert!( + contains(body, b"\"http_link_header\":true"), + "should say a Link can be delivered" + ); + } +} + +#[test] +fn an_optimizer_answer_is_sent_requiring_its_manifest_and_bound_by_its_bytes() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&json!({"policy_digest": POLICY_DIGEST})).expect("JSON"), + ); + http.push_response_with_headers(200, SOURCE.to_vec(), external_answer(SOURCE)); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let source = Bytes::from_static(SOURCE); + let sha = sha256_hex(SOURCE); + let optimized = MediaJob { + optimizer_output: true, + ..job( + "https://publisher.example.com/images/a.png", + &source, + &sha, + Some("https://publisher.example.com/images/a.png"), + Some("publisher.example.com"), + ) + }; + + deliver(&services, &optimized).expect("should sign"); + + let body = &http.recorded_request_bodies()[1]; + assert!( + contains(body, b"\"require_embedded_manifest\":true"), + "should require the optimizer's embedded manifest" + ); + assert!( + contains(body, b"\"reference\":null"), + "should not name the address the optimizer read as the source" + ); + assert!( + contains(body, b"\"operations\":[]"), + "should report no operation of the edge's own" + ); +} + +#[test] +fn a_result_is_never_reused_for_the_same_bytes_from_another_origin() { + let http = Arc::new(StubHttpClient::new()); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let signer = signer(); + let sha = sha256_hex(SOURCE); + + for host in ["origin-a.example.com", "origin-b.example.com"] { + let source = MediaSource { + origin_host: Some(host), + ..png_source(&sha) + }; + let (_, reason) = sign(&signer, &services, &source).expect("should sign"); + assert_eq!(reason, Reason::Signed, "{host}: should ask the endpoint"); + } + assert_eq!( + http.recorded_request_methods().len(), + 2, + "should bind each result to its origin" + ); + let headers = http.recorded_request_headers(); + assert_ne!( + header(&headers[0], "idempotency-key"), + header(&headers[1], "idempotency-key"), + "should replay neither under the other's key" + ); +} + +#[test] +fn media_is_recognized_by_its_type_on_every_path_but_excluded_prefixes() { + let media = EncypherMedia::new(signer()); + for path in [ + "/images/a.png", + "/video/clip.MOV", + "/audio/a.mp3", + "/img/123", + "/media", + ] { + assert!(media.handles_path(path), "should consider {path}"); + } + for path in [ + "/api/v1/a.png", + "/wp-admin/x.jpg", + "/.well-known/a.png", + "/feed/", + ] { + assert!(!media.handles_path(path), "should exclude {path}"); + } + for media_type in [ + "image/webp", + "image/svg+xml", + "video/mp4", + "video/webm", + "audio/mpeg", + "font/woff2", + ] { + assert!( + media.handles_media_type(media_type), + "should sign {media_type}" + ); + } + for media_type in [ + "image/", + "text/css", + "text/html", + "application/pdf", + "video/iso.segment", + ] { + assert!( + !media.handles_media_type(media_type), + "should not sign {media_type}" + ); + } + assert_eq!(media.max_source_bytes(), 5_000_000, "should bound captures"); +} + +#[test] +fn the_kept_copy_namespace_fails_closed_on_revocation_however_warm_the_caches() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&json!({"policy_digest": POLICY_DIGEST})).expect("JSON"), + ); + http.push_response(403, b"{}".to_vec()); + let kv = Arc::new(MemoryKv::default()); + let runtime = services(&http, &kv); + let media = EncypherMedia::new(signer()); + // The article path's policy cache holds a fresh digest throughout. + let signer = signer(); + kv.set( + &signer.policy_key, + Bytes::from( + serde_json::to_vec(&json!({ + "kind": "digest", + "policy_digest": POLICY_DIGEST, + "expires_at": unix_now() + 300, + })) + .expect("JSON"), + ), + ); + + let namespace = block_on(media.namespace(&runtime)).expect("should answer a namespace"); + assert!( + namespace.contains(POLICY_DIGEST), + "should bind the policy digest: {namespace}" + ); + assert!( + !namespace.contains(TOKEN), + "should never carry the credential" + ); + let failure = block_on(media.namespace(&runtime)) + .expect_err("should refuse a revoked credential with the cached digest still fresh"); + assert_eq!(failure.reason, "forbidden"); +} + +#[test] +fn a_warm_signed_result_is_not_delivered_under_a_revoked_credential() { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&json!({"policy_digest": POLICY_DIGEST})).expect("JSON"), + ); + http.push_response_with_headers(200, SIGNED.to_vec(), answer(SIGNED, "image/png")); + http.push_response(403, b"{}".to_vec()); + let kv = Arc::new(MemoryKv::default()); + let runtime = services(&http, &kv); + let source = Bytes::from_static(SOURCE); + let sha = sha256_hex(SOURCE); + let job = job( + "https://publisher.example.com/images/a.png", + &source, + &sha, + None, + ORIGIN, + ); + deliver(&runtime, &job).expect("should sign and cache the result"); + assert!( + !kv.keys_with(MEDIA_KEY_PREFIX).is_empty(), + "should hold the signed result" + ); + + let refused = deliver(&runtime, &job).expect_err("should refuse the revoked credential"); + assert_eq!(refused.reason, "forbidden"); + assert_eq!( + http.recorded_request_methods(), + ["GET", "POST", "GET"], + "should neither sign again nor deliver the cached result" + ); +} diff --git a/crates/provenance/encypher/src/processor/prepared.rs b/crates/provenance/encypher/src/processor/prepared.rs new file mode 100644 index 0000000000..8aa58ad3ad --- /dev/null +++ b/crates/provenance/encypher/src/processor/prepared.rs @@ -0,0 +1,1087 @@ +//! Large media signed where it is delivered, through the prepared lane. +//! +//! A source too large to capture is never uploaded and never held whole. +//! Core pins one version of it by its strong `ETag` and hashes it in bounded +//! chunks. This lane then asks the Encypher prepared-signing endpoints for a +//! remote manifest over those exact bytes: +//! +//! 1. The policy, then the lane's capabilities for the asset. +//! 2. The signed probe release the service advertises, verified against the +//! pinned release key before it runs. +//! 3. The probe's disclosure of the source's container structure, read from +//! the pinned version. A source that already carries an embedded C2PA +//! manifest stops here: a remote manifest cannot stand beside it, as an +//! embedded manifest wins over a `Link`, so the source is delivered as it +//! is, with its own credentials, reported as existing provenance kept and +//! never as newly signed or verified. +//! 4. `prepare`, then the probe's check that the server's plan covers the +//! whole source unchanged, then a last check that the version is still +//! current, then `finalize`. +//! 5. The returned store is checked to bind the whole-source digest. The +//! reader gets the origin's bytes with a `Link` to that store. +//! +//! Every failure is a refusal with a code. Core then delivers the origin's +//! bytes unsigned; nothing is published for a source the lane did not +//! verify. + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderValue, Method, Request, StatusCode, header}; +use serde::Serialize; +use serde_json::{Value, json}; +use std::time::Duration; +use trusted_server_core::media_signing::{ + LargeMediaJob, LargeSignedMedia, MediaSourceError, VersionedMediaSource, +}; +use trusted_server_core::middleware::ResponseHeader; +use trusted_server_core::platform::RuntimeServices; + +use super::{ + Deadline, Reason, SKIP_WRITE_RESERVE, Signer, WORKER_TOKEN_HEADER, is_excluded_prefix, +}; +use crate::api::valid_policy_digest; +use crate::media_api::{ + self, IDEMPOTENCY_KEY_HEADER, canonical_media_type, safe_source_reference, sha256_hex, + valid_origin_host, +}; +use crate::prepared_probe::{ + ProbeCallError, ProbeDescriptor, ProbeLimits, ProbeModule, ProbeSource, ReleaseAuthority, +}; + +const CAPABILITIES_PATH: &str = "/api/v1/public/cdn/media/hash/capabilities"; +const PREPARE_PATH: &str = "/api/v1/public/cdn/media/hash/prepare"; +const FINALIZE_PATH: &str = "/api/v1/public/cdn/media/hash/finalize"; +/// Signed probe releases, served without credentials and never changed. +const PROBE_PATH: &str = "/api/v1/"; +const PROTOCOL: &str = "encypher-prepared-signing/2"; +/// The cached artifact of a probe release, by its SHA-256. +const PROBE_KEY_PREFIX: &str = "encypher:probe:v1"; +/// The largest probe artifact accepted. +const MAX_PROBE_BYTES: usize = 4 * 1024 * 1024; +/// A capabilities answer is a few descriptors and limits. +const MAX_CAPABILITIES_BYTES: usize = 64 * 1024; +/// A prepare answer is ids and the planner's geometry. +const MAX_PREPARE_BYTES: usize = 1024 * 1024; +/// A finalize answer carries the manifest store, base64 encoded, besides +/// ids and URLs. +const FINALIZE_OVERHEAD_BYTES: usize = 64 * 1024; +/// The largest finalized manifest store the lane takes, whatever the +/// service allows: the answer, its decoded store and the probe's copy of it +/// must fit an edge instance. An external manifest of an unsigned source, +/// with no ingredient chain or identity assertion, is far smaller. +const MAX_FINALIZED_MANIFEST_BYTES: usize = 2 * 1024 * 1024; +/// The refusal for a finalized manifest larger than the lane takes. +const MANIFEST_TOO_LARGE: &str = "manifest_too_large"; +/// The outcome of a source that already carries an embedded C2PA manifest: +/// delivered as it is, its own provenance kept, nothing newly signed. +const EMBEDDED_PROVENANCE: &str = "source_has_embedded_provenance"; +/// The outcome of a source whose XMP already names a C2PA manifest: delivered +/// as it is, its own provenance kept, nothing newly signed. +const REFERENCED_PROVENANCE: &str = "source_has_provenance_reference"; + +const INSTANCE_ID_HEADER: &str = "x-encypher-manifest-instance-id"; +const DOCUMENT_ID_HEADER: &str = "x-encypher-document-id"; +const VERIFICATION_URL_HEADER: &str = "x-encypher-verification-url"; + +/// Why the prepared lane did not sign a large source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LargeRefusal { + /// A refusal the module shares with its other lanes. + Module(Reason), + /// The pinned source could not be read as the version it was pinned at. + Source(MediaSourceError), + /// A refusal particular to this lane. + Lane(&'static str), +} + +impl LargeRefusal { + pub(super) fn code(self) -> &'static str { + match self { + Self::Module(reason) => reason.code(), + Self::Source(error) => error.code(), + Self::Lane(code) => code, + } + } + + /// Whether the source was delivered with its own embedded credentials, + /// its provenance kept rather than lost to a failure. + pub(super) fn retains_provenance(self) -> bool { + self == Self::Lane(EMBEDDED_PROVENANCE) || self == Self::Lane(REFERENCED_PROVENANCE) + } +} + +impl From for LargeRefusal { + fn from(reason: Reason) -> Self { + Self::Module(reason) + } +} + +impl From for LargeRefusal { + fn from(error: MediaSourceError) -> Self { + Self::Source(error) + } +} + +/// The pinned version, as the probe reads it. +struct PinnedSource<'a>(&'a VersionedMediaSource<'a>); + +impl ProbeSource for PinnedSource<'_> { + type Error = MediaSourceError; + + fn size(&self) -> u64 { + self.0.size() + } + + async fn read_exact_at( + &self, + offset: u64, + destination: &mut [u8], + ) -> Result<(), MediaSourceError> { + self.0.read_exact_at(offset, destination).await + } +} + +/// The lane's capabilities for one asset, as the service answers them. +struct Capabilities { + policy_digest: String, + formats: Vec, + total_size_bytes: u64, + manifest_decoded_bytes: usize, + probes: Vec, +} + +impl Capabilities { + /// The largest finalized store the lane takes: the service's maximum, + /// never more than the edge's own. + fn manifest_limit(&self) -> usize { + self.manifest_decoded_bytes + .min(MAX_FINALIZED_MANIFEST_BYTES) + } + + fn parse(body: &[u8]) -> Option { + let value: Value = serde_json::from_slice(body).ok()?; + if value.get("protocol")?.as_str()? != PROTOCOL || !value.get("enabled")?.as_bool()? { + return None; + } + let maximums = value.get("maximums")?; + Some(Self { + policy_digest: value.get("policy_digest")?.as_str()?.to_owned(), + formats: value + .get("formats")? + .as_array()? + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + total_size_bytes: maximums.get("total_size_bytes")?.as_u64()?, + manifest_decoded_bytes: usize::try_from( + maximums.get("manifest_decoded_bytes")?.as_u64()?, + ) + .ok()?, + probes: ProbeDescriptor::parse_list(value.get("probe")), + }) + } +} + +/// What the lane signs one source with, once the gates passed. +#[derive(Clone, Copy)] +struct Lane<'a> { + job: &'a LargeMediaJob<'a>, + reference: &'a str, + mime: &'a str, + policy_digest: &'a str, + /// The release key probes are held to: production, except in tests. + authority: &'a ReleaseAuthority, +} + +/// The prepare request, serialized from borrowed values so the disclosure +/// is not copied. +#[derive(Serialize)] +struct PrepareBody<'a> { + page_url: &'a str, + expected_policy_digest: &'a str, + protocol: &'a str, + probe: ProbeSelection<'a>, + source: PreparedSource<'a>, + structure: Vec>, +} + +#[derive(Serialize)] +struct ProbeSelection<'a> { + sha256: &'a str, + planner_id: &'a str, +} + +#[derive(Serialize)] +struct PreparedSource<'a> { + reference: &'a str, + origin_host: &'a str, + mime: &'a str, + sha256: &'a str, + byte_length: u64, +} + +#[derive(Serialize)] +struct StructureChunk<'a> { + offset: u64, + b64: &'a str, +} + +/// The verified finalize answer: what the delivered headers name. +struct Finalized { + manifest_url: String, + instance_id: String, + document_id: String, + verification_url: String, +} + +/// Whether `value` is an identifier safe in a response header. +fn valid_identifier(value: &str) -> bool { + !value.is_empty() && value.len() <= 256 && value.bytes().all(|byte| byte.is_ascii_graphic()) +} + +/// Whether `value` is a canonical absolute `https` URL safe inside a `Link`. +fn valid_https_url(value: &str) -> bool { + value.len() <= 2048 + && value + .bytes() + .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'<' | b'>' | b'"')) + && url::Url::parse(value).is_ok_and(|parsed| { + parsed.scheme() == "https" && parsed.host_str().is_some() && parsed.as_str() == value + }) +} + +/// The reason a probe call failed, logged with the probe's own code. +fn probe_refusal>(op: &str, error: ProbeCallError) -> LargeRefusal { + log::info!("encypher: large media probe op={op} code={}", error.code()); + match error { + ProbeCallError::Source(error) => error.into(), + ProbeCallError::Refused(refusal) if refusal.integrity => { + LargeRefusal::Lane("source_provenance_invalid") + } + ProbeCallError::Refused(_) => LargeRefusal::Lane("probe_refused"), + ProbeCallError::Failed(_) => LargeRefusal::Lane("probe_failed"), + } +} + +/// Whether `items` is the one program of an unchanged external plan: the +/// whole source, once, with nothing excluded or inserted. +fn covers_whole_source(items: &[Value], size: u64) -> bool { + matches!(items, [item] if item.get("range").and_then(Value::as_array).is_some_and(|range| { + range.len() == 2 + && range.first().and_then(Value::as_u64) == Some(0) + && range.get(1).and_then(Value::as_u64) == Some(size) + }) && item.as_object().is_some_and(|object| object.len() == 1)) +} + +/// The reason for an error answer of the prepared endpoints, by status and +/// failure code. +fn answer_refusal(status: StatusCode, code: Option<&str>) -> LargeRefusal { + match (status.as_u16(), code) { + (409, Some("TRUSTED_SERVER_POLICY_CHANGED")) => Reason::PolicyChanged.into(), + (409, Some("PROBE_VERSION_UNSUPPORTED" | "PLAN_STALE")) => { + LargeRefusal::Lane("probe_release_stale") + } + (409, Some("SOURCE_DIGEST_MISMATCH")) => LargeRefusal::Lane("source_digest_mismatch"), + (422, Some("SOURCE_PROVENANCE_INVALID")) => LargeRefusal::Lane("source_provenance_invalid"), + // A store the probe's disclosure did not report: kept, not signed. + (422, Some("SOURCE_MANIFEST_PRESENT")) => LargeRefusal::Lane(EMBEDDED_PROVENANCE), + (422, Some("HASH_MODE_UNSUPPORTED")) => LargeRefusal::Lane("format_unsupported"), + (403 | 404, _) => Reason::Forbidden.into(), + (409, _) => LargeRefusal::Lane("prepared_conflict"), + (413, _) => Reason::PayloadTooLarge.into(), + (422, _) => Reason::Unprocessable.into(), + (429, _) => Reason::RateLimited.into(), + (500..=599, _) => Reason::ServerError.into(), + _ => Reason::UnexpectedStatus.into(), + } +} + +impl Signer { + /// Largest source the prepared lane signs; larger media is delivered + /// unsigned. + pub(super) fn max_large_media_bytes(&self) -> u64 { + self.config.max_large_media_bytes + } + + /// The whole time core and this lane may spend on one large source, or + /// none when the lane is off. + pub(super) fn max_large_media_duration(&self) -> Duration { + if self.config.max_large_media_bytes == 0 { + Duration::ZERO + } else { + Duration::from_millis(self.config.max_large_media_ms) + } + } + + /// Sign one large source core pinned and hashed, returning the headers + /// every response of it carries and the namespace it was signed under. + /// Probe releases are always held to the pinned production key. + pub(super) async fn sign_large( + &self, + services: &RuntimeServices, + job: &LargeMediaJob<'_>, + ) -> Result { + self.sign_large_with(services, job, &ReleaseAuthority::production()) + .await + } + + /// [`Self::sign_large`] with the release `authority` given, which only + /// tests set to anything but production. + async fn sign_large_with( + &self, + services: &RuntimeServices, + job: &LargeMediaJob<'_>, + authority: &ReleaseAuthority, + ) -> Result { + let source = job.source; + let size = source.size(); + let reader_url = url::Url::parse(job.reader_url).map_err(|_| Reason::OutsideClaim)?; + let reader_host = reader_url.host_str().ok_or(Reason::OutsideClaim)?; + if !matches!(reader_url.scheme(), "https" | "http") + || !self.claims_host(reader_host) + || is_excluded_prefix(reader_url.path()) + { + return Err(Reason::OutsideClaim.into()); + } + if size == 0 || size > self.max_large_media_bytes() { + return Err(Reason::MediaTooLarge.into()); + } + if !valid_origin_host(job.source_origin_host) { + return Err(Reason::InvalidOriginHost.into()); + } + if !valid_policy_digest(job.source_sha256) { + return Err(Reason::SourceDigestMismatch.into()); + } + // The fetched address when it names the origin's bytes; otherwise + // the reader's own address, which names the same bytes here because + // they are delivered unchanged. A source with neither is refused. + let reference = job + .source_reference + .filter(|reference| safe_source_reference(reference, job.source_origin_host)) + .or_else(|| { + job.reader_reference.filter(|reference| { + *reference == job.reader_url && safe_source_reference(reference, reader_host) + }) + }) + .ok_or(LargeRefusal::Lane("source_reference_required"))?; + let mime = canonical_media_type(job.content_type); + + // What is left of the lane's budget after core hashed the source, + // less the reserve a policy eviction may still use. + let mut work = Deadline::new( + services.timer(), + job.remaining.saturating_sub(SKIP_WRITE_RESERVE), + ); + work.start().await; + // Asked live: the namespace core keeps the result under must be the + // policy the service holds now, never a cached one a revocation + // outlived. + let policy_digest = self + .request_policy(job.reader_url, services, &mut work) + .await?; + let lane = Lane { + job, + reference, + mime: &mime, + policy_digest: &policy_digest, + authority, + }; + let result = self.run_prepared(services, &lane, &mut work).await; + if let Err(LargeRefusal::Module(Reason::PolicyChanged | Reason::PolicyDigestMismatch)) = + &result + { + self.evict_policy(services.kv_store(), &mut Self::skip_deadline(services)) + .await; + } + let finalized = result?; + let link = format!("<{}>; rel=\"c2pa-manifest\"", finalized.manifest_url); + let mut headers: Vec = Vec::with_capacity(4); + for (name, value) in [ + (header::LINK, link.as_str()), + ( + http::HeaderName::from_static(INSTANCE_ID_HEADER), + finalized.instance_id.as_str(), + ), + ( + http::HeaderName::from_static(DOCUMENT_ID_HEADER), + finalized.document_id.as_str(), + ), + ( + http::HeaderName::from_static(VERIFICATION_URL_HEADER), + finalized.verification_url.as_str(), + ), + ] { + headers.push(( + name, + HeaderValue::from_str(value).map_err(|_| Reason::InvalidSignedResponse)?, + )); + } + Ok(LargeSignedMedia { + headers, + namespace: self.kept_namespace(&policy_digest), + }) + } + + /// The lane's steps after the gates: capabilities, probe, disclosure, + /// prepare, plan check, version check, finalize, store check. + async fn run_prepared( + &self, + services: &RuntimeServices, + lane: &Lane<'_>, + work: &mut Deadline<'_>, + ) -> Result { + let Lane { + job, + reference, + mime, + policy_digest, + authority, + } = *lane; + let source = job.source; + let size = source.size(); + let capabilities = self.capabilities(services, job.reader_url, work).await?; + if capabilities.policy_digest != policy_digest { + return Err(Reason::PolicyChanged.into()); + } + if !capabilities.formats.iter().any(|format| format == mime) { + return Err(LargeRefusal::Lane("format_unsupported")); + } + if size > capabilities.total_size_bytes { + return Err(Reason::MediaTooLarge.into()); + } + let probe = self + .load_probe(services, authority, &capabilities.probes, work) + .await?; + let pinned = PinnedSource(source); + + let mut extracted = match work + .race(probe.call( + "extract", + &json!({"mime": mime, "total_size": size, "disposition": "external"}), + &pinned, + )) + .await + { + None => return Err(Reason::Timeout.into()), + Some(result) => result.map_err(|error| probe_refusal("extract", error))?, + }; + // An embedded manifest wins over a `Link`, so a remote one would be + // ignored beside it, and a source whose XMP already names a manifest + // has provenance of its own: either way the source keeps it. A + // release that does not say whether the XMP names one cannot be + // trusted to have looked. + if extracted.get("credentials").is_some() { + return Err(LargeRefusal::Lane(EMBEDDED_PROVENANCE)); + } + match extracted + .get("has_provenance_reference") + .and_then(Value::as_bool) + { + Some(true) => return Err(LargeRefusal::Lane(REFERENCED_PROVENANCE)), + Some(false) => {} + None => return Err(LargeRefusal::Lane("probe_result_invalid")), + } + // The disclosure is taken out of the result, not copied: it is sent + // at prepare and handed back to the probe at plan verification. + let disclosure = extracted + .get_mut("chunks") + .map(Value::take) + .ok_or(LargeRefusal::Lane("probe_result_invalid"))?; + drop(extracted); + let structure = disclosure + .as_array() + .ok_or(LargeRefusal::Lane("probe_result_invalid"))? + .iter() + .map(|chunk| { + Some(StructureChunk { + offset: chunk.get("offset")?.as_u64()?, + b64: chunk.get("data")?.as_str()?, + }) + }) + .collect::>>() + .ok_or(LargeRefusal::Lane("probe_result_invalid"))?; + + let idempotency_key = sha256_hex( + format!( + "prepared\n{}\n{policy_digest}\n{}\n{reference}\n{}\n{mime}\n{}\n{size}\n{}", + self.cache_context, + job.reader_url, + job.source_origin_host, + job.source_sha256, + source.version().etag(), + ) + .as_bytes(), + ); + let descriptor = probe.descriptor(); + let prepare = PrepareBody { + page_url: job.reader_url, + expected_policy_digest: policy_digest, + protocol: PROTOCOL, + probe: ProbeSelection { + sha256: &descriptor.sha256, + planner_id: &descriptor.planner_id, + }, + source: PreparedSource { + reference, + origin_host: job.source_origin_host, + mime, + sha256: job.source_sha256, + byte_length: size, + }, + structure, + }; + let mut prepared = self + .post( + services, + PREPARE_PATH, + &prepare, + &idempotency_key, + MAX_PREPARE_BYTES, + work, + ) + .await?; + drop(prepare); + let plan_id = prepared + .get("plan_id") + .and_then(Value::as_str) + .filter(|id| id.len() == 36) + .ok_or(LargeRefusal::Lane("prepare_answer_invalid"))? + .to_owned(); + if prepared.get("policy_digest").and_then(Value::as_str) != Some(policy_digest) { + return Err(Reason::PolicyDigestMismatch.into()); + } + let unchanged = prepared + .get("local_ops") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + && prepared.get("effective_action").and_then(Value::as_str) == Some("c2pa.opened"); + let geometry = prepared + .get_mut("geometry") + .map(Value::take) + .filter(|geometry| { + geometry.get("disposition").and_then(Value::as_str) == Some("external") + }); + drop(prepared); + let Some(geometry) = geometry.filter(|_| unchanged) else { + return Err(LargeRefusal::Lane("prepare_answer_invalid")); + }; + + // The probe recomputes the server's plan from the same bytes; an + // unchanged external plan hashes the whole source once. + let verified = match work + .race(probe.call( + "verify_plan", + &json!({ + "mime": mime, + "total_size": size, + "disclosure": disclosure, + "geometry": geometry, + }), + &pinned, + )) + .await + { + None => return Err(Reason::Timeout.into()), + Some(result) => result.map_err(|error| probe_refusal("verify_plan", error))?, + }; + let items = verified + .get("hash_items") + .and_then(Value::as_array) + .ok_or(LargeRefusal::Lane("probe_result_invalid"))?; + if !covers_whole_source(items, size) { + return Err(LargeRefusal::Lane("plan_not_whole_source")); + } + drop(verified); + match work.race(source.confirm_current()).await { + None => return Err(Reason::Timeout.into()), + Some(result) => result?, + } + + let finalize = json!({ + "page_url": job.reader_url, + "expected_policy_digest": policy_digest, + "protocol": PROTOCOL, + "plan_id": plan_id, + "binding": {"type": "data_hash", "digest": job.source_sha256}, + "xmp_reference": false, + }); + let manifest_limit = capabilities.manifest_limit(); + let answer_limit = manifest_limit + .saturating_mul(4) + .div_ceil(3) + .saturating_add(FINALIZE_OVERHEAD_BYTES); + let finalized = self + .post( + services, + FINALIZE_PATH, + &finalize, + &idempotency_key, + answer_limit, + work, + ) + .await + .map_err(|refusal| match refusal { + LargeRefusal::Module(Reason::ResponseTooLarge) => { + LargeRefusal::Lane(MANIFEST_TOO_LARGE) + } + other => other, + })?; + self.verify_finalized(&probe, &finalized, job, policy_digest, &capabilities, work) + .await + } + + /// Checks the finalize answer against the plan and the pinned source, + /// and that the store it returns binds the whole-source digest. + async fn verify_finalized( + &self, + probe: &ProbeModule, + finalized: &Value, + job: &LargeMediaJob<'_>, + policy_digest: &str, + capabilities: &Capabilities, + work: &mut Deadline<'_>, + ) -> Result { + let text = |name: &str| finalized.get(name).and_then(Value::as_str); + if text("policy_digest") != Some(policy_digest) { + return Err(Reason::PolicyDigestMismatch.into()); + } + let external = text("manifest_disposition") == Some("external") + && text("xmp_reference") == Some("declined") + && text("binding_algorithm") == Some("c2pa.hash.data") + && finalized.get("expected_final_size").and_then(Value::as_u64) + == Some(job.source.size()); + if !external { + return Err(LargeRefusal::Lane("finalize_answer_invalid")); + } + let store_b64 = text("manifest_store_b64").ok_or(Reason::UnsignedResponse)?; + let manifest_limit = capabilities.manifest_limit(); + if store_b64.len() + > manifest_limit + .saturating_mul(4) + .div_ceil(3) + .saturating_add(3) + { + return Err(LargeRefusal::Lane(MANIFEST_TOO_LARGE)); + } + let store = STANDARD + .decode(store_b64) + .map_err(|_| Reason::InvalidSignedResponse)?; + if store.is_empty() { + return Err(Reason::InvalidSignedResponse.into()); + } + if store.len() > manifest_limit { + return Err(LargeRefusal::Lane(MANIFEST_TOO_LARGE)); + } + if text("manifest_sha256") != Some(sha256_hex(&store).as_str()) { + return Err(Reason::SignedDigestMismatch.into()); + } + let manifest_url = text("manifest_url") + .filter(|url| valid_https_url(url)) + .ok_or(Reason::InvalidSignedResponse)?; + let verification_url = text("verification_url") + .filter(|url| valid_https_url(url)) + .ok_or(Reason::InvalidSignedResponse)?; + let instance_id = text("instance_id") + .filter(|id| valid_identifier(id)) + .ok_or(Reason::InvalidSignedResponse)?; + let document_id = text("document_id") + .filter(|id| valid_identifier(id)) + .ok_or(Reason::InvalidSignedResponse)?; + // The store's hard binding must be over the whole source, nothing + // excluded, with the digest core computed over the pinned version. + let checked = match work + .race(probe.call_without_source::( + "check_store", + &json!({ + "manifest_store_b64": store_b64, + "digest": job.source_sha256, + "binding_recipe": {"type": "data_hash", "algorithm": "sha256", "exclusions": []}, + }), + )) + .await + { + None => return Err(Reason::Timeout.into()), + Some(result) => result.map_err(|error| probe_refusal("check_store", error))?, + }; + if checked.get("ok").and_then(Value::as_bool) != Some(true) { + return Err(Reason::SignedDigestMismatch.into()); + } + Ok(Finalized { + manifest_url: manifest_url.to_owned(), + instance_id: instance_id.to_owned(), + document_id: document_id.to_owned(), + verification_url: verification_url.to_owned(), + }) + } + + /// The lane's capabilities for the asset at `page_url`. + async fn capabilities( + &self, + services: &RuntimeServices, + page_url: &str, + work: &mut Deadline<'_>, + ) -> Result { + let mut url = url::Url::parse(&format!( + "{}{CAPABILITIES_PATH}", + self.config.api_base_url.trim_end_matches('/') + )) + .map_err(|_| Reason::RequestBuild)?; + url.query_pairs_mut().append_pair("page_url", page_url); + let request = Request::builder() + .method(Method::GET) + .uri(url.as_str()) + .header(WORKER_TOKEN_HEADER, self.worker_token()?) + .body(EdgeBody::empty()) + .map_err(|_| Reason::RequestBuild)?; + let body = self + .exchange(services, request, MAX_CAPABILITIES_BYTES, work) + .await?; + Capabilities::parse(&body).ok_or(LargeRefusal::Lane("capabilities_invalid")) + } + + /// The newest verified release the service advertises, from the cache or + /// fetched, compiled with the edge's limits. + async fn load_probe( + &self, + services: &RuntimeServices, + authority: &ReleaseAuthority, + advertised: &[ProbeDescriptor], + work: &mut Deadline<'_>, + ) -> Result { + let descriptor = authority.select(advertised).map_err(|error| { + log::info!("encypher: large media probe release code={}", error.code()); + LargeRefusal::Lane(error.code()) + })?; + let kv = services.kv_store(); + let key = format!("{PROBE_KEY_PREFIX}:{}", descriptor.sha256); + let cached = match work.race(kv.get_bytes(&key)).await { + Some(Ok(Some(bytes))) if sha256_hex(&bytes) == descriptor.sha256 => Some(bytes), + _ => None, + }; + let artifact = match cached { + Some(bytes) => bytes, + None => { + let request = Request::builder() + .method(Method::GET) + .uri(format!( + "{}{PROBE_PATH}{}", + self.config.api_base_url.trim_end_matches('/'), + descriptor.artifact_path() + )) + .body(EdgeBody::empty()) + .map_err(|_| Reason::RequestBuild)?; + let bytes = self + .exchange(services, request, MAX_PROBE_BYTES, work) + .await?; + if sha256_hex(&bytes) != descriptor.sha256 { + return Err(LargeRefusal::Lane("PROBE_RELEASE_INVALID")); + } + // Releases never change, so a cached copy needs no expiry + // beyond the plan lifetime. + let ttl = Duration::from_secs(self.config.plan_ttl_seconds); + if !matches!( + work.race(kv.put_bytes_with_ttl(&key, bytes.clone(), ttl)) + .await, + Some(Ok(())) + ) { + log::debug!("encypher: probe release cache write failed"); + } + bytes + } + }; + let loaded = match work + // The host's default limits are the edge's: structure only, never + // the payload. A release that runs out of fuel is refused. + .race(ProbeModule::load( + authority, + descriptor, + &artifact, + ProbeLimits::default(), + )) + .await + { + None => return Err(Reason::Timeout.into()), + Some(result) => result, + }; + loaded.map_err(|error| { + log::info!("encypher: large media probe release code={}", error.code()); + LargeRefusal::Lane(error.code()) + }) + } + + /// One authenticated JSON `POST` to a prepared endpoint. + async fn post( + &self, + services: &RuntimeServices, + path: &str, + body: &impl Serialize, + idempotency_key: &str, + max_response_bytes: usize, + work: &mut Deadline<'_>, + ) -> Result { + let body = serde_json::to_vec(body).map_err(|_| Reason::RequestBuild)?; + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "{}{path}", + self.config.api_base_url.trim_end_matches('/') + )) + .header(header::CONTENT_TYPE, "application/json") + .header(WORKER_TOKEN_HEADER, self.worker_token()?) + .header(IDEMPOTENCY_KEY_HEADER, idempotency_key) + .body(EdgeBody::from(body)) + .map_err(|_| Reason::RequestBuild)?; + let answer = self + .exchange(services, request, max_response_bytes, work) + .await?; + serde_json::from_slice(&answer).map_err(|_| Reason::MalformedResponse.into()) + } + + /// One bounded call; a non-`200` answer is a refusal with its code. + async fn exchange( + &self, + services: &RuntimeServices, + request: Request, + max_response_bytes: usize, + work: &mut Deadline<'_>, + ) -> Result { + let limit = max_response_bytes.saturating_add(1); + let response = self.send_api(services, request, limit, work).await?; + let status = response.status(); + if status != StatusCode::OK { + let code = work + .race( + response + .into_body() + .into_bytes_bounded(media_api::MAX_ERROR_BODY_BYTES), + ) + .await + .and_then(Result::ok) + .and_then(|body| media_api::error_code(&body)); + log::info!( + "encypher: large media answer status={} code={}", + status.as_u16(), + code.as_deref().unwrap_or("none") + ); + return Err(answer_refusal(status, code.as_deref())); + } + let body = match work + .race(response.into_body().into_bytes_bounded(limit)) + .await + { + None => return Err(Reason::Timeout.into()), + Some(Err(_)) => return Err(Reason::ResponseTooLarge.into()), + Some(Ok(body)) => body, + }; + if body.len() > max_response_bytes { + return Err(Reason::ResponseTooLarge.into()); + } + Ok(body) + } +} + +#[cfg(test)] +mod flow_tests; + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Arc; + + use futures::executor::block_on; + use http::HeaderMap; + use trusted_server_core::media_signing::SourceVersion; + use trusted_server_core::platform::PlatformHttpClient; + use trusted_server_core::platform::test_support::{ + StubHttpClient, build_services_with_http_client, + }; + + use crate::EncypherConfig; + + const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn signer() -> Signer { + let config: EncypherConfig = serde_json::from_value(json!({ + "api_base_url": "https://api.example.com", + "domain": "publisher.example.com", + "worker_token": "cwk_test_worker_token_value", + "max_large_media_bytes": 1_073_741_824_u64, + })) + .expect("should parse config"); + Signer::new(config, 5_000_000) + } + + /// The gate refusal for one job, made before any request is sent. + fn refusal( + reader_url: &str, + source_reference: Option<&str>, + reader_reference: Option<&str>, + origin_host: &str, + ) -> &'static str { + let http = Arc::new(StubHttpClient::new()); + let services = + build_services_with_http_client(Arc::clone(&http) as Arc); + let origin = url::Url::parse("https://origin.example.com/video/ferry.mp4") + .expect("should parse the origin URL"); + let headers = HeaderMap::new(); + let source = VersionedMediaSource::new( + &services, + &origin, + &headers, + "origin", + SourceVersion::new("\"v1\"", 64 * 1024 * 1024).expect("should pin a strong ETag"), + ); + let job = LargeMediaJob { + reader_url, + content_type: "video/mp4", + source: &source, + source_sha256: SHA, + source_reference, + reader_reference, + source_origin_host: origin_host, + remaining: Duration::from_secs(60), + }; + let code = block_on(signer().sign_large(&services, &job)) + .expect_err("should refuse at the gates") + .code(); + assert!( + http.recorded_request_methods().is_empty(), + "should refuse before any request" + ); + code + } + + #[test] + fn a_large_source_without_a_usable_reference_is_refused_before_any_request() { + let reader = "https://publisher.example.com/video/ferry.mp4"; + assert_eq!( + refusal( + "https://cdn.example.org/video/ferry.mp4", + None, + None, + "origin.example.com" + ), + "outside_claim" + ); + assert_eq!( + refusal(reader, None, None, "Origin.example.com:8080"), + "invalid_origin_host" + ); + // A fetched address on another host than the one fetched, and a + // reader address that is not the delivered one, name no source. + assert_eq!( + refusal( + reader, + Some("https://elsewhere.example.com/video/ferry.mp4"), + Some("https://publisher.example.com/video/other.mp4"), + "origin.example.com", + ), + "source_reference_required" + ); + assert_eq!( + refusal(reader, None, None, "origin.example.com"), + "source_reference_required" + ); + } + + #[test] + fn only_a_single_whole_source_range_is_an_unchanged_plan() { + assert!(covers_whole_source(&[json!({"range": [0, 100]})], 100)); + for items in [ + vec![json!({"range": [0, 99]})], + vec![json!({"range": [0, 50]}), json!({"range": [50, 50]})], + vec![json!({"range": [0, 100]}), json!({"literal": "QQ=="})], + vec![json!({"range": [0, 100], "skip": true})], + vec![], + ] { + assert!( + !covers_whole_source(&items, 100), + "should refuse {items:?} as an unchanged plan" + ); + } + } + + #[test] + fn a_source_with_its_own_manifest_is_reported_as_kept_not_failed_or_signed() { + let kept = answer_refusal( + StatusCode::UNPROCESSABLE_ENTITY, + Some("SOURCE_MANIFEST_PRESENT"), + ); + assert_eq!(kept.code(), "source_has_embedded_provenance"); + assert!( + kept.retains_provenance(), + "should report the provenance kept" + ); + for failed in [ + answer_refusal( + StatusCode::UNPROCESSABLE_ENTITY, + Some("HASH_MODE_UNSUPPORTED"), + ), + answer_refusal( + StatusCode::UNPROCESSABLE_ENTITY, + Some("SOURCE_PROVENANCE_INVALID"), + ), + LargeRefusal::Module(Reason::Timeout), + ] { + assert!( + !failed.retains_provenance(), + "{}: should not claim kept provenance", + failed.code() + ); + } + } + + #[test] + fn prepared_errors_map_to_specific_refusals() { + assert_eq!( + answer_refusal(StatusCode::CONFLICT, Some("TRUSTED_SERVER_POLICY_CHANGED")), + LargeRefusal::Module(Reason::PolicyChanged) + ); + assert_eq!( + answer_refusal(StatusCode::CONFLICT, Some("PROBE_VERSION_UNSUPPORTED")).code(), + "probe_release_stale" + ); + assert_eq!( + answer_refusal( + StatusCode::UNPROCESSABLE_ENTITY, + Some("SOURCE_PROVENANCE_INVALID") + ) + .code(), + "source_provenance_invalid" + ); + assert_eq!( + answer_refusal(StatusCode::FORBIDDEN, Some("claim_changed")), + LargeRefusal::Module(Reason::Forbidden) + ); + } + + #[test] + fn capabilities_parse_only_an_enabled_v2_lane() { + let body = |enabled: bool, protocol: &str| { + serde_json::to_vec(&json!({ + "protocol": protocol, + "enabled": enabled, + "policy_digest": "a".repeat(64), + "formats": ["video/mp4"], + "maximums": { + "total_size_bytes": 1_000_000_000_u64, + "structure_decoded_bytes": 33_554_432, + "structure_chunks": 8192, + "manifest_decoded_bytes": 1_048_576, + "prepare_request_bytes": 50_331_648, + }, + "probe": [], + })) + .expect("should encode") + }; + let parsed = Capabilities::parse(&body(true, PROTOCOL)).expect("should parse"); + assert_eq!(parsed.formats, ["video/mp4"]); + assert_eq!(parsed.manifest_decoded_bytes, 1_048_576); + assert!(Capabilities::parse(&body(false, PROTOCOL)).is_none()); + assert!(Capabilities::parse(&body(true, "encypher-prepared-signing/1")).is_none()); + } +} diff --git a/crates/provenance/encypher/src/processor/prepared/flow_tests.rs b/crates/provenance/encypher/src/processor/prepared/flow_tests.rs new file mode 100644 index 0000000000..8ae75e4d41 --- /dev/null +++ b/crates/provenance/encypher/src/processor/prepared/flow_tests.rs @@ -0,0 +1,581 @@ +//! The prepared lane end to end, against a stand-in probe release signed with +//! a test key, a stub service and a stub origin. +//! +//! The stand-in has no format logic: each operation answers JSON the test +//! chose, so these tests exercise the lane's own orchestration and checks, +//! and no Encypher planner code enters this repository. Production always +//! holds releases to the pinned production key; only these tests pass +//! another authority. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use ed25519_dalek::{Signer as _, SigningKey}; +use edgezero_core::key_value_store::KvPage; +use futures::executor::block_on; +use http::HeaderMap; +use serde_json::{Value, json}; +use trusted_server_core::media_signing::{ + LargeMediaJob, LargeSignedMedia, SourceVersion, VersionedMediaSource, +}; +use trusted_server_core::platform::test_support::{ + StubHttpClient, build_services_with_http_client, +}; +use trusted_server_core::platform::{ + KvError, PlatformHttpClient, PlatformKvStore, RuntimeServices, +}; + +use super::{LargeRefusal, Signer}; +use crate::EncypherConfig; +use crate::media_api::sha256_hex; +use crate::prepared_probe::{ProbeDescriptor, ReleaseAuthority}; + +const POLICY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OTHER_POLICY: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const SOURCE_SHA: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const SIZE: u64 = 20_000_000; +const READER_URL: &str = "https://publisher.example.com/video/ferry.mp4"; +const ORIGIN_HOST: &str = "publisher.example.com"; +const PLAN_ID: &str = "0f8fad5b-d9cb-469f-a165-70867728950e"; +const STORE: &[u8] = b"jumb-c2pa-remote-manifest-store"; + +const STAND_IN: &str = r#" +(module + (import "env" "host_read" (func $host_read (param i64 i32 i32) (result i32))) + (memory (export "memory") 2) + (global $heap (mut i32) (i32.const 16384)) + (global $result_ptr (mut i32) (i32.const 0)) + (global $result_len (mut i32) (i32.const 0)) + (data (i32.const 1024) "@VERSION@") + (data (i32.const 2048) "@EXTRACT@") + (data (i32.const 4096) "@VERIFY@") + (data (i32.const 6144) "@CHECK@") + (data (i32.const 8192) "@UNKNOWN@") + (func (export "probe_abi") (result i32) (i32.const 1)) + (func (export "probe_alloc") (param $length i32) (result i32) + (local $pointer i32) + (local.set $pointer (global.get $heap)) + (global.set $heap (i32.add (global.get $heap) (local.get $length))) + (local.get $pointer)) + (func (export "probe_free") (param i32 i32)) + (func (export "probe_result_ptr") (result i32) (global.get $result_ptr)) + (func (export "probe_result_len") (result i32) (global.get $result_len)) + (func $answer (param $pointer i32) (param $length i32) (param $code i32) (result i32) + (global.set $result_ptr (local.get $pointer)) + (global.set $result_len (local.get $length)) + (local.get $code)) + (func (export "probe_call") + (param $op i32) (param $op_len i32) (param $arg i32) (param $arg_len i32) (result i32) + (local $kind i32) + (local.set $kind (i32.load8_u (local.get $op))) + ;; "version" (7 bytes) and "verify_plan" (11 bytes) both start with v + (if (i32.and (i32.eq (local.get $kind) (i32.const 118)) (i32.eq (local.get $op_len) (i32.const 7))) + (then (return (call $answer (i32.const 1024) (i32.const @VERSION_LEN@) (i32.const 0))))) + (if (i32.eq (local.get $kind) (i32.const 118)) + (then (return (call $answer (i32.const 4096) (i32.const @VERIFY_LEN@) (i32.const 0))))) + ;; "extract" + (if (i32.eq (local.get $kind) (i32.const 101)) + (then (return (call $answer (i32.const 2048) (i32.const @EXTRACT_LEN@) (i32.const 0))))) + ;; "check_store" + (if (i32.eq (local.get $kind) (i32.const 99)) + (then (return (call $answer (i32.const 6144) (i32.const @CHECK_LEN@) (i32.const 0))))) + (call $answer (i32.const 8192) (i32.const @UNKNOWN_LEN@) (i32.const 1))) +) +"#; + +/// What each stand-in operation answers. +struct Answers { + extract: Value, + verify: Value, + check: Value, +} + +impl Default for Answers { + fn default() -> Self { + Self { + extract: json!({ + "chunks": [{"offset": 0, "data": "AAAA"}], + "has_provenance_reference": false, + }), + verify: json!({"hash_items": [{"range": [0, SIZE]}]}), + check: json!({"ok": true}), + } + } +} + +fn planner() -> String { + "d".repeat(64) +} + +fn wat_string(text: &str) -> String { + text.replace('\\', "\\\\").replace('"', "\\\"") +} + +/// The stand-in release answering `answers`. +fn release(answers: &Answers) -> Vec { + let version = json!({"planner_id": planner()}).to_string(); + let unknown = + json!({"error": {"code": "UNKNOWN_OP", "integrity": false, "message": "no such op"}}) + .to_string(); + let extract = answers.extract.to_string(); + let verify = answers.verify.to_string(); + let check = answers.check.to_string(); + let text = STAND_IN + .replace("@VERSION@", &wat_string(&version)) + .replace("@VERSION_LEN@", &version.len().to_string()) + .replace("@EXTRACT@", &wat_string(&extract)) + .replace("@EXTRACT_LEN@", &extract.len().to_string()) + .replace("@VERIFY@", &wat_string(&verify)) + .replace("@VERIFY_LEN@", &verify.len().to_string()) + .replace("@CHECK@", &wat_string(&check)) + .replace("@CHECK_LEN@", &check.len().to_string()) + .replace("@UNKNOWN@", &wat_string(&unknown)) + .replace("@UNKNOWN_LEN@", &unknown.len().to_string()); + wat::parse_str(&text).expect("should assemble the stand-in release") +} + +fn release_key() -> SigningKey { + SigningKey::from_bytes(&[11; 32]) +} + +/// The descriptor of `artifact`, signed with the test release key. +fn descriptor(artifact: &[u8]) -> ProbeDescriptor { + let mut descriptor = ProbeDescriptor { + abi: 1, + planner_id: planner(), + release: 3, + sha256: sha256_hex(artifact), + signature: String::new(), + }; + let signature = release_key().sign(descriptor.canonical_json().as_bytes()); + descriptor.signature = STANDARD.encode(signature.to_bytes()); + descriptor +} + +fn test_authority() -> ReleaseAuthority { + ReleaseAuthority::pinned(vec![release_key().verifying_key()], 1, Vec::new()) +} + +/// A runtime KV store held in memory. +#[derive(Default)] +struct MemoryKv(Mutex>); + +impl MemoryKv { + fn has(&self, key: &str) -> bool { + self.0.lock().expect("should lock").contains_key(key) + } +} + +#[async_trait::async_trait(?Send)] +impl PlatformKvStore for MemoryKv { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + Ok(self.0.lock().expect("should lock").get(key).cloned()) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.0 + .lock() + .expect("should lock") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: std::time::Duration, + ) -> Result<(), KvError> { + self.put_bytes(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.0.lock().expect("should lock").remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } +} + +fn signer() -> Signer { + let config: EncypherConfig = serde_json::from_value(json!({ + "api_base_url": "https://api.example.com", + "domain": "publisher.example.com", + "worker_token": "cwk_test_worker_token_value", + "max_large_media_bytes": 1_073_741_824_u64, + })) + .expect("should parse config"); + Signer::new(config, 5_000_000) +} + +fn finalize_answer() -> Value { + json!({ + "protocol": "encypher-prepared-signing/2", + "plan_id": PLAN_ID, + "instance_id": "xmp:iid:0001", + "document_id": "doc-1", + "manifest_disposition": "external", + "content_hash_kind": "hard_binding", + "binding_algorithm": "c2pa.hash.data", + "manifest_store_b64": STANDARD.encode(STORE), + "manifest_sha256": sha256_hex(STORE), + "manifest_url": "https://api.example.com/manifests/doc-1", + "xmp_reference": "declined", + "expected_final_size": SIZE, + "verification_url": "https://verify.example.com/doc-1", + "reused": false, + "policy_digest": POLICY, + }) +} + +fn prepare_answer() -> Value { + json!({ + "protocol": "encypher-prepared-signing/2", + "plan_id": PLAN_ID, + "instance_id": "xmp:iid:0001", + "document_id": "doc-1", + "mime": "video/mp4", + "effective_action": "c2pa.opened", + "geometry": {"disposition": "external"}, + "local_ops": [], + "policy_digest": POLICY, + }) +} + +/// One run of the lane, and what it asked of the service and origin. +struct Run { + result: Result, + http: Arc, + kv: Arc, + signer: Signer, +} + +impl Run { + fn methods(&self) -> Vec { + self.http.recorded_request_methods() + } + + fn refusal(&self) -> LargeRefusal { + *self.result.as_ref().expect_err("should refuse") + } + + fn body(&self, index: usize) -> Value { + serde_json::from_slice(&self.http.recorded_request_bodies()[index]) + .expect("should send JSON") + } +} + +/// The scenario's answers, queued in the order the lane asks for them. +struct Scenario { + answers: Answers, + capabilities_policy: &'static str, + /// The largest manifest store the service says it returns. + manifest_decoded_bytes: usize, + prepare: Value, + finalize: Value, +} + +impl Default for Scenario { + fn default() -> Self { + Self { + answers: Answers::default(), + capabilities_policy: POLICY, + manifest_decoded_bytes: 1_048_576, + prepare: prepare_answer(), + finalize: finalize_answer(), + } + } +} + +fn run(scenario: &Scenario) -> Run { + let artifact = release(&scenario.answers); + let probe = descriptor(&artifact); + let http = Arc::new(StubHttpClient::new()); + http.set_streaming_responses_supported(true); + http.push_response( + 200, + serde_json::to_vec(&json!({"policy_digest": POLICY})).expect("JSON"), + ); + http.push_response( + 200, + serde_json::to_vec(&json!({ + "protocol": "encypher-prepared-signing/2", + "enabled": true, + "policy_digest": scenario.capabilities_policy, + "formats": ["video/mp4"], + "maximums": { + "total_size_bytes": 9_007_199_246_352_383_u64, + "structure_decoded_bytes": 33_554_432, + "structure_chunks": 8192, + "manifest_decoded_bytes": scenario.manifest_decoded_bytes, + "prepare_request_bytes": 50_331_648, + }, + "probe": [probe.to_json()], + })) + .expect("JSON"), + ); + http.push_response(200, artifact); + http.push_response(200, serde_json::to_vec(&scenario.prepare).expect("JSON")); + // The pinned one-byte read that confirms the version before finalize. + http.push_response_with_headers( + 206, + b"\x00".to_vec(), + vec![ + ("content-range", format!("bytes 0-0/{SIZE}")), + ("content-length", "1".to_owned()), + ("etag", "\"v1\"".to_owned()), + ], + ); + http.push_response(200, serde_json::to_vec(&scenario.finalize).expect("JSON")); + + let kv = Arc::new(MemoryKv::default()); + let services: RuntimeServices = + build_services_with_http_client(Arc::clone(&http) as Arc) + .with_kv_store(Arc::clone(&kv) as Arc); + let origin = url::Url::parse("https://publisher.example.com/video/ferry.mp4") + .expect("should parse the origin URL"); + let headers = HeaderMap::new(); + let source = VersionedMediaSource::new( + &services, + &origin, + &headers, + "origin", + SourceVersion::new("\"v1\"", SIZE).expect("should pin a strong ETag"), + ); + let job = LargeMediaJob { + reader_url: READER_URL, + content_type: "video/mp4", + source: &source, + source_sha256: SOURCE_SHA, + source_reference: Some(READER_URL), + reader_reference: None, + source_origin_host: ORIGIN_HOST, + remaining: std::time::Duration::from_secs(60), + }; + let signer = signer(); + let result = block_on(signer.sign_large_with(&services, &job, &test_authority())); + Run { + result, + http, + kv, + signer, + } +} + +#[test] +fn an_unchanged_source_is_bound_whole_after_its_version_is_confirmed() { + let run = run(&Scenario::default()); + + run.result.as_ref().expect("should sign"); + assert_eq!( + run.methods(), + ["GET", "GET", "GET", "POST", "GET", "POST"], + "should confirm the pinned version after prepare and before finalize" + ); + let finalize = run.body(5); + assert_eq!( + finalize["binding"], + json!({"type": "data_hash", "digest": SOURCE_SHA}), + "should bind the whole-source digest" + ); + assert_eq!( + finalize["xmp_reference"], false, + "should never edit the source" + ); + let keys: Vec = run + .http + .recorded_request_headers() + .iter() + .filter_map(|headers| { + headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("idempotency-key")) + .map(|(_, value)| value.clone()) + }) + .collect(); + assert_eq!(keys.len(), 2, "should key prepare and finalize"); + assert_eq!(keys[0], keys[1], "should finalize under the plan's own key"); +} + +#[test] +fn a_manifest_larger_than_the_lane_takes_is_refused() { + let run = run(&Scenario { + manifest_decoded_bytes: STORE.len() - 1, + ..Scenario::default() + }); + assert_eq!( + run.refusal().code(), + "manifest_too_large", + "should hold the store to the smaller of the service's and the edge's limit" + ); +} + +#[test] +fn a_source_with_provenance_of_its_own_keeps_it_and_is_never_prepared() { + for (extract, reason) in [ + ( + json!({ + "chunks": [{"offset": 0, "data": "AAAA"}], + "credentials": {"manifests": 1}, + "has_provenance_reference": false, + }), + "source_has_embedded_provenance", + ), + ( + json!({ + "chunks": [{"offset": 0, "data": "AAAA"}], + "has_provenance_reference": true, + }), + "source_has_provenance_reference", + ), + ] { + let run = run(&Scenario { + answers: Answers { + extract, + ..Answers::default() + }, + ..Scenario::default() + }); + + let refusal = run.refusal(); + assert_eq!(refusal.code(), reason); + assert!(refusal.retains_provenance(), "{reason}: should keep it"); + assert!( + !run.methods().iter().any(|method| method == "POST"), + "{reason}: should never prepare or finalize" + ); + } +} + +#[test] +fn a_release_that_does_not_report_a_provenance_reference_is_not_trusted() { + for flag in [None, Some(json!("false")), Some(json!(0))] { + let mut extract = json!({"chunks": [{"offset": 0, "data": "AAAA"}]}); + if let Some(flag) = flag.clone() { + extract["has_provenance_reference"] = flag; + } + let run = run(&Scenario { + answers: Answers { + extract, + ..Answers::default() + }, + ..Scenario::default() + }); + + let refusal = run.refusal(); + assert_eq!(refusal.code(), "probe_result_invalid", "{flag:?}"); + assert!(!refusal.retains_provenance(), "{flag:?}: should not claim"); + assert!( + !run.methods().iter().any(|method| method == "POST"), + "{flag:?}: should never prepare" + ); + } +} + +#[test] +fn a_plan_that_is_not_the_unchanged_whole_source_is_never_finalized() { + let mut edits = prepare_answer(); + edits["local_ops"] = json!([{"op": "xmp"}]); + let mut edited = prepare_answer(); + edited["effective_action"] = json!("c2pa.edited"); + let mut embedded = prepare_answer(); + embedded["geometry"] = json!({"disposition": "embedded"}); + for prepare in [edits, edited, embedded] { + let run = run(&Scenario { + prepare, + ..Scenario::default() + }); + assert_eq!(run.refusal().code(), "prepare_answer_invalid"); + assert_eq!(run.methods().len(), 4, "should stop after prepare"); + } + + let run = run(&Scenario { + answers: Answers { + verify: json!({"hash_items": [{"range": [0, SIZE - 1]}]}), + ..Answers::default() + }, + ..Scenario::default() + }); + assert_eq!(run.refusal().code(), "plan_not_whole_source"); + assert_eq!( + run.methods().len(), + 4, + "should stop before the version check" + ); +} + +#[test] +fn a_finalized_answer_that_does_not_bind_the_source_is_never_delivered() { + let mut resized = finalize_answer(); + resized["expected_final_size"] = json!(SIZE + 1); + let mut swapped = finalize_answer(); + swapped["manifest_sha256"] = json!(sha256_hex(b"another store")); + let mut embedded = finalize_answer(); + embedded["manifest_disposition"] = json!("embedded"); + for (finalize, reason) in [ + (resized, "finalize_answer_invalid"), + (swapped, "signed_digest_mismatch"), + (embedded, "finalize_answer_invalid"), + ] { + let run = run(&Scenario { + finalize, + ..Scenario::default() + }); + assert_eq!(run.refusal().code(), reason); + } + + let run = run(&Scenario { + answers: Answers { + check: json!({"ok": false}), + ..Answers::default() + }, + ..Scenario::default() + }); + assert_eq!( + run.refusal().code(), + "signed_digest_mismatch", + "should refuse a store that does not bind the whole-source digest" + ); +} + +#[test] +fn a_changed_policy_evicts_the_cached_policy_and_publishes_nothing() { + let changed = run(&Scenario { + capabilities_policy: OTHER_POLICY, + ..Scenario::default() + }); + assert_eq!(changed.refusal().code(), "policy_changed"); + assert!( + !changed.kv.has(&changed.signer.policy_key), + "should evict the policy the lane held" + ); + assert!(!changed.methods().iter().any(|method| method == "POST")); + + let mut prepared = prepare_answer(); + prepared["policy_digest"] = json!(OTHER_POLICY); + let mismatched = run(&Scenario { + prepare: prepared, + ..Scenario::default() + }); + assert_eq!(mismatched.refusal().code(), "policy_digest_mismatch"); + assert!(!mismatched.kv.has(&mismatched.signer.policy_key)); + + let mut finalized = finalize_answer(); + finalized["policy_digest"] = json!(OTHER_POLICY); + let late = run(&Scenario { + finalize: finalized, + ..Scenario::default() + }); + assert_eq!(late.refusal().code(), "policy_digest_mismatch"); + assert!(!late.kv.has(&late.signer.policy_key)); +} diff --git a/crates/provenance/encypher/src/svg_equivalence.rs b/crates/provenance/encypher/src/svg_equivalence.rs new file mode 100644 index 0000000000..3f4a9d0685 --- /dev/null +++ b/crates/provenance/encypher/src/svg_equivalence.rs @@ -0,0 +1,977 @@ +//! The standalone SVG document an inline `` is signed as. +//! +//! A browser builds an inline SVG's tree with the HTML parser, which reads +//! it differently from an XML parser: it needs no namespace declaration, +//! keeps the first of two attributes whose names differ only in case, and +//! decodes HTML character references. What is signed must be a standalone +//! SVG document whose XML tree is the tree the browser built, so a verifier +//! reading the signed bytes sees what the reader saw. +//! +//! The fragment is parsed as HTML in a `body` context, into scraper's +//! namespace-aware tree, as the browser parsed it, and refused unless that +//! yields exactly one `` root with nothing displaced beside it but +//! whitespace. Where the original bytes already parse as XML to the same +//! tree, they are signed unchanged. Otherwise, unless the fragment carries +//! provenance of its own, which is never rewritten, the browser's tree is +//! serialized as XML and kept only when that serialization parses to the +//! same tree both as HTML and as XML. +//! +//! Trees are compared by element namespace and local name, the set of +//! ordinary attributes by namespace, local name and value, text with +//! adjacent text merged and CDATA read as text, and comments. Namespace +//! declarations are not ordinary attributes and are not compared: only the +//! namespaces they resolve are. Anything else, a processing instruction or a +//! doctype, makes two trees unequal. XML is always parsed with DTDs +//! refused, so no entity is ever declared or resolved. + +use std::borrow::Cow; +use std::marker::PhantomData; + +use ego_tree::NodeRef; +use ego_tree::iter::Children; +use html5ever::tendril::TendrilSink as _; +use html5ever::{LocalName, Namespace, ParseOpts, QualName, parse_fragment}; +use scraper::node::Element; +use scraper::{HtmlTreeSink, Node}; + +use crate::embedded; +use crate::processor::Reason; + +const HTML_NAMESPACE: &str = "http://www.w3.org/1999/xhtml"; +const SVG_NAMESPACE: &str = "http://www.w3.org/2000/svg"; +const XLINK_NAMESPACE: &str = "http://www.w3.org/1999/xlink"; +const XML_NAMESPACE: &str = "http://www.w3.org/XML/1998/namespace"; +const XMLNS_NAMESPACE: &str = "http://www.w3.org/2000/xmlns/"; + +/// The most nodes an inline SVG may hold. +const MAX_NODES: usize = 10_000; +/// The deepest an inline SVG's elements may nest. +const MAX_DEPTH: usize = 256; +/// Nodes the HTML fragment parser makes besides the fragment's own: the +/// fragment root, the context element and the root `html` element. +const PARSER_NODES: usize = 3; + +/// The declarations the serialization's root carries. +const SVG_DECLARATION: &str = " xmlns=\"http://www.w3.org/2000/svg\""; +const XLINK_DECLARATION: &str = " xmlns:xlink=\"http://www.w3.org/1999/xlink\""; + +/// The standalone SVG document `fragment`, an inline `` element as +/// written in an HTML page, is signed as: `fragment` itself where it +/// already parses as XML to the tree the browser built from it, else that +/// tree serialized as XML. +/// +/// # Errors +/// +/// - [`Reason::MediaTooLarge`] where `fragment` is longer than `max_bytes`. +/// - [`Reason::SvgProvenanceNotEquivalent`] where `fragment` carries +/// provenance of its own and is not, as written, equivalent: it is never +/// rewritten. +/// - [`Reason::InlineSvgNotEquivalent`] where the browser's tree is not one +/// `` root, exceeds the node or depth bound, or has no XML +/// serialization that parses back to it as both HTML and XML. +pub(crate) fn signable_svg(fragment: &str, max_bytes: usize) -> Result, Reason> { + if fragment.len() > max_bytes { + return Err(Reason::MediaTooLarge); + } + let parsed = parse_html(fragment).ok_or(Reason::InlineSvgNotEquivalent)?; + let root = parsed.root().ok_or(Reason::InlineSvgNotEquivalent)?; + if let Some(document) = parse_xml(fragment) + && equal(&Browser, root, &Xml(PhantomData), document.root_element()) + { + return Ok(Cow::Borrowed(fragment)); + } + if carries_provenance(fragment, root) { + return Err(Reason::SvgProvenanceNotEquivalent); + } + let serialized = serialize(root, max_bytes).ok_or(Reason::InlineSvgNotEquivalent)?; + let equivalent = { + let as_html = parse_html(&serialized); + let as_xml = parse_xml(&serialized); + match (as_html.as_ref().and_then(Parsed::root), as_xml) { + (Some(as_html), Some(as_xml)) => { + equal(&Browser, root, &Browser, as_html) + && equal(&Browser, root, &Xml(PhantomData), as_xml.root_element()) + } + _ => false, + } + }; + if equivalent { + Ok(Cow::Owned(serialized)) + } else { + Err(Reason::InlineSvgNotEquivalent) + } +} + +/// Refuse all edits inside an original inline SVG that carries provenance. +/// A fragment whose structure cannot be inspected within the parser's limits +/// is also left intact, rather than assuming it has no provenance. +pub(crate) fn descendant_edit_refusal(fragment: &str) -> Option { + let Some(parsed) = parse_html(fragment) else { + return Some(Reason::InlineSvgNotEquivalent); + }; + let Some(root) = parsed.root() else { + return Some(Reason::InlineSvgNotEquivalent); + }; + carries_provenance(fragment, root).then_some(Reason::SvgExistingProvenance) +} + +/// Whether the inline SVG under `root` carries provenance of its own: the +/// manifest carrier the signer's writer finds, an element or attribute in +/// the `c2pa` prefix, a declaration of a C2PA namespace, or a provenance +/// reference such as XMP's `dcterms:provenance`. The HTML parser keeps such +/// prefixed names whole, as written. Ordinary text that mentions C2PA is +/// not provenance. +fn carries_provenance(fragment: &str, root: NodeRef<'_, Node>) -> bool { + if embedded::existing_carrier(fragment.as_bytes()).is_some() { + return true; + } + root.descendants().any(|node| { + let Node::Element(element) = node.value() else { + return false; + }; + names_provenance(&element.name.local) + || element.attrs.iter().any(|(name, value)| { + names_provenance(&name.local) + || (is_declaration(&name.ns, &name.local) && is_c2pa_namespace(value)) + }) + }) +} + +/// Whether a name the HTML parser kept whole, prefix included, is in the +/// `c2pa` prefix or names a provenance reference. +fn names_provenance(name: &str) -> bool { + let (prefix, local) = name.rsplit_once(':').unwrap_or(("", name)); + prefix.eq_ignore_ascii_case("c2pa") || local.eq_ignore_ascii_case("provenance") +} + +fn is_c2pa_namespace(uri: &str) -> bool { + let uri = uri.trim(); + ["http://c2pa.org/", "https://c2pa.org/"] + .iter() + .any(|start| { + uri.get(..start.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(start)) + }) +} + +/// scraper's tree for one fragment, with its `` root. +struct Parsed { + html: scraper::Html, + root: ego_tree::NodeId, +} + +impl Parsed { + fn root(&self) -> Option> { + self.html.tree.get(self.root) + } +} + +/// `fragment` parsed as HTML in a `body` context with scripting on, as a +/// browser parses inline SVG, into scraper's tree, or `None` unless that +/// yields exactly one `` element at the top, with only whitespace text +/// beside it, within the node bound. +fn parse_html(fragment: &str) -> Option { + // Every element and comment the tokenizer starts begins at a `<`, so + // this bounds what the parse allocates before the exact count below. + if fragment.bytes().filter(|byte| *byte == b'<').count() > MAX_NODES { + return None; + } + let context = QualName::new( + None, + Namespace::from(HTML_NAMESPACE), + LocalName::from("body"), + ); + let html = parse_fragment( + HtmlTreeSink::new(scraper::Html::new_fragment()), + ParseOpts::default(), + context, + Vec::new(), + true, + ) + .one(fragment); + if html.tree.values().count() > MAX_NODES + PARSER_NODES { + return None; + } + let top = html + .tree + .root() + .children() + .find(|child| child.value().is_element())?; + let mut root = None; + for child in top.children() { + match child.value() { + Node::Element(element) + if root.is_none() + && &*element.name.ns == SVG_NAMESPACE + && &*element.name.local == "svg" => + { + root = Some(child.id()); + } + Node::Text(text) if text.chars().all(is_html_whitespace) => {} + _ => return None, + } + } + let root = root?; + Some(Parsed { html, root }) +} + +fn is_html_whitespace(character: char) -> bool { + matches!(character, '\t' | '\n' | '\u{c}' | '\r' | ' ') +} + +/// `text` parsed as XML with DTDs refused, so no entity is declared or +/// resolved, or `None` where it is not well-formed. +fn parse_xml(text: &str) -> Option> { + let options = roxmltree::ParsingOptions { + allow_dtd: false, + nodes_limit: u32::try_from(MAX_NODES + 1).unwrap_or(u32::MAX), + ..roxmltree::ParsingOptions::default() + }; + roxmltree::Document::parse_with_options(text, options).ok() +} + +/// What a compared node is. +enum Kind<'a> { + Element { namespace: &'a str, name: &'a str }, + Text(&'a str), + Comment(&'a str), + Other, +} + +/// A tree, as the comparison walks it. +trait View<'a> { + type Node: Copy; + type Children: Iterator; + + fn kind(&self, node: Self::Node) -> Kind<'a>; + /// The element's ordinary attributes as (namespace, local name, value), + /// sorted, namespace declarations excluded. + fn attributes(&self, node: Self::Node) -> Vec<(&'a str, &'a str, &'a str)>; + fn children(&self, node: Self::Node) -> Self::Children; +} + +/// Whether an attribute is a namespace declaration rather than an ordinary +/// attribute. The HTML parser puts `xmlns` and `xmlns:xlink` in the XMLNS +/// namespace and leaves any other `xmlns:` prefixed name unnamespaced. +fn is_declaration(namespace: &str, name: &str) -> bool { + namespace == XMLNS_NAMESPACE + || (namespace.is_empty() && (name == "xmlns" || name.starts_with("xmlns:"))) +} + +/// scraper's tree, whose node references carry the tree. +struct Browser; + +impl<'a> View<'a> for Browser { + type Node = NodeRef<'a, Node>; + type Children = Children<'a, Node>; + + fn kind(&self, node: Self::Node) -> Kind<'a> { + match node.value() { + Node::Element(element) => Kind::Element { + namespace: &element.name.ns, + name: &element.name.local, + }, + Node::Text(text) => Kind::Text(&text.text), + Node::Comment(comment) => Kind::Comment(&comment.comment), + _ => Kind::Other, + } + } + + fn attributes(&self, node: Self::Node) -> Vec<(&'a str, &'a str, &'a str)> { + let Node::Element(element) = node.value() else { + return Vec::new(); + }; + let mut ordinary: Vec<(&'a str, &'a str, &'a str)> = element + .attrs + .iter() + .map(|(name, value)| (&*name.ns, &*name.local, &**value)) + .filter(|(namespace, name, _)| !is_declaration(namespace, name)) + .collect(); + ordinary.sort_unstable(); + ordinary + } + + fn children(&self, node: Self::Node) -> Self::Children { + node.children() + } +} + +/// A roxmltree document, whose nodes carry their own references. +struct Xml<'a, 'input: 'a>(PhantomData>); + +impl<'a, 'input: 'a> View<'a> for Xml<'a, 'input> { + type Node = roxmltree::Node<'a, 'input>; + type Children = roxmltree::Children<'a, 'input>; + + fn kind(&self, node: Self::Node) -> Kind<'a> { + match node.node_type() { + roxmltree::NodeType::Element => { + let name = node.tag_name(); + Kind::Element { + namespace: name.namespace().unwrap_or_default(), + name: name.name(), + } + } + roxmltree::NodeType::Text => Kind::Text(node.text().unwrap_or_default()), + roxmltree::NodeType::Comment => Kind::Comment(node.text().unwrap_or_default()), + roxmltree::NodeType::Root | roxmltree::NodeType::PI => Kind::Other, + } + } + + fn attributes(&self, node: Self::Node) -> Vec<(&'a str, &'a str, &'a str)> { + // roxmltree keeps namespace declarations apart from attributes. + let mut ordinary: Vec<_> = node + .attributes() + .map(|attribute| { + ( + attribute.namespace().unwrap_or_default(), + attribute.name(), + attribute.value(), + ) + }) + .collect(); + ordinary.sort_unstable(); + ordinary + } + + fn children(&self, node: Self::Node) -> Self::Children { + node.children() + } +} + +/// Whether the trees under `a_root` and `b_root` are equal, within the +/// depth bound. The walk keeps one pair of child iterators per open element +/// and copies neither tree. +fn equal<'a, 'b, A: View<'a>, B: View<'b>>(a: &A, a_root: A::Node, b: &B, b_root: B::Node) -> bool { + if !same(a, a_root, b, b_root) { + return false; + } + let mut open = vec![(a.children(a_root), b.children(b_root))]; + while let Some((mut a_children, mut b_children)) = open.pop() { + match (a_children.next(), b_children.next()) { + (None, None) => {} + (Some(a_node), Some(b_node)) => { + if !same(a, a_node, b, b_node) { + return false; + } + let element = matches!(a.kind(a_node), Kind::Element { .. }); + open.push((a_children, b_children)); + if element { + if open.len() >= MAX_DEPTH { + return false; + } + open.push((a.children(a_node), b.children(b_node))); + } + } + _ => return false, + } + } + true +} + +fn same<'a, 'b, A: View<'a>, B: View<'b>>(a: &A, a_node: A::Node, b: &B, b_node: B::Node) -> bool { + match (a.kind(a_node), b.kind(b_node)) { + ( + Kind::Element { + namespace: a_namespace, + name: a_name, + }, + Kind::Element { + namespace: b_namespace, + name: b_name, + }, + ) => { + a_namespace == b_namespace + && a_name == b_name + && a.attributes(a_node) == b.attributes(b_node) + } + (Kind::Text(a_text), Kind::Text(b_text)) + | (Kind::Comment(a_text), Kind::Comment(b_text)) => a_text == b_text, + _ => false, + } +} + +/// The tree under `root` written as a standalone SVG document, or `None` +/// where XML cannot carry it: an element outside the SVG namespace, an +/// attribute outside no namespace, `XLink` or XML, a name XML cannot spell, a +/// character XML forbids, a comment XML cannot hold, any other node, or +/// more than `max_bytes`. +/// +/// The root declares the SVG namespace, and the `XLink` prefix where an +/// attribute uses it. Every element and ordinary attribute is the tree's, +/// with its value, which is the HTML parser's: the first of attributes that +/// differ only in case, and character references decoded. Values escape +/// `&`, `<`, `>`, `"`, tab, line feed and carriage return, and text escapes +/// `&`, `<`, `>` and carriage return, so XML's attribute-value and line-end +/// normalization leaves them as parsed. +fn serialize(root: NodeRef<'_, Node>, max_bytes: usize) -> Option { + let Node::Element(element) = root.value() else { + return None; + }; + let mut output = SvgOutput::new(max_bytes); + let mut uses_xlink = false; + write_start(&mut output, element, &mut uses_xlink)?; + // The root's declarations follow its name: the XLink one is known only + // once every attribute is written. + let declarations = 1 + element.name.local.len(); + output.insert_str(declarations, SVG_DECLARATION)?; + let mut open: Vec<(Children<'_, Node>, &str)> = Vec::new(); + if root.first_child().is_some() { + output.push('>')?; + open.push((root.children(), &*element.name.local)); + } else { + output.push_str("/>")?; + } + while let Some((mut children, name)) = open.pop() { + let Some(child) = children.next() else { + output.push_str("')?; + continue; + }; + open.push((children, name)); + match child.value() { + Node::Element(element) => { + write_start(&mut output, element, &mut uses_xlink)?; + if child.first_child().is_some() { + if open.len() >= MAX_DEPTH { + return None; + } + output.push('>')?; + open.push((child.children(), &*element.name.local)); + } else { + output.push_str("/>")?; + } + } + Node::Text(text) => write_text(&mut output, &text.text)?, + Node::Comment(comment) => write_comment(&mut output, &comment.comment)?, + _ => return None, + } + } + if uses_xlink { + output.insert_str(declarations + SVG_DECLARATION.len(), XLINK_DECLARATION)?; + } + Some(output.text) +} + +/// A serializer buffer whose length and requested allocation never exceed +/// the output limit. Grow geometrically, but request the exact bounded target +/// so String's implicit doubling cannot allocate beyond it. +struct SvgOutput { + text: String, + max_bytes: usize, +} + +impl SvgOutput { + fn new(max_bytes: usize) -> Self { + Self { + text: String::new(), + max_bytes, + } + } + + fn reserve(&mut self, additional: usize) -> Option<()> { + let needed = self.text.len().checked_add(additional)?; + if needed > self.max_bytes { + return None; + } + if needed > self.text.capacity() { + let target = self + .text + .capacity() + .checked_mul(2) + .unwrap_or(self.max_bytes) + .max(64.min(self.max_bytes)) + .max(needed) + .min(self.max_bytes); + self.text + .try_reserve_exact(target.checked_sub(self.text.len())?) + .ok()?; + } + Some(()) + } + + fn push_str(&mut self, text: &str) -> Option<()> { + self.reserve(text.len())?; + self.text.push_str(text); + Some(()) + } + + fn push(&mut self, character: char) -> Option<()> { + self.reserve(character.len_utf8())?; + self.text.push(character); + Some(()) + } + + fn insert_str(&mut self, at: usize, text: &str) -> Option<()> { + self.reserve(text.len())?; + self.text.insert_str(at, text); + Some(()) + } +} + +/// Writes ` Option<()> { + if &*element.name.ns != SVG_NAMESPACE || !is_xml_local_name(&element.name.local) { + return None; + } + output.push('<')?; + output.push_str(&element.name.local)?; + for (name, value) in element.attrs.iter() { + let namespace = &*name.ns; + let local = &*name.local; + if is_declaration(namespace, local) { + continue; + } + let prefix = match namespace { + "" => "", + XLINK_NAMESPACE => { + *uses_xlink = true; + "xlink:" + } + XML_NAMESPACE => "xml:", + _ => return None, + }; + if !is_xml_local_name(local) { + return None; + } + output.push(' ')?; + output.push_str(prefix)?; + output.push_str(local)?; + output.push_str("=\"")?; + for character in value.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '"' => output.push_str("""), + '\t' => output.push_str(" "), + '\n' => output.push_str(" "), + '\r' => output.push_str(" "), + character if is_xml_char(character) => output.push(character), + _ => return None, + }?; + } + output.push('"')?; + } + Some(()) +} + +fn write_text(output: &mut SvgOutput, text: &str) -> Option<()> { + for character in text.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '\r' => output.push_str(" "), + character if is_xml_char(character) => output.push(character), + _ => return None, + }?; + } + Some(()) +} + +/// Writes a comment XML can hold: no `--` inside, no `-` at the end. +fn write_comment(output: &mut SvgOutput, text: &str) -> Option<()> { + if text.contains("--") || text.ends_with('-') || !text.chars().all(is_xml_char) { + return None; + } + output.push_str("")?; + Some(()) +} + +/// A character XML 1.0 allows in a document. +fn is_xml_char(character: char) -> bool { + matches!( + character, + '\t' | '\n' + | '\r' + | '\u{20}'..='\u{d7ff}' + | '\u{e000}'..='\u{fffd}' + | '\u{10000}'..='\u{10ffff}' + ) +} + +/// A name XML can write without a prefix. Conservative: a name it refuses +/// is never written, and anything this admits that XML reads otherwise +/// fails the equivalence check. +fn is_xml_local_name(name: &str) -> bool { + let mut characters = name.chars(); + characters + .next() + .is_some_and(|first| first.is_alphabetic() || first == '_') + && characters + .all(|character| character.is_alphanumeric() || matches!(character, '-' | '_' | '.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LIMIT: usize = 1024 * 1024; + + fn signed(fragment: &str) -> Result, Reason> { + signable_svg(fragment, LIMIT) + } + + /// The serialization `fragment` is signed as, checked to be a standalone + /// SVG document whose tree, read as XML and again as HTML, is the + /// browser's tree of `fragment`. + fn normalized(fragment: &str) -> String { + let Ok(Cow::Owned(serialized)) = signed(fragment) else { + panic!("should serialize {fragment}, got {:?}", signed(fragment)); + }; + let browser = parse_html(fragment).expect("should parse the original as HTML"); + let browser = browser.root().expect("should have an svg root"); + let reparsed = parse_html(&serialized).expect("should parse the serialization as HTML"); + let reparsed = reparsed.root().expect("should keep one svg root"); + let document = parse_xml(&serialized).expect("should be standalone XML"); + assert_eq!( + document.root_element().tag_name().namespace(), + Some(SVG_NAMESPACE), + "should declare the SVG namespace" + ); + assert!( + equal(&Browser, browser, &Browser, reparsed), + "should read back as the browser's tree: {serialized}" + ); + assert!( + equal( + &Browser, + browser, + &Xml(PhantomData), + document.root_element() + ), + "should read as XML to the browser's tree: {serialized}" + ); + serialized + } + + fn assert_unchanged(fragment: &str) { + assert!( + matches!(signed(fragment), Ok(Cow::Borrowed(kept)) if kept == fragment), + "should sign {fragment} as written, got {:?}", + signed(fragment) + ); + } + + /// The first SVG element named `name` in `serialized`, read as XML. + fn element<'a, 'input>( + document: &'a roxmltree::Document<'input>, + name: &str, + ) -> roxmltree::Node<'a, 'input> { + document + .descendants() + .find(|node| node.has_tag_name((SVG_NAMESPACE, name))) + .unwrap_or_else(|| panic!("should hold a {name} element")) + } + + #[test] + fn the_first_of_two_case_variant_attributes_is_the_one_signed() { + // The captured logo: the browser keeps `viewbox`'s value as + // `viewBox` and drops the later `viewBox`. + let serialized = normalized( + "\n \n", + ); + let document = parse_xml(&serialized).expect("should be XML"); + let root = document.root_element(); + assert_eq!(root.attribute("viewBox"), Some("0 0 300 300.251")); + assert_eq!(root.attribute("viewbox"), None); + assert_eq!(root.attribute("version"), Some("1.1")); + assert_eq!(element(&document, "path").attribute("d"), Some("M0 0h1")); + } + + #[test] + fn an_svg_without_its_namespace_is_signed_in_it() { + let serialized = normalized( + "", + ); + let document = parse_xml(&serialized).expect("should be XML"); + let root = document.root_element(); + assert_eq!(root.attribute("fill"), Some("currentColor")); + assert_eq!(root.attribute("aria-hidden"), Some("true")); + assert_eq!( + element(&document, "path").attribute("d"), + Some("M18.244 2.25h3.308") + ); + } + + #[test] + fn a_standalone_svg_is_signed_as_written_whatever_its_declarations() { + assert_unchanged( + "", + ); + assert_unchanged( + "", + ); + assert_unchanged( + "", + ); + assert_unchanged( + "&’&", + ); + } + + #[test] + fn an_xlink_attribute_is_signed_in_the_xlink_namespace() { + for fragment in [ + "", + // A wrong binding is a declaration the browser ignores. + "", + ] { + let serialized = normalized(fragment); + let document = parse_xml(&serialized).expect("should be XML"); + assert_eq!( + element(&document, "use").attribute((XLINK_NAMESPACE, "href")), + Some("#a"), + "for {fragment}" + ); + } + assert!( + !normalized("").contains("xmlns:xlink"), + "should declare XLink only where it is used" + ); + } + + #[test] + fn wrapped_attribute_whitespace_keeps_its_parsed_value() { + let serialized = normalized( + "", + ); + let document = parse_xml(&serialized).expect("should be XML"); + assert_eq!( + element(&document, "path").attribute("d"), + Some("M0 0\n L1 1\tZ") + ); + } + + #[test] + fn html_character_references_are_signed_as_their_characters() { + let serialized = normalized( + "A &<B’", + ); + let document = parse_xml(&serialized).expect("should be XML"); + assert_eq!(element(&document, "title").text(), Some("A\u{a0}&a�b"); + let document = parse_xml(&serialized).expect("should be XML"); + assert_eq!(element(&document, "text").text(), Some("a\u{fffd}b")); + } + + #[test] + fn cdata_is_signed_as_its_text() { + let serialized = normalized(""); + let document = parse_xml(&serialized).expect("should be XML"); + assert_eq!(element(&document, "style").text(), Some("a&e;"); + assert!(!serialized.contains("DOCTYPE"), "should carry no doctype"); + let document = parse_xml(&serialized).expect("should be XML"); + assert_eq!(element(&document, "text").text(), Some("&e;")); + } + + #[test] + fn markup_that_breaks_out_of_the_svg_is_refused() { + for fragment in [ + "

x

", + "
x
", + "
x
", + "x", + ] { + assert_eq!( + signed(fragment), + Err(Reason::InlineSvgNotEquivalent), + "should refuse {fragment}" + ); + } + } + + #[test] + fn what_xml_cannot_carry_is_refused() { + for fragment in [ + "", + "", + "", + "￾", + "", + "", + ] { + assert_eq!( + signed(fragment), + Err(Reason::InlineSvgNotEquivalent), + "should refuse {fragment}" + ); + } + } + + #[test] + fn provenance_the_svg_carries_is_never_rewritten() { + for fragment in [ + // The writer's carrier, without and with the SVG namespace. + "QUJD", + "QUJD", + // An unclosed carrier element. + "QUJD", + // A C2PA namespace declared for anything. + "", + // An XMP provenance reference, which need not name C2PA. + "", + "https://example.com/m", + ] { + assert_eq!( + signed(fragment), + Err(Reason::SvgProvenanceNotEquivalent), + "should leave {fragment} as written" + ); + } + } + + #[test] + fn a_caption_that_mentions_c2pa_is_not_provenance() { + let serialized = normalized( + "Signed with C2PAc2pa", + ); + let document = parse_xml(&serialized).expect("should be XML"); + assert_eq!(element(&document, "title").text(), Some("Signed with C2PA")); + } + + #[test] + fn serializer_checks_root_attributes_text_and_namespace_growth_before_writing() { + let fragment = ">&"; + let parsed = parse_html(fragment).expect("should parse"); + let root = parsed.root().expect("should have a root"); + let serialized = serialize(root, LIMIT).expect("should serialize"); + assert!(serialized.contains(SVG_DECLARATION)); + assert!(serialized.contains(XLINK_DECLARATION)); + for limit in 0..serialized.len() { + assert!( + serialize(root, limit).is_none(), + "must refuse limit {limit}" + ); + } + assert_eq!(serialize(root, serialized.len()), Some(serialized)); + + let fragment = format!("", "\"".repeat(1000)); + let parsed = parse_html(&fragment).expect("should parse a large root attribute"); + let Node::Element(element) = parsed.root().expect("should have a root").value() else { + panic!("should be an element"); + }; + let mut output = SvgOutput::new(127); + assert!(write_start(&mut output, element, &mut false).is_none()); + assert!(output.text.len() <= 127); + assert!(output.text.capacity() <= 127); + assert_eq!( + signable_svg(&fragment, 1100), + Err(Reason::InlineSvgNotEquivalent) + ); + + let fragment = format!("{}", ">".repeat(1000)); + assert_eq!( + signable_svg(&fragment, 1100), + Err(Reason::InlineSvgNotEquivalent) + ); + } + + #[test] + fn serializer_buffer_growth_is_bounded_amortized_and_not_the_media_limit() { + let mut output = SvgOutput::new(5_000_000); + assert_eq!(output.text.capacity(), 0); + output.push_str("", ">"), + ("\r", " "), + ] { + for limit in 0..escaped.len() { + let mut output = SvgOutput::new(limit); + assert!(write_text(&mut output, text).is_none()); + assert!(output.text.len() <= limit); + assert!(output.text.capacity() <= limit); + } + let mut output = SvgOutput::new(escaped.len()); + write_text(&mut output, text).expect("should fit the complete escape"); + assert_eq!(output.text, escaped); + } + for limit in 0..10 { + let mut output = SvgOutput::new(limit); + assert!(write_comment(&mut output, "art").is_none()); + assert!(output.text.len() <= limit); + assert!(output.text.capacity() <= limit); + } + let mut output = SvgOutput::new(10); + write_comment(&mut output, "art").expect("should fit the complete comment"); + assert_eq!(output.text, ""); + } + + #[test] + fn the_size_node_and_depth_bounds_refuse() { + let fragment = ""; + assert_eq!( + signable_svg(fragment, fragment.len() - 1), + Err(Reason::MediaTooLarge) + ); + let wide = format!( + "{}", + "".repeat(MAX_NODES) + ); + assert_eq!(signed(&wide), Err(Reason::InlineSvgNotEquivalent)); + let deep = format!( + "{}{}", + "".repeat(MAX_DEPTH), + "".repeat(MAX_DEPTH) + ); + assert_eq!(signed(&deep), Err(Reason::InlineSvgNotEquivalent)); + let nested = format!( + "{}{}", + "".repeat(MAX_DEPTH - 2), + "".repeat(MAX_DEPTH - 2) + ); + assert_unchanged(&nested); + } +} diff --git a/crates/provenance/encypher/src/tests.rs b/crates/provenance/encypher/src/tests.rs index 6740aedf93..bd2ddb588d 100644 --- a/crates/provenance/encypher/src/tests.rs +++ b/crates/provenance/encypher/src/tests.rs @@ -2,6 +2,8 @@ use super::*; use std::sync::Arc; +use base64::Engine as _; + use edgezero_core::body::Body as EdgeBody; use edgezero_core::key_value_store::KvPage; use futures::executor::block_on; @@ -87,13 +89,36 @@ fn an_unselected_module_registers_nothing() { } #[test] -fn a_selected_and_placed_module_registers_its_middleware() { +fn a_selected_and_placed_module_registers_its_middleware_and_media_signer() { let settings = placed(); assert!(validate(&settings).expect("should validate")); + let registration = register(&settings) + .expect("should register") + .expect("a selected module with a worker token should register"); + let names: Vec<&str> = registration + .middleware + .iter() + .map(|middleware| middleware.middleware_id()) + .collect(); + assert_eq!( + names, + [MODULE, STYLESHEET_MIDDLEWARE], + "should supply the page and stylesheet middleware" + ); + assert_eq!( + registration + .media_signer + .as_ref() + .map(|signer| signer.signer_id()), + Some(MODULE), + "should register the media signer alongside the text path" + ); + let stylesheet = ®istration.middleware[1]; + assert!(stylesheet.handles_media_type("text/css")); assert!( - register(&settings).expect("should register").is_some(), - "a selected module with a worker token should register" + stylesheet.handles_path("/assets/site.css"), + "the stylesheet path should not inherit the page path's asset exclusions" ); } @@ -138,10 +163,6 @@ fn required_settings_and_bounds_are_enforced() { "config without {field} should fail" ); } - let defaults = parsed(&config_value()).expect("should parse defaults"); - assert_eq!(defaults.policy_ttl_seconds, 300); - assert_eq!(defaults.plan_ttl_seconds, 604_800); - assert_eq!(defaults.max_hold_ms, 5_000); for (field, value) in [ ("api_base_url", json!("http://api.example.com")), ("api_base_url", json!("https://api.example.com/?q=1")), @@ -155,6 +176,17 @@ fn required_settings_and_bounds_are_enforced() { ("negative_ttl_seconds", json!(10)), ("policy_ttl_seconds", json!(301)), ("article_selector", json!("a".repeat(92))), + ("max_media_bytes", json!(100)), + ("max_media_bytes", json!(8_000_001)), + ("max_media_sign_calls_per_document", json!(0)), + ("max_media_sign_calls_per_document", json!(129)), + ("max_large_media_bytes", json!(1)), + ( + "max_large_media_bytes", + json!(MAX_PREPARED_SOURCE_BYTES + 1), + ), + ("max_large_media_ms", json!(4_999)), + ("max_large_media_ms", json!(600_001)), ("enabled", json!(true)), ("path_prefixes", json!(["/news/"])), ] { @@ -165,6 +197,20 @@ fn required_settings_and_bounds_are_enforced() { "{field} = {value} should be rejected" ); } + for value in [0, MIN_LARGE_MEDIA_BYTES, MAX_PREPARED_SOURCE_BYTES] { + let mut config = config_value(); + config["max_large_media_bytes"] = json!(value); + assert!( + parsed(&config).is_ok(), + "max_large_media_bytes = {value} should be accepted, 0 turning the lane off" + ); + } + for limit in [1, 64, 128] { + let mut config = config_value(); + config["max_media_sign_calls_per_document"] = json!(limit); + let accepted = parsed(&config).expect("bounded media-call limit"); + assert_eq!(accepted.max_media_sign_calls_per_document, limit); + } for (max_hold_ms, timeout_ms, valid) in [ (499, 250, false), (500, 250, true), @@ -184,6 +230,157 @@ fn required_settings_and_bounds_are_enforced() { } } +#[test] +fn article_selector_rejects_malformed_classes_at_settings_admission() { + for selector in [ + ".", + "..article", + ".123article", + ".article.body", + ".article body", + ".article>p", + ".article,main", + ".article#body", + ".article:hover", + ".article[data-body=yes]", + ".article\\:body", + ] { + let table = format!("{}article_selector = {}\n", table(), json!(selector)); + let settings = page_settings(&module_settings(&table, true)); + assert!( + validate(&settings).is_err(), + "malformed class selector {selector:?} should fail settings admission" + ); + assert!( + register(&settings).is_err(), + "malformed class selector {selector:?} should not register" + ); + } +} + +#[test] +fn article_selector_rejects_malformed_attributes_at_settings_admission() { + for selector in [ + "[", + "[itemprop]", + "[=articleBody]", + "[itemprop=]", + "[itemprop=articleBody", + "[itemprop=articleBody]p", + "[itemprop~=articleBody]", + "[itemprop=articleBody main]", + "[itemprop=articleBody=main]", + "[itemprop=[articleBody]]", + "[itemprop=\"articleBody']", + "[itemprop='articleBody' i]", + "[itemprop=articleBody][role=main]", + "[itemprop=articleBody],main", + "[itemprop='article\\Body']", + ] { + let table = format!("{}article_selector = {}\n", table(), json!(selector)); + let settings = page_settings(&module_settings(&table, true)); + assert!( + validate(&settings).is_err(), + "malformed attribute selector {selector:?} should fail settings admission" + ); + assert!( + register(&settings).is_err(), + "malformed attribute selector {selector:?} should not register" + ); + } +} + +#[test] +fn article_selector_rejects_malformed_tags_at_settings_admission() { + for selector in [ + " ", + " article", + "article ", + "123article", + "
", + "article.body", + "article p", + "article>p", + "article+p", + "article~p", + "article,main", + "article#body", + "article:hover", + "article[role=main]", + "#article", + "*", + ] { + let table = format!("{}article_selector = {}\n", table(), json!(selector)); + let settings = page_settings(&module_settings(&table, true)); + assert!( + validate(&settings).is_err(), + "malformed tag selector {selector:?} should fail settings admission" + ); + assert!( + register(&settings).is_err(), + "malformed tag selector {selector:?} should not register" + ); + } +} + +#[test] +fn article_selector_accepts_supported_syntax_and_selects_the_override() { + let selected = "Selected."; + let html = format!("
Fallback.
{selected}"); + for selector in [ + ".story-body", + "._story", + ".-story", + ".--story", + "[itemprop=articleBody]", + "[itemprop='articleBody']", + "[itemprop=\"articleBody\"]", + "[data-body='article body']", + "custom-story", + "CUSTOM-STORY", + ] { + let table = format!("{}article_selector = {}\n", table(), json!(selector)); + let settings = page_settings(&module_settings(&table, true)); + assert!( + validate(&settings).expect("should admit supported selector"), + "supported selector {selector:?} should select the module" + ); + assert!( + register(&settings) + .expect("should register supported selector") + .is_some(), + "supported selector {selector:?} should register" + ); + let boundary = boundary::find_article_boundary(&html, Some(selector)) + .expect("should find the configured override"); + assert_eq!( + boundary.selector, + format!("override:{selector}"), + "supported selector should take priority over the article fallback" + ); + assert_eq!(html.get(boundary.start..boundary.end), Some(selected)); + } +} + +#[test] +fn article_selector_empty_or_valid_but_absent_keeps_article_fallback() { + let selected = "

Fallback article.

"; + let html = format!("
Other region.
{selected}"); + for selector in ["", ".absent-story", "[data-body=absent]", "custom-story"] { + let table = format!("{}article_selector = {}\n", table(), json!(selector)); + let settings = page_settings(&module_settings(&table, true)); + assert!( + validate(&settings).expect("should admit empty or absent selector"), + "selector {selector:?} should select the module" + ); + let boundary = boundary::find_article_boundary(&html, Some(selector)) + .expect("should find the fallback article"); + assert_eq!(boundary.selector, "article"); + assert_eq!(html.get(boundary.start..boundary.end), Some(selected)); + } +} + #[test] fn excluded_paths_leave_the_middleware_out_of_the_chain() { let middleware = Encypher { @@ -312,8 +509,20 @@ fn served_response( request: Request, streaming: bool, ) -> http::Response { + try_served_response(settings, services, request, streaming).expect("should finish the page") +} + +fn try_served_response( + settings: &Settings, + services: &RuntimeServices, + request: Request, + streaming: bool, +) -> Result, error_stack::Report> { let registry = IntegrationRegistry::with_registrations(settings, &[builder()]) .expect("should build the registry"); + // As every adapter does, the request's services carry the registry's + // media signer. + let services = &services.clone().with_media_signer(registry.media_signer()); let plan = compile_auction_plan(settings).expect("should compile the auction plan"); let orchestrator = Arc::new(AuctionOrchestrator::from_plan(Arc::new(plan), None)); let method = request.method().clone(); @@ -338,8 +547,7 @@ fn served_response( request, EdgeCacheHeader::SurrogateControl, ) - .await - .expect("should fetch the page"); + .await?; if streaming { publisher_response_into_streaming_response( publisher, @@ -361,7 +569,6 @@ fn served_response( ) .await } - .expect("should finish the page") }) } @@ -427,6 +634,35 @@ fn the_publisher_path_signs_an_article_through_both_finalizers() { } } +#[test] +fn an_article_on_a_publisher_addressed_by_path_is_signed_for_its_own_address() { + let stub = stub("public, max-age=60"); + let mut request = page_request(Method::GET); + request + .extensions_mut() + .insert(trusted_server_core::addressing::RemovedPathPrefix::new( + "/www.site.example".to_owned(), + )); + + let page = served(&placed(), &services_for(&stub), request, false); + + assert!(page.contains(MARKER), "should sign the article: {page}"); + let policy_uri = &stub.recorded_request_uris()[1]; + assert!( + policy_uri.ends_with( + "page_url=http%3A%2F%2Fpublisher.example.com%2Fwww.site.example%2Fnews%2Fharbor-vote" + ), + "should ask the policy for the address the reader asked for: {policy_uri}" + ); + let sign = String::from_utf8(stub.recorded_request_bodies()[2].clone()).expect("JSON body"); + assert!( + sign.contains( + "\"page_url\":\"http://publisher.example.com/www.site.example/news/harbor-vote\"" + ), + "should sign the article for the address the reader asked for: {sign}" + ); +} + /// The unsigned bytes produced by the same public publisher pipeline before /// its final writer applies the configured bound. Core middleware may rewrite /// the origin body before Encypher sees it, so the origin fixture length is not @@ -521,3 +757,961 @@ fn a_page_the_origin_marked_private_is_never_sent() { "should call the origin and nothing else" ); } + +/// The pixels of a small image an origin embeds or serves. +const SOURCE_IMAGE: &[u8] = b"\x89PNG\r\n\x1a\n-source-pixels"; +/// The same image with an embedded C2PA manifest, as the media endpoint +/// answers it. +const SIGNED_IMAGE: &[u8] = b"\x89PNG\r\n\x1a\n-source-pixels-caBX-jumb-c2pa-manifest"; + +fn encoded(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +/// The headers of an embedded-manifest answer of the media endpoint for +/// `body` of `content_type`. +fn media_answer_as(body: &[u8], content_type: &str) -> Vec<(String, String)> { + [ + ("content-type", content_type.to_owned()), + ("x-encypher-policy-digest", POLICY_DIGEST.to_owned()), + ("x-encypher-signed-sha256", media_api::sha256_hex(body)), + ("x-encypher-manifest-instance-id", "xmp:iid:0001".to_owned()), + ("x-encypher-manifest-disposition", "embedded".to_owned()), + ("x-encypher-manifest-link-required", "false".to_owned()), + ( + "x-encypher-verification-url", + "https://verify.example.com/doc-2".to_owned(), + ), + ("x-encypher-document-id", "doc-2".to_owned()), + ] + .into_iter() + .map(|(name, value)| (name.to_owned(), value)) + .collect() +} + +/// [`ARTICLE`] with a lazily loaded embedded image inside the article. +fn article_with_image() -> String { + ARTICLE.replace( + "
", + &format!( + "\"Harbor\"
", + encoded(SOURCE_IMAGE) + ), + ) +} + +/// An origin answering `body` as `content_type`, publicly cacheable. +fn origin_answer(stub: &StubHttpClient, body: &[u8], content_type: &str) { + stub.push_response_with_headers( + 200, + body.to_vec(), + vec![ + ("content-type", content_type), + ("cache-control", "public, max-age=60"), + ], + ); +} + +/// The policy answer the media lane asks for live, before it signs or reuses +/// embedded media. +fn live_policy(stub: &StubHttpClient) { + stub.push_response( + 200, + serde_json::to_vec(&json!({ "policy_digest": POLICY_DIGEST })).expect("should encode"), + ); +} + +#[test] +fn a_revoked_credential_stops_embedded_media_reuse_while_the_cached_policy_lives() { + let kv = Arc::new(MemoryKv::default()); + let first = Arc::new(StubHttpClient::new()); + origin_answer( + &first, + article_with_image().as_bytes(), + "text/html; charset=utf-8", + ); + text_answers(&first); + live_policy(&first); + first.push_response_with_headers(200, SIGNED_IMAGE.to_vec(), media_answer(SIGNED_IMAGE)); + let warmed = served( + &placed(), + &services_for_kv(&first, Arc::clone(&kv)), + page_request(Method::GET), + false, + ); + assert!( + warmed.contains(&encoded(SIGNED_IMAGE)), + "should warm the policy and media caches" + ); + + // The cached policy and the cached signed image are both still fresh; + // the service now refuses the credential. + let revoked = Arc::new(StubHttpClient::new()); + origin_answer( + &revoked, + article_with_image().as_bytes(), + "text/html; charset=utf-8", + ); + revoked.push_response(403, b"{}".to_vec()); + let page = served( + &placed(), + &services_for_kv(&revoked, kv), + page_request(Method::GET), + false, + ); + + assert!( + !page.contains(&encoded(SIGNED_IMAGE)), + "should never reuse a signed image under a revoked credential" + ); + assert!( + page.contains(&encoded(SOURCE_IMAGE)), + "should deliver the image as the origin wrote it" + ); + assert!( + !revoked + .recorded_request_methods() + .iter() + .any(|method| method == "POST"), + "should sign nothing" + ); +} + +/// The policy and article-sign answers [`stub`] queues after the origin. +fn text_answers(stub: &StubHttpClient) { + stub.push_response( + 200, + serde_json::to_vec(&json!({ "policy_digest": POLICY_DIGEST })).expect("should encode"), + ); + stub.push_response( + 200, + serde_json::to_vec(&json!({ + "success": true, + "policy_digest": POLICY_DIGEST, + "embedding_plan": { + "index_unit": "codepoint", + "operations": [{ "insert_after_index": 5, "marker": MARKER }], + }, + "manifest_url": MANIFEST_URL, + })) + .expect("should encode"), + ); +} + +#[test] +fn the_publisher_path_signs_the_article_then_its_embedded_image() { + for streaming in [false, true] { + let stub = Arc::new(StubHttpClient::new()); + origin_answer( + &stub, + article_with_image().as_bytes(), + "text/html; charset=utf-8", + ); + text_answers(&stub); + live_policy(&stub); + stub.push_response_with_headers(200, SIGNED_IMAGE.to_vec(), media_answer(SIGNED_IMAGE)); + + let page = served( + &placed(), + &services_for(&stub), + page_request(Method::GET), + streaming, + ); + + assert!( + page.contains(MARKER), + "streaming={streaming}: should sign the text" + ); + assert!( + page.contains(&format!( + "src=\"data:image/png;base64,{}\" alt=\"Harbor\"", + encoded(SIGNED_IMAGE) + )), + "streaming={streaming}: should deliver the signed image bytes in place: {page}" + ); + assert!( + !page.contains(&encoded(SOURCE_IMAGE)), + "streaming={streaming}: should leave no unsigned copy" + ); + assert_eq!( + stub.recorded_request_methods(), + ["GET", "GET", "POST", "GET", "POST"], + "streaming={streaming}: origin, policy, text sign, live policy, media sign" + ); + assert!( + stub.recorded_request_uris()[4].ends_with("/api/v1/public/cdn/media/sign"), + "streaming={streaming}: should call the media endpoint" + ); + let media_body = &stub.recorded_request_bodies()[4]; + assert!( + media_body + .windows(SOURCE_IMAGE.len()) + .any(|window| window == SOURCE_IMAGE), + "streaming={streaming}: should send the decoded source bytes" + ); + } +} + +#[test] +fn a_refused_embedded_image_stays_as_written_and_the_text_is_still_signed() { + let stub = Arc::new(StubHttpClient::new()); + origin_answer( + &stub, + article_with_image().as_bytes(), + "text/html; charset=utf-8", + ); + text_answers(&stub); + live_policy(&stub); + stub.push_response( + 422, + serde_json::to_vec(&json!({ + "success": false, + "error": {"code": "UNSUPPORTED_MEDIA_TYPE", "message": "x"}, + })) + .expect("should encode"), + ); + + let page = served( + &placed(), + &services_for(&stub), + page_request(Method::GET), + false, + ); + + assert!(page.contains(MARKER), "should still sign the text"); + assert!( + page.contains(&encoded(SOURCE_IMAGE)), + "should leave the unsigned image as the origin wrote it" + ); + assert!( + !page.contains(&encoded(SIGNED_IMAGE)), + "should never present unsigned bytes as signed" + ); +} + +/// The module's settings with a stylesheet entry, scoped by `path` when one +/// is given. +fn stylesheet_settings(path: Option<&str>) -> String { + let path = path.map_or(String::new(), |path| format!("path = \"{path}\"\n")); + format!( + "{}\n[[serve]]\nmedia_type = \"text/css\"\n{path}middleware = [\"{STYLESHEET_MIDDLEWARE}\"]\n", + module_settings(&table(), true) + ) +} + +#[test] +fn a_stylesheet_entry_for_every_path_is_refused() { + for path in [None, Some("/")] { + let settings = page_settings(&stylesheet_settings(path)); + let error = validate(&settings).expect_err("should refuse an unscoped entry"); + assert!( + format!("{error:?}").contains("integrity"), + "{path:?}: should explain the integrity risk: {error:?}" + ); + } + let scoped = page_settings(&stylesheet_settings(Some("/assets/"))); + assert!(validate(&scoped).expect("should accept a scoped entry")); +} + +#[test] +fn the_publisher_path_signs_media_a_stylesheet_embeds() { + let settings = page_settings(&stylesheet_settings(Some("/assets/"))); + let stylesheet = format!( + ".hero{{background-image:url(\"data:image/png;base64,{}\")}}", + encoded(SOURCE_IMAGE) + ); + let stub = Arc::new(StubHttpClient::new()); + origin_answer(&stub, stylesheet.as_bytes(), "text/css; charset=utf-8"); + stub.push_response( + 200, + serde_json::to_vec(&json!({ "policy_digest": POLICY_DIGEST })).expect("should encode"), + ); + live_policy(&stub); + stub.push_response_with_headers(200, SIGNED_IMAGE.to_vec(), media_answer(SIGNED_IMAGE)); + let request = Request::builder() + .method(Method::GET) + .uri("https://publisher.example.com/assets/site.css") + .header(header::HOST, "publisher.example.com") + .body(EdgeBody::empty()) + .expect("should build the stylesheet request"); + + let served_stylesheet = served(&settings, &services_for(&stub), request, false); + + assert_eq!( + served_stylesheet, + format!( + ".hero{{background-image:url(\"data:image/png;base64,{}\")}}", + encoded(SIGNED_IMAGE) + ), + "should replace the embedded image with its signed bytes and nothing else" + ); +} + +/// The headers of an embedded-manifest PNG answer for `body`. +fn media_answer(body: &[u8]) -> Vec<(String, String)> { + media_answer_as(body, "image/png") +} + +fn media_request(path: &str) -> Request { + Request::builder() + .method(Method::GET) + .uri(format!("https://publisher.example.com{path}")) + .header(header::HOST, "publisher.example.com") + .header(header::ACCEPT, "*/*") + .body(EdgeBody::empty()) + .expect("should build the media request") +} + +fn body_of(response: http::Response) -> bytes::Bytes { + block_on(response.into_body().into_bytes_bounded(16 * 1024 * 1024)) + .expect("should read the body") +} + +#[test] +fn the_publisher_path_delivers_the_signed_bytes_of_images_audio_and_video_on_any_path() { + for (path, content_type) in [ + ("/images/harbor.png", "image/png"), + ("/media/4711", "image/png"), + ("/video/ferry.mp4", "video/mp4"), + ("/audio/council.mp3", "audio/mpeg"), + ] { + let stub = Arc::new(StubHttpClient::new()); + origin_answer(&stub, SOURCE_IMAGE, content_type); + stub.push_response( + 200, + serde_json::to_vec(&json!({ "policy_digest": POLICY_DIGEST })).expect("should encode"), + ); + stub.push_response_with_headers( + 200, + SIGNED_IMAGE.to_vec(), + media_answer_as(SIGNED_IMAGE, content_type), + ); + + let response = served_response(&placed(), &services_for(&stub), media_request(path), false); + + assert_eq!(response.status(), 200, "{path}: should answer"); + let headers = response.headers().clone(); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some(content_type), + "{path}: should keep the media type" + ); + assert_eq!( + headers + .get("x-encypher-document-id") + .and_then(|value| value.to_str().ok()), + Some("doc-2"), + "{path}: should name the signed document" + ); + assert_eq!( + headers + .get(header::ETAG) + .and_then(|value| value.to_str().ok()), + Some(format!("\"sig-{}\"", media_api::sha256_hex(SIGNED_IMAGE)).as_str()), + "{path}: should validate the signed bytes, not the origin's" + ); + assert_eq!( + body_of(response).as_ref(), + SIGNED_IMAGE, + "{path}: should deliver the exact signed bytes" + ); + assert_eq!( + stub.recorded_request_methods(), + ["GET", "GET", "POST"], + "{path}: origin, policy, media sign" + ); + let metadata = &stub.recorded_request_bodies()[2]; + assert!( + metadata + .windows(SOURCE_IMAGE.len()) + .any(|window| window == SOURCE_IMAGE), + "{path}: should send the captured origin bytes" + ); + assert!( + metadata + .windows(b"\"origin_host\":\"".len()) + .any(|window| window == b"\"origin_host\":\""), + "{path}: should name the host core fetched from" + ); + } +} + +#[test] +fn a_failed_media_signature_delivers_the_origin_bytes_unmarked() { + let stub = Arc::new(StubHttpClient::new()); + origin_answer(&stub, SOURCE_IMAGE, "image/png"); + stub.push_response( + 200, + serde_json::to_vec(&json!({ "policy_digest": POLICY_DIGEST })).expect("should encode"), + ); + // An answer whose bytes are not the ones it names is never delivered. + stub.push_response_with_headers( + 200, + b"\x89PNG\r\n\x1a\nswapped-c2pa".to_vec(), + media_answer(SIGNED_IMAGE), + ); + + let response = served_response( + &placed(), + &services_for(&stub), + media_request("/images/harbor.png"), + false, + ); + + assert!( + response.headers().get("x-encypher-document-id").is_none(), + "should claim no signature" + ); + assert_eq!( + body_of(response).as_ref(), + SOURCE_IMAGE, + "should deliver the origin's bytes as they were" + ); +} + +#[test] +fn a_media_segment_passes_through_as_the_origin_sent_it() { + // A C2PA verifiable-segment-info segment is bound by its stream's init + // manifest. Signing it alone would fail, and the unsigned fallback would + // make it private and drop its validators. + const SEGMENT: &[u8] = b"\0\0\0\x18stypmsdh\0\0\0\0msdhmsixmoof-mdat-emsg"; + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + SEGMENT.to_vec(), + vec![ + ("content-type", "video/iso.segment"), + ("cache-control", "public, max-age=60"), + ("etag", "\"segment-7\""), + ], + ); + + let response = served_response( + &placed(), + &services_for(&stub), + media_request("/live/ferry/segment-7.m4s"), + false, + ); + + assert_eq!(response.status(), 200, "should answer"); + let headers = response.headers().clone(); + assert_eq!( + headers + .get(header::ETAG) + .and_then(|value| value.to_str().ok()), + Some("\"segment-7\""), + "should keep the origin's validator" + ); + assert!( + headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| !value.contains("private")), + "should not make the segment private" + ); + assert!( + headers.get("x-encypher-document-id").is_none(), + "should claim no signature" + ); + assert_eq!( + body_of(response).as_ref(), + SEGMENT, + "should deliver the segment byte for byte" + ); + assert_eq!( + stub.recorded_request_methods(), + ["GET"], + "should ask neither the policy nor the signing endpoint" + ); +} + +/// One ISO BMFF box of `kind` around `body`. +fn bmff_box(kind: &[u8; 4], body: &[u8]) -> Vec { + let size = u32::try_from(body.len() + 8).expect("should fit a 32-bit size"); + [&size.to_be_bytes()[..], kind, body].concat() +} + +#[test] +fn fragmented_stream_parts_typed_as_mp4_pass_through_as_the_origin_sent_them() { + let init = [ + bmff_box(b"ftyp", b"cmfc\0\0\0\0iso6cmfc"), + bmff_box( + b"moov", + &[ + bmff_box(b"mvhd", &[0; 100]), + bmff_box(b"mvex", &bmff_box(b"trex", &[0; 24])), + bmff_box(b"pssh", &[0; 32]), + ] + .concat(), + ), + ] + .concat(); + let segment = [ + bmff_box(b"styp", b"msdh\0\0\0\0msdhmsix"), + bmff_box(b"emsg", b"\0\0\0\0urn:c2pa:verifiable-segment-info\0fseg\0"), + bmff_box(b"sidx", &[0; 32]), + bmff_box(b"moof", &bmff_box(b"mfhd", &[0; 8])), + bmff_box(b"mdat", b"coded-samples"), + ] + .concat(); + for (path, part) in [ + ("/live/ferry/init.mp4", init), + ("/live/ferry/7.m4s", segment), + ] { + // No `no-transform`: the bytes alone keep the part from being signed. + let stub = Arc::new(StubHttpClient::new()); + stub.push_response_with_headers( + 200, + part.clone(), + vec![ + ("content-type", "video/mp4"), + ("cache-control", "public, max-age=60"), + ("etag", "\"part-7\""), + ], + ); + + let response = served_response(&placed(), &services_for(&stub), media_request(path), false); + + assert_eq!(response.status(), 200, "{path}: should answer"); + let headers = response.headers().clone(); + assert_eq!( + headers + .get(header::ETAG) + .and_then(|value| value.to_str().ok()), + Some("\"part-7\""), + "{path}: should keep the origin's validator" + ); + assert!( + headers + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| !value.contains("private")), + "{path}: should not make the part private" + ); + assert!( + headers.get(header::LINK).is_none() && headers.get("x-encypher-document-id").is_none(), + "{path}: should add no manifest" + ); + assert_eq!( + body_of(response).as_ref(), + part.as_slice(), + "{path}: should deliver the part byte for byte" + ); + assert_eq!( + stub.recorded_request_methods(), + ["GET"], + "{path}: should ask neither the policy nor the signing endpoint" + ); + } +} + +#[test] +fn a_progressive_mp4_is_still_handed_to_the_signing_endpoint() { + let progressive = [ + bmff_box(b"ftyp", b"isom\0\0\0\0isomavc1"), + bmff_box(b"moov", &bmff_box(b"trak", &[0; 16])), + bmff_box(b"mdat", b"coded-samples"), + ] + .concat(); + let stub = Arc::new(StubHttpClient::new()); + origin_answer(&stub, &progressive, "video/mp4"); + live_policy(&stub); + stub.push_response_with_headers( + 200, + SIGNED_IMAGE.to_vec(), + media_answer_as(SIGNED_IMAGE, "video/mp4"), + ); + + let _response = served_response( + &placed(), + &services_for(&stub), + media_request("/video/ferry.mp4"), + false, + ); + + assert_eq!( + stub.recorded_request_methods(), + ["GET", "GET", "POST"], + "origin, policy, media sign" + ); + let sent = &stub.recorded_request_bodies()[2]; + assert!( + sent.windows(progressive.len()) + .any(|window| window == progressive.as_slice()), + "should send the progressive file's exact bytes" + ); +} + +/// Signed source bytes deliberately contain targets of core's attribute and +/// text URL replacement before the provenance carrier/reference appears. +fn protected_svg(reference: bool) -> String { + let provenance = if reference { + "" + } else { + "QUJD" + }; + format!( + "\ + \ + https://origin.example.com/\ + \ + \ + {}{provenance}", + encoded(SOURCE_IMAGE), + "origin.example.com ".repeat(1024), + ) +} + +fn article_with_protected_svg(svg: &str) -> String { + ARTICLE.replace( + "", + &format!( + "{svg}\ + https://origin.example.com/outside" + ), + ) +} + +fn assert_source_and_surroundings(page: &str, svg: &str) { + assert!( + page.contains(svg), + "must retain the complete original SVG byte slice" + ); + assert!( + page.contains("http://publisher.example.com/outside"), + "core's ordinary outside URL edits must still run: {page}" + ); + assert!(page.contains(MARKER), "article signing must still run"); + assert!( + !page.contains("` and `` are whole comments, and `--!>` also ends one. -fn comment_end(bytes: &[u8], start: usize) -> Option { +pub(crate) fn comment_end(bytes: &[u8], start: usize) -> Option { let body = start + 4; if starts_with_at(bytes, body, b">") { return Some(body + 1); @@ -600,7 +600,7 @@ enum TagState { /// Returns the offset of the `>` ending the tag whose name starts at `start`. /// As in the HTML tokenizer, a quote delimits only a value it begins. -fn tag_end(bytes: &[u8], start: usize) -> Option { +pub(crate) fn tag_end(bytes: &[u8], start: usize) -> Option { let mut state = TagState::Name; for (offset, byte) in bytes.get(start..)?.iter().copied().enumerate() { let space = byte.is_ascii_whitespace(); @@ -696,7 +696,7 @@ pub(crate) fn is_js_whitespace(ch: char) -> bool { ) } -fn find_byte(bytes: &[u8], needle: u8, start: usize) -> Option { +pub(crate) fn find_byte(bytes: &[u8], needle: u8, start: usize) -> Option { let relative = bytes .get(start..)? .iter() @@ -704,7 +704,7 @@ fn find_byte(bytes: &[u8], needle: u8, start: usize) -> Option { Some(start.saturating_add(relative)) } -fn find_bytes(bytes: &[u8], needle: &[u8], start: usize) -> Option { +pub(crate) fn find_bytes(bytes: &[u8], needle: &[u8], start: usize) -> Option { if needle.is_empty() { return Some(start.min(bytes.len())); } @@ -715,7 +715,7 @@ fn find_bytes(bytes: &[u8], needle: &[u8], start: usize) -> Option { Some(start.saturating_add(relative)) } -fn starts_with_at(bytes: &[u8], start: usize, needle: &[u8]) -> bool { +pub(crate) fn starts_with_at(bytes: &[u8], start: usize, needle: &[u8]) -> bool { bytes .get(start..) .is_some_and(|tail| tail.starts_with(needle)) @@ -742,7 +742,7 @@ fn structural_tag<'a>(start: usize, token: &'a str, mode: ScanMode) -> Option bool { +pub(crate) fn is_structural_element_start(bytes: &[u8], start: usize) -> bool { match bytes.get(start + 1).copied() { Some(byte) if byte.is_ascii_alphabetic() => true, Some(b'/') => bytes.get(start + 2).is_some_and(u8::is_ascii_alphabetic), @@ -750,7 +750,7 @@ fn is_structural_element_start(bytes: &[u8], start: usize) -> bool { } } -fn raw_close_name_end(bytes: &[u8], start: usize, expected: &str) -> Option { +pub(crate) fn raw_close_name_end(bytes: &[u8], start: usize, expected: &str) -> Option { if !starts_with_at(bytes, start, b" Option Option<&str> { +pub(crate) fn take_tag_name(content: &str) -> Option<&str> { let end = content .char_indices() .find(|(_, ch)| ch.is_ascii_whitespace() || matches!(*ch, '/' | '>')) diff --git a/crates/shared/aws/src/lib.rs b/crates/shared/aws/src/lib.rs index e0210a1c6a..a70259e77a 100644 --- a/crates/shared/aws/src/lib.rs +++ b/crates/shared/aws/src/lib.rs @@ -371,6 +371,7 @@ mod tests { handle_asset_proxy_request( &settings, &services, + ®istry, request, &settings.proxy.asset_routes[0], source.as_ref(), diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 5075f56b70..60b58f6246 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -21,6 +21,8 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +# The dev server's own front end hands streamed bodies to Hyper (src/server.rs). +axum = { workspace = true } bytes = { workspace = true } edgezero-adapter-axum = { workspace = true, features = ["axum"] } edgezero-core = { workspace = true } @@ -29,7 +31,7 @@ futures = { workspace = true } log = { workspace = true } reqwest = { workspace = true } simple_logger = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time"] } trusted-server-ad-server-iabtl = { workspace = true } trusted-server-ad-tag-google = { workspace = true } trusted-server-ads-txt-iabtl = { workspace = true } @@ -63,7 +65,6 @@ trusted-server-testing-testlight = { workspace = true } [dev-dependencies] trusted-server-core = { workspace = true, features = ["test-utils"] } -axum = { workspace = true } base64 = { workspace = true } serde_json = { workspace = true } temp-env = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 1f13770b4e..819b76a878 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -433,7 +433,9 @@ impl AppState { if let Some(module) = self.registry.device_module() { services = services.with_device_module(module); } - services = services.with_identifier_modules(self.registry.identifier_modules()); + services = services + .with_identifier_modules(self.registry.identifier_modules()) + .with_media_signer(self.registry.media_signer()); // The publisher this request resolved to names every key in a shared // store and every name the browser holds. let prefixes = self.registry.prefixes( @@ -962,8 +964,15 @@ async fn dispatch_asset_fallback( let asset_route = &state.settings.proxy.asset_routes[index]; let answer = match state.registry.route_source(&state.settings, index) { Ok(source) => { - handle_asset_proxy_request(&state.settings, services, req, asset_route, source.as_ref()) - .await + handle_asset_proxy_request( + &state.settings, + services, + &state.registry, + req, + asset_route, + source.as_ref(), + ) + .await } Err(report) => Err(report), }; diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index 2f15e566d2..4dacd424e3 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -10,3 +10,5 @@ pub mod app; pub mod middleware; /// Platform-trait implementations backed by env vars and `reqwest`. pub mod platform; +/// HTTP front end that delivers streamed responses chunk by chunk. +pub mod server; diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 0a88091dda..bdfec9b1d6 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,10 +1,11 @@ use std::path::Path; -use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; +use edgezero_adapter_axum::dev_server::AxumDevServerConfig; use edgezero_core::app::Hooks as _; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; use trusted_server_adapter_axum::platform::{KV_PATH_ENV, open_kv_handle}; +use trusted_server_adapter_axum::server; /// Environment variable that sets the maximum log level. const LOG_LEVEL_ENV: &str = "RUST_LOG"; @@ -42,13 +43,13 @@ fn main() { log::info!("Listening on http://{}", config.addr); let router = TrustedServerApp::routes(); - let mut server = AxumDevServer::with_config(router, config); + let mut kv_handle = None; if let Some(path) = std::env::var_os(KV_PATH_ENV).filter(|path| !path.is_empty()) { // Construct the store once so every request shares one handle. match open_kv_handle(Path::new(&path)) { Ok(handle) => { log::info!("Runtime KV: {}", Path::new(&path).display()); - server = server.with_kv_handle(handle); + kv_handle = Some(handle); } Err(err) => { log::error!("{KV_PATH_ENV} is set but the KV store cannot be opened: {err}"); @@ -56,7 +57,9 @@ fn main() { } } } - if let Err(err) = server.run() { + // The adapter's own front end replaces `AxumDevServer`, whose service + // collects every streamed response into one buffer before sending it. + if let Err(err) = server::run(router, &config, kv_handle) { log::error!("trusted-server-adapter-axum failed: {err}"); std::process::exit(1); } diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 6736e71f23..9ca2bc5a9c 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -311,10 +311,17 @@ impl Future for AxumPendingHandle { /// reqwest-backed HTTP client for the Axum dev server. /// +/// [`send`](PlatformHttpClient::send) preserves the origin body as a +/// `Body::Stream` when the request asks for it: each chunk is read from the +/// connection only when the caller asks for the next one, so a large origin +/// response never sits whole in memory. The client's 30 second timeout still +/// covers the whole exchange, body included. +/// /// `send_async` buffers any `Body::Stream` in the calling context, then spawns /// a `tokio` task for each outbound request so that multiple `send_async` calls /// run concurrently. `select` uses [`futures::future::select_all`] to wait for -/// the first completing handle, preserving fan-out semantics. +/// the first completing handle, preserving fan-out semantics. That path stays +/// buffered, so it refuses a request that asks for a streamed response. pub struct AxumPlatformHttpClient { client: reqwest::Client, } @@ -436,12 +443,59 @@ impl AxumPlatformHttpClient { Ok(body) } + /// Yield the origin body chunk by chunk, enforcing `limit` before each + /// chunk is passed on. + /// + /// The chunk that would carry the total past `limit` becomes an error + /// and ends the stream, as does a failed read, so a truncated or + /// oversized origin body never reaches the caller as a complete one. + fn streamed_response_body( + response: reqwest::Response, + limit: Option, + ) -> edgezero_core::body::Body { + let stream = futures::stream::unfold(Some((response, 0usize)), move |state| async move { + let (mut response, received) = state?; + match response.chunk().await { + Ok(None) => None, + Ok(Some(chunk)) => { + let Some(next_received) = received.checked_add(chunk.len()) else { + return Some(( + Err(std::io::Error::other( + "upstream response body byte count overflowed", + )), + None, + )); + }; + if let Some(limit) = limit + && next_received > limit + { + return Some(( + Err(std::io::Error::other(format!( + "upstream response body exceeded caller limit of {limit} bytes" + ))), + None, + )); + } + Some((Ok(chunk), Some((response, next_received)))) + } + Err(error) => Some(( + Err(std::io::Error::other(format!( + "failed to read upstream response body: {error}" + ))), + None, + )), + } + }); + edgezero_core::body::Body::from_stream(stream) + } + async fn execute( &self, request: PlatformHttpRequest, ) -> Result> { Self::refuse_image_optimizer_metadata(&request)?; let max_response_body_bytes = request.max_response_body_bytes; + let stream_response = request.stream_response; let request_is_head = request.request.method() == edgezero_core::http::Method::HEAD; let uri = request.request.uri().to_string(); let method = reqwest::Method::from_bytes(request.request.method().as_str().as_bytes()) @@ -464,21 +518,40 @@ impl AxumPlatformHttpClient { .change_context(PlatformError::HttpClient) .attach(format!("outbound request to {uri} failed"))?; - let status = resp.status().as_u16(); - let mut edge_builder = edgezero_core::http::response_builder().status(status); + let status = resp.status(); + let mut edge_builder = edgezero_core::http::response_builder().status(status.as_u16()); for (name, value) in sanitized_response_headers(resp.headers()) { edge_builder = edge_builder.header(name.as_str(), value.as_slice()); } - let resp_bytes = - Self::response_body(resp, max_response_body_bytes, request_is_head).await?; - // A caller limit is enforced chunk by chunk before this Vec grows past it. - // Unbounded callers retain the dev server's previous whole-body behavior. - log::debug!( - "buffered {} upstream response bytes from {uri}", - resp_bytes.len() - ); + let body = if !stream_response { + let resp_bytes = + Self::response_body(resp, max_response_body_bytes, request_is_head).await?; + // A caller limit is enforced chunk by chunk before this Vec grows past it. + // Unbounded callers retain the dev server's previous whole-body behavior. + log::debug!( + "buffered {} upstream response bytes from {uri}", + resp_bytes.len() + ); + edgezero_core::body::Body::from(resp_bytes) + } else if !Self::response_carries_body(request_is_head, status) { + edgezero_core::body::Body::empty() + } else { + // Content-Length permits an early refusal. The stream enforces the + // same limit for missing or dishonest lengths. + if let Some(limit) = max_response_body_bytes + && resp + .content_length() + .is_some_and(|length| u64::try_from(limit).is_ok_and(|limit| length > limit)) + { + return Err(Report::new(PlatformError::HttpClient).attach(format!( + "upstream Content-Length exceeds caller response body limit of {limit} bytes" + ))); + } + log::debug!("streaming upstream response body from {uri}"); + Self::streamed_response_body(resp, max_response_body_bytes) + }; let edge_resp = edge_builder - .body(edgezero_core::body::Body::from(resp_bytes)) + .body(body) .change_context(PlatformError::HttpClient)?; Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) @@ -493,6 +566,10 @@ impl Default for AxumPlatformHttpClient { #[async_trait(?Send)] impl PlatformHttpClient for AxumPlatformHttpClient { + fn supports_streaming_responses(&self) -> bool { + true + } + async fn send( &self, request: PlatformHttpRequest, @@ -505,6 +582,11 @@ impl PlatformHttpClient for AxumPlatformHttpClient { request: PlatformHttpRequest, ) -> Result> { Self::refuse_image_optimizer_metadata(&request)?; + if request.stream_response { + return Err(Report::new(PlatformError::HttpClient).attach( + "the Axum dev server streams responses through send alone; send_async buffers", + )); + } let backend_name = request.backend_name.clone(); let max_response_body_bytes = request.max_response_body_bytes; @@ -900,6 +982,7 @@ mod tests { &Arc::default(), ) } + use futures::StreamExt as _; use std::time::Duration; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; @@ -1257,6 +1340,306 @@ mod tests { } } + /// Serve one connection: read the request through `request_end`, answer + /// `head_and_first`, wait for `release`, then send `rest`. The request + /// bytes the client sent come back through the returned receiver. + async fn serve_gated_response( + request_end: &'static [u8], + head_and_first: &'static [u8], + release: tokio::sync::oneshot::Receiver<()>, + rest: &'static [u8], + ) -> (String, tokio::sync::oneshot::Receiver>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind gated HTTP test server"); + let addr = listener.local_addr().expect("should read local address"); + let (request_sender, request_receiver) = tokio::sync::oneshot::channel(); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("should accept request"); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.ends_with(request_end) { + let read = stream.read(&mut buffer).await.expect("should read request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + drop(request_sender.send(request)); + stream + .write_all(head_and_first) + .await + .expect("should write the head and first chunk"); + stream.flush().await.expect("should flush the first chunk"); + if release.await.is_ok() { + stream + .write_all(rest) + .await + .expect("should write the rest of the body"); + } + }); + + (format!("http://{addr}/media"), request_receiver) + } + + async fn next_chunk( + stream: &mut futures::stream::LocalBoxStream<'static, Result>, + ) -> Option> { + tokio::time::timeout(Duration::from_secs(5), stream.next()) + .await + .expect("the next chunk or the end should arrive promptly") + } + + #[test] + fn the_client_streams_through_send_and_not_through_send_async() { + let client = AxumPlatformHttpClient::new(); + assert!( + client.supports_streaming_responses(), + "send should preserve origin bodies as streams" + ); + assert!( + !client.supports_pending_streaming_responses(), + "send_async stays buffered" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_streamed_send_returns_before_the_origin_finishes_and_keeps_the_request() { + let (release, release_receiver) = tokio::sync::oneshot::channel(); + let (url, request_bytes) = serve_gated_response( + b"payload", + b"HTTP/1.1 200 OK\r\n\ + Content-Type: video/mp4\r\n\ + Transfer-Encoding: chunked\r\n\ + \r\n\ + 6\r\n\ + first-\r\n", + release_receiver, + b"6\r\nsecond\r\n0\r\n\r\n", + ) + .await; + let request = edgezero_core::http::request_builder() + .method("POST") + .uri(url) + .header(header::AUTHORIZATION, "Bearer example-token") + .body(EdgeBody::from(b"payload".to_vec())) + .expect("should build outbound request"); + + let response = tokio::time::timeout( + Duration::from_secs(5), + AxumPlatformHttpClient::new().send( + PlatformHttpRequest::new(request, "test_backend") + .with_stream_response() + .with_max_response_body_bytes(64), + ), + ) + .await + .expect("send should return while the origin body is still open") + .expect("the streamed send should succeed") + .response; + + let sent = String::from_utf8(request_bytes.await.expect("should capture the request")) + .expect("the request head should be text"); + assert!( + sent.starts_with("POST /media "), + "should keep the method: {sent}" + ); + assert!( + sent.to_ascii_lowercase() + .contains("authorization: bearer example-token"), + "should forward the authorization header: {sent}" + ); + assert!( + sent.ends_with("payload"), + "should forward the request body: {sent}" + ); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("video/mp4") + ); + let mut stream = response + .into_body() + .into_stream() + .expect("a streamed send should return a streamed body"); + let first = next_chunk(&mut stream) + .await + .expect("the first chunk should arrive before the origin finishes") + .expect("the first chunk should be readable"); + assert_eq!(first.as_ref(), b"first-"); + + release + .send(()) + .expect("should release the rest of the body"); + let mut rest = Vec::new(); + while let Some(chunk) = next_chunk(&mut stream).await { + rest.extend_from_slice(&chunk.expect("the rest of the body should be readable")); + } + assert_eq!(rest, b"second"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_streamed_send_ends_with_an_error_at_the_chunk_that_crosses_the_caller_limit() { + let url = serve_raw_response( + b"HTTP/1.1 200 OK\r\n\ + Transfer-Encoding: chunked\r\n\ + \r\n\ + 2\r\n\ + ab\r\n\ + 2\r\n\ + cd\r\n\ + 1\r\n\ + e\r\n\ + 0\r\n\ + \r\n", + ) + .await; + let request = edgezero_core::http::request_builder() + .uri(url) + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + let mut stream = AxumPlatformHttpClient::new() + .send( + PlatformHttpRequest::new(request, "test_backend") + .with_stream_response() + .with_max_response_body_bytes(4), + ) + .await + .expect("a chunked response has no length to refuse up front") + .response + .into_body() + .into_stream() + .expect("a streamed send should return a streamed body"); + + let mut delivered = Vec::new(); + let error = loop { + match next_chunk(&mut stream).await { + Some(Ok(chunk)) => delivered.extend_from_slice(&chunk), + Some(Err(error)) => break Some(error), + None => break None, + } + } + .expect("a body over the limit must not end cleanly"); + assert!( + delivered.len() <= 4 && b"abcde".starts_with(&delivered), + "should pass on only bytes within the limit: {delivered:?}" + ); + assert!( + error.to_string().contains("limit of 4 bytes"), + "should report the enforced limit: {error}" + ); + assert!( + next_chunk(&mut stream).await.is_none(), + "should end at the error" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_streamed_send_refuses_a_declared_length_over_the_caller_limit() { + let url = + serve_raw_response(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\n0123456789").await; + let request = edgezero_core::http::request_builder() + .uri(url) + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + let result = AxumPlatformHttpClient::new() + .send( + PlatformHttpRequest::new(request, "test_backend") + .with_stream_response() + .with_max_response_body_bytes(4), + ) + .await; + + assert!( + result.is_err(), + "a declared length over the limit should be refused before any byte is read" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_streamed_send_passes_on_an_origin_that_closes_mid_body() { + // The origin declares ten bytes, sends four, and closes. + let url = serve_raw_response(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabcd").await; + let request = edgezero_core::http::request_builder() + .uri(url) + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + let mut stream = AxumPlatformHttpClient::new() + .send(PlatformHttpRequest::new(request, "test_backend").with_stream_response()) + .await + .expect("the head arrives before the origin closes") + .response + .into_body() + .into_stream() + .expect("a streamed send should return a streamed body"); + + let mut delivered = Vec::new(); + let outcome = loop { + match next_chunk(&mut stream).await { + Some(Ok(chunk)) => delivered.extend_from_slice(&chunk), + Some(Err(error)) => break Err(error), + None => break Ok(()), + } + }; + assert!( + outcome.is_err(), + "a truncated origin body must end with an error, after {delivered:?}" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_streamed_head_request_has_an_empty_body_whatever_its_declared_length() { + let url = serve_raw_response(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n").await; + let request = edgezero_core::http::request_builder() + .method("HEAD") + .uri(url) + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + let response = AxumPlatformHttpClient::new() + .send( + PlatformHttpRequest::new(request, "test_backend") + .with_stream_response() + .with_max_response_body_bytes(4), + ) + .await + .expect("a HEAD answer carries no body for the limit to refuse") + .response; + + assert_eq!( + response + .into_body() + .into_bytes() + .expect("a bodiless answer should be an empty buffer") + .len(), + 0 + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn send_async_refuses_a_streamed_response_rather_than_buffer_it() { + let url = serve_raw_response(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok").await; + let request = edgezero_core::http::request_builder() + .uri(url) + .body(EdgeBody::empty()) + .expect("should build outbound request"); + + let result = AxumPlatformHttpClient::new() + .send_async(PlatformHttpRequest::new(request, "test_backend").with_stream_response()) + .await; + + assert!( + result.is_err(), + "the buffered send_async path must refuse a streamed response" + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn select_attributes_failed_backend_name() { // Bind and immediately drop a listener so the port is closed — the diff --git a/crates/trusted-server-adapter-axum/src/server.rs b/crates/trusted-server-adapter-axum/src/server.rs new file mode 100644 index 0000000000..e798a4f463 --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/server.rs @@ -0,0 +1,468 @@ +//! The dev server's HTTP front end, which delivers streamed responses as +//! they are produced. +//! +//! `EdgeZero`'s own Axum service collects every `Body::Stream` response into +//! one buffer before Hyper sees it, so a large origin response streamed +//! through the platform client would be held whole in memory on its way to +//! the client. This front end dispatches through the same `EdgeZero` router +//! and request conversion, and hands a streamed body to Hyper chunk by +//! chunk instead. +//! +//! The router's futures and a streamed body are `!Send`, while Hyper needs a +//! `Send` body. Each request therefore runs on a Tokio blocking-pool thread, +//! which drives the router through the runtime handle, as `EdgeZero`'s +//! service does with `block_in_place`. That thread then passes the body's +//! chunks to Hyper through a bounded channel, so a slow client holds back +//! the origin rather than letting chunks pile up. A failed chunk is passed on +//! as a body error, which makes Hyper abort the response instead of ending +//! it as though it were complete. A client that leaves closes the channel, +//! which stops the router or the body read at once and drops the origin +//! request. +//! +//! At most [`MAX_CONCURRENT_REQUESTS`] requests hold a blocking thread at a +//! time. Later requests wait for a permit in their own connection task, so +//! no work is queued behind the blocking pool without a bound. + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::body::Body as AxumBody; +use axum::extract::Request as AxumRequest; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::Response as AxumResponse; +use bytes::Bytes; +use edgezero_adapter_axum::dev_server::AxumDevServerConfig; +use edgezero_adapter_axum::request::into_core_request; +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::key_value_store::KvHandle; +use edgezero_core::router::RouterService; +use edgezero_core::store_registry::KvRegistry; +use futures::StreamExt as _; +use tokio::sync::{Semaphore, mpsc, oneshot}; + +/// Chunks a streamed body may hold between the router and Hyper before the +/// producer waits for the client. +const STREAMED_CHUNKS_IN_FLIGHT: usize = 8; + +/// Requests that may hold a blocking-pool thread at once. +/// +/// Half of Tokio's default 512 blocking threads, so the KV store's own +/// blocking operations, which a request waits on, always find a thread. +const MAX_CONCURRENT_REQUESTS: usize = 256; + +/// The store id `EdgeZero` binds a single KV handle under. +const DEFAULT_KV_STORE_ID: &str = "default"; + +/// Run the dev server on its own multi-threaded Tokio runtime. +/// +/// `kv_handle` is bound as the `default` KV store of every request, as +/// `EdgeZero`'s dev server binds it. +/// +/// # Errors +/// +/// Returns an error when the runtime cannot start, the address cannot be +/// bound, or the server's accept loop fails. +pub fn run( + router: RouterService, + config: &AxumDevServerConfig, + kv_handle: Option, +) -> std::io::Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let addr = config.addr; + let enable_ctrl_c = config.enable_ctrl_c; + runtime.block_on(async move { + let listener = tokio::net::TcpListener::bind(addr).await.map_err(|error| { + std::io::Error::new( + error.kind(), + format!("failed to bind dev server to {addr}: {error}"), + ) + })?; + let kv_registry = + kv_handle.map(|handle| KvRegistry::single_id(DEFAULT_KV_STORE_ID.to_owned(), handle)); + if enable_ctrl_c { + serve(listener, router, kv_registry, async { + if let Err(error) = tokio::signal::ctrl_c().await { + log::error!("failed to listen for ctrl-c: {error}"); + std::future::pending::<()>().await; + } + }) + .await + } else { + serve(listener, router, kv_registry, std::future::pending()).await + } + }) +} + +/// Serve requests from `listener` until `shutdown` completes. +async fn serve( + listener: tokio::net::TcpListener, + router: RouterService, + kv_registry: Option, + shutdown: F, +) -> std::io::Result<()> +where + F: Future + Send + 'static, +{ + let permits = Arc::new(Semaphore::new(MAX_CONCURRENT_REQUESTS)); + let app = axum::Router::new().fallback(move |request: AxumRequest| { + dispatch( + router.clone(), + kv_registry.clone(), + Arc::clone(&permits), + request, + ) + }); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown) + .await +} + +/// Answer one request, returning as soon as the response head is ready. +async fn dispatch( + router: RouterService, + kv_registry: Option, + permits: Arc, + request: AxumRequest, +) -> AxumResponse { + let Ok(permit) = permits.acquire_owned().await else { + return plain_response( + StatusCode::SERVICE_UNAVAILABLE, + "server is shutting down".to_owned(), + ); + }; + let (head_sender, head) = oneshot::channel(); + let runtime = tokio::runtime::Handle::current(); + let worker = tokio::task::spawn_blocking(move || { + runtime.block_on(produce(router, kv_registry, request, head_sender)); + drop(permit); + }); + match head.await { + Ok(response) => response, + Err(_) => { + let reason = match worker.await { + Err(error) => format!("request worker failed: {error}"), + Ok(()) => "request worker ended without a response".to_owned(), + }; + log::error!("{reason}"); + plain_response(StatusCode::INTERNAL_SERVER_ERROR, reason) + } + } +} + +/// Run the router for `request`, send the response head, then feed a +/// streamed body to Hyper until it ends, fails, or the client leaves. +/// +/// The error answers match `EdgeZero`'s Axum service: a request that cannot +/// be converted answers 500 with the conversion error, and a router error +/// answers 500 with `internal error: `. +async fn produce( + router: RouterService, + kv_registry: Option, + request: AxumRequest, + mut head: oneshot::Sender, +) { + let mut core_request = match into_core_request(request).await { + Ok(core_request) => core_request, + Err(error) => { + drop(head.send(plain_response(StatusCode::INTERNAL_SERVER_ERROR, error))); + return; + } + }; + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); + } + let routed = tokio::select! { + routed = router.oneshot(core_request) => routed, + // Hyper dropped the request future: the client left before the head. + () = head.closed() => return, + }; + let core_response = match routed { + Ok(core_response) => core_response, + Err(error) => { + drop(head.send(plain_response( + StatusCode::INTERNAL_SERVER_ERROR, + format!("internal error: {error}"), + ))); + return; + } + }; + + let (parts, body) = core_response.into_parts(); + let mut stream = match body { + EdgeBody::Once(bytes) => { + drop(head.send(AxumResponse::from_parts(parts, AxumBody::from(bytes)))); + return; + } + EdgeBody::Stream(stream) => stream, + }; + + let (chunk_sender, chunk_receiver) = + mpsc::channel::>(STREAMED_CHUNKS_IN_FLIGHT); + let body = AxumBody::from_stream(futures::stream::unfold( + chunk_receiver, + |mut receiver| async move { receiver.recv().await.map(|chunk| (chunk, receiver)) }, + )); + if head.send(AxumResponse::from_parts(parts, body)).is_err() { + return; + } + loop { + let next = tokio::select! { + next = stream.next() => next, + // Hyper dropped the body: the client left mid-stream. + () = chunk_sender.closed() => return, + }; + let Some(chunk) = next else { + return; + }; + let failed = chunk.is_err(); + let chunk = chunk.map_err(|error| { + log::warn!("streamed response failed mid-body: {error:#}"); + std::io::Error::other(format!("{error:#}")) + }); + // A send waits while the channel is full, so a slow client slows + // the origin read. It fails once Hyper has dropped the body. + if chunk_sender.send(chunk).await.is_err() || failed { + return; + } + } +} + +fn plain_response(status: StatusCode, message: String) -> AxumResponse { + let mut response = AxumResponse::new(AxumBody::from(message)); + *response.status_mut() = status; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + use edgezero_core::context::RequestContext; + use edgezero_core::error::EdgeError; + + /// Start the front end on a free local port and return its base URL. + async fn start(router: RouterService) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind a local port"); + let addr = listener + .local_addr() + .expect("should read the bound address"); + tokio::spawn(serve(listener, router, None, std::future::pending())); + format!("http://{addr}") + } + + /// A body that yields `first`, waits for `release`, then yields `second`. + fn gated_body( + first: &'static [u8], + release: Arc, + second: Result<&'static [u8], &'static str>, + ) -> EdgeBody { + let stream = futures::stream::unfold(0u8, move |step| { + let release = Arc::clone(&release); + async move { + match step { + 0 => Some((Ok(Bytes::from_static(first)), 1)), + 1 => { + release.notified().await; + let item = second + .map(Bytes::from_static) + .map_err(std::io::Error::other); + Some((item, 2)) + } + _ => None, + } + } + }); + EdgeBody::from_stream(stream) + } + + /// Records that the body it travels with was dropped. + struct DropFlag(Arc); + + impl Drop for DropFlag { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_client_that_leaves_stops_the_body_read() { + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let handler_dropped = Arc::clone(&dropped); + let router = RouterService::builder() + .get("/media", move |_ctx: RequestContext| { + let flag = DropFlag(Arc::clone(&handler_dropped)); + async move { + // An origin body that never ends, 64 KiB at a time. + let chunk = Bytes::from(vec![1u8; 64 * 1024]); + let stream = futures::stream::unfold(flag, move |flag| { + let chunk = chunk.clone(); + async move { + tokio::task::yield_now().await; + Some((Ok::<_, std::io::Error>(chunk), flag)) + } + }); + Ok::<_, EdgeError>(EdgeBody::from_stream(stream)) + } + }) + .build(); + let base = start(router).await; + + let mut response = reqwest::get(format!("{base}/media")) + .await + .expect("the head should arrive"); + response + .chunk() + .await + .expect("the first chunk should be readable") + .expect("the first chunk should arrive"); + drop(response); + + tokio::time::timeout(Duration::from_secs(5), async { + while !dropped.load(std::sync::atomic::Ordering::SeqCst) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the body, and the origin read it holds, should be dropped once the client leaves"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_streamed_body_reaches_the_client_before_it_finishes() { + let release = Arc::new(tokio::sync::Notify::new()); + let handler_release = Arc::clone(&release); + let router = RouterService::builder() + .get("/media", move |_ctx: RequestContext| { + let release = Arc::clone(&handler_release); + async move { + let response = edgezero_core::http::response_builder() + .status(200) + .header("x-origin", "kept") + .header(header::CONTENT_TYPE, "video/mp4") + .body(gated_body(b"first-", release, Ok(b"second"))) + .expect("should build the streamed response"); + Ok::<_, EdgeError>(response) + } + }) + .build(); + let base = start(router).await; + + let mut response = tokio::time::timeout( + Duration::from_secs(5), + reqwest::get(format!("{base}/media")), + ) + .await + .expect("the head should arrive while the body is still open") + .expect("the request should succeed"); + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response + .headers() + .get("x-origin") + .and_then(|value| value.to_str().ok()), + Some("kept"), + "should deliver the router's headers" + ); + let first = tokio::time::timeout(Duration::from_secs(5), response.chunk()) + .await + .expect("the first chunk should arrive before the body finishes") + .expect("the first chunk should be readable") + .expect("the body should not end after the first chunk"); + assert_eq!(first.as_ref(), b"first-"); + + release.notify_one(); + let mut rest = Vec::new(); + while let Some(chunk) = tokio::time::timeout(Duration::from_secs(5), response.chunk()) + .await + .expect("the rest of the body should arrive once released") + .expect("the rest of the body should be readable") + { + rest.extend_from_slice(&chunk); + } + assert_eq!(rest, b"second", "should deliver the released chunk and end"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_body_that_fails_mid_stream_is_not_delivered_as_complete() { + let release = Arc::new(tokio::sync::Notify::new()); + let handler_release = Arc::clone(&release); + let router = RouterService::builder() + .get("/media", move |_ctx: RequestContext| { + let release = Arc::clone(&handler_release); + async move { + Ok::<_, EdgeError>(gated_body( + b"partial", + release, + Err("origin connection reset"), + )) + } + }) + .build(); + let base = start(router).await; + + let mut response = reqwest::get(format!("{base}/media")) + .await + .expect("the head should arrive"); + let first = response + .chunk() + .await + .expect("the first chunk should be readable") + .expect("the first chunk should arrive"); + assert_eq!(first.as_ref(), b"partial"); + + release.notify_one(); + let outcome = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match response.chunk().await { + Ok(Some(_)) => {} + Ok(None) => return Ok(()), + Err(error) => return Err(error), + } + } + }) + .await + .expect("the failure should surface promptly"); + assert!( + outcome.is_err(), + "a failed stream must abort the response rather than end it cleanly" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_buffered_body_and_an_unknown_route_answer_as_before() { + async fn hello(_ctx: RequestContext) -> Result<&'static str, EdgeError> { + Ok("hello") + } + let router = RouterService::builder().get("/hello", hello).build(); + let base = start(router).await; + + let hello = reqwest::get(format!("{base}/hello")) + .await + .expect("the buffered route should answer"); + assert_eq!(hello.status().as_u16(), 200); + assert_eq!( + hello.text().await.expect("the body should be readable"), + "hello" + ); + + let missing = reqwest::get(format!("{base}/missing")) + .await + .expect("an unknown route should answer"); + assert_eq!( + missing.status().as_u16(), + 404, + "the router's own not-found answer should pass through" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/tests/shipped_modules.rs b/crates/trusted-server-adapter-axum/tests/shipped_modules.rs index a72162abb8..71992faad0 100644 --- a/crates/trusted-server-adapter-axum/tests/shipped_modules.rs +++ b/crates/trusted-server-adapter-axum/tests/shipped_modules.rs @@ -1,23 +1,13 @@ //! What the module builders the Axum adapter links give a deployment, proven //! from outside the adapter: the stock loading cache, each module's own //! deploy rules and secret settings, the diagnostics module's request -//! preparer, the middleware the template and the guide document, the ad -//! server's decision between planned bids, and the maintainers every crate -//! outside core names. +//! preparer, the ad server's decision between planned bids, and the maintainers +//! every crate outside core names. #![allow(clippy::panic, reason = "tests use panic-on-failure helpers")] use trusted_server_adapter_axum::app::shipped_module_builders; -use trusted_server_core::integrations::{ - IntegrationRegistry, SuppliedMiddleware, supplied_middleware, -}; -use trusted_server_core::middleware::{HTML_MEDIA_TYPE, MiddlewarePhase}; - -/// The middleware the linked modules and core supply, with the integration -/// that supplies each, in the order the registry registers them. -fn listed_middleware() -> Vec<(&'static str, SuppliedMiddleware)> { - supplied_middleware(&shipped_module_builders()) -} +use trusted_server_core::integrations::IntegrationRegistry; /// A feature selects the stock loading cache by the module's short name, so /// a stock build keeps what it loads rather than asking each source every @@ -309,106 +299,6 @@ fn the_linked_list_attaches_the_diagnostics_module_s_request_preparer() { ); } -/// The template's entries name every middleware the linked modules and core -/// supply, in the order the registry registers them, which is the order a -/// deployment that runs them all names them in. -#[test] -fn the_template_s_entries_name_the_listed_middleware() { - use trusted_server_core::settings::Settings; - use trusted_server_core::test_support::template::{ - template_with_resolved_required_secrets, uncomment_block, - }; - - let template = uncomment_block( - &uncomment_block(&template_with_resolved_required_secrets(), "[[fetch]]"), - "[[serve]]", - ); - let settings = - Settings::from_toml(&template).expect("should load the template with its entries"); - let listed = listed_middleware(); - - for phase in MiddlewarePhase::ALL { - let entries = settings.phase_entries(phase).entries(); - let mut media_types: Vec<&str> = Vec::new(); - for (_, row) in listed.iter().filter(|(_, row)| row.phase == phase) { - if !media_types.contains(&row.media_type) { - media_types.push(row.media_type); - } - } - for media_type in media_types { - let named_in_list: Vec<&str> = listed - .iter() - .filter(|(_, row)| row.phase == phase && row.media_type == media_type) - .map(|(_, row)| row.name) - .collect(); - let covering: Vec<_> = entries - .iter() - .filter(|entry| entry.media_type == media_type) - .collect(); - if media_type == HTML_MEDIA_TYPE || covering.iter().any(|entry| entry.path.is_none()) { - // One entry covers every page, for HTML and for a media - // type a page's own responses come in. - assert_eq!( - covering.len(), - 1, - "should document one [[{phase}]] entry for {media_type}" - ); - assert!( - covering[0].path.is_none(), - "should document the [[{phase}]] entry that covers every page" - ); - } else { - // A file's middleware is placed on the file's own path, one - // entry for each file. - assert!( - !covering.is_empty(), - "should document a [[{phase}]] entry on a path for each {media_type} file" - ); - } - let named: Vec<&str> = covering - .iter() - .flat_map(|entry| entry.middleware.iter().map(String::as_str)) - .collect(); - assert_eq!( - named, named_in_list, - "should document every {phase} middleware the linked modules supply for \ - {media_type}, in the listed order" - ); - } - } -} - -/// The guide's table of middleware lists what the linked modules and core -/// supply, each with its phase, in the order the registry registers them. -#[test] -fn the_guide_s_table_lists_the_same_middleware() { - const GUIDE: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../docs/guide/configuration.md" - )); - - // A row of the table is a name and then the list it is written in. - let documented: Vec<(String, String)> = GUIDE - .lines() - .filter_map(|line| { - let mut cells = line.split('|').map(str::trim).skip(1); - let name = cells.next()?.strip_prefix('`')?.strip_suffix('`')?; - let list = cells.next()?.strip_prefix("`[[")?.strip_suffix("]]`")?; - Some((name.to_owned(), list.to_owned())) - }) - .collect(); - let listed: Vec<(String, String)> = listed_middleware() - .iter() - .map(|(_, row)| (row.name.to_owned(), row.phase.to_string())) - .collect(); - - assert_eq!( - documented, listed, - "should document the middleware the linked modules supply, each with its phase, in \ - the listed order" - ); -} - /// The ad server decides between the bids APS returned, and the winner /// keeps the seat the exchange returned, APS's own bidder name and its /// renderer. diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 1d98405d7d..3498a2fdc8 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -69,6 +69,7 @@ js-sys = { workspace = true } worker = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } trusted-server-core = { workspace = true, features = ["test-utils"] } base64 = { workspace = true } edgezero-core = { workspace = true } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index b73760c7af..bc9d22ae3d 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -530,7 +530,9 @@ impl AppState { if let Some(module) = self.registry.device_module() { services = services.with_device_module(module); } - services = services.with_identifier_modules(self.registry.identifier_modules()); + services = services + .with_identifier_modules(self.registry.identifier_modules()) + .with_media_signer(self.registry.media_signer()); // The publisher this request resolved to names every key in a shared // store and every name the browser holds. let prefixes = self.registry.prefixes( @@ -1103,8 +1105,15 @@ async fn dispatch_asset_fallback( let asset_route = &state.settings.proxy.asset_routes[index]; let answer = match state.registry.route_source(&state.settings, index) { Ok(source) => { - handle_asset_proxy_request(&state.settings, services, req, asset_route, source.as_ref()) - .await + handle_asset_proxy_request( + &state.settings, + services, + &state.registry, + req, + asset_route, + source.as_ref(), + ) + .await } Err(report) => Err(report), }; diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 8bb37cd902..7885edef65 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -23,6 +23,9 @@ use trusted_server_core::platform::{ PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, }; +#[cfg(any(target_arch = "wasm32", test))] +mod response_stream; + // --------------------------------------------------------------------------- // Noop stubs — used when a handle is absent (native CI, missing binding) // --------------------------------------------------------------------------- @@ -215,6 +218,13 @@ struct CloudflarePendingResponse { /// [`worker::Fetch`]-backed HTTP client for the Cloudflare Workers runtime. /// +/// [`send`](PlatformHttpClient::send) preserves the origin body as a +/// `Body::Stream` when the request asks for it, reading the Workers +/// `ReadableStream` one runtime chunk at a time, see +/// [`response_stream::bounded_origin_stream`]. A streamed body honors the +/// caller's exact `max_response_body_bytes`, if any, instead of the buffered +/// path's 10 MiB ceiling. +/// /// # Multi-provider auction limitation /// /// `send_async` eagerly awaits each request before returning, so @@ -223,6 +233,8 @@ struct CloudflarePendingResponse { /// request launches. `select` keeps a defense-in-depth rejection for more /// than one pending request. Configure a single auction provider for /// Cloudflare Workers, or use the Fastly adapter for parallel DSP fan-out. +/// That path stays buffered, so it refuses a request that asks for a +/// streamed response. /// /// Per-provider timeouts baked into the backend name are not enforced at the /// fetch layer; the Workers runtime's global CPU budget (~30 s on paid plans) @@ -281,6 +293,17 @@ fn response_carries_body(request_is_head: bool, status: u16) -> bool { !request_is_head && !(100..200).contains(&status) && !matches!(status, 204 | 205 | 304) } +/// Decoded bodies have no usable wire length; HEAD and 304 keep the +/// selected representation's length rather than advertising an empty asset. +#[cfg(any(target_arch = "wasm32", test))] +fn streamed_content_length(status: u16, unencoded: bool, claimed: Option) -> Option { + if unencoded && !(100..200).contains(&status) && !matches!(status, 204 | 205) { + claimed + } else { + None + } +} + /// Collect the lowercased tokens listed in the upstream `Connection:` header(s). /// /// Every header named here is hop-by-hop (RFC 7230 §6.1) and must not be @@ -371,26 +394,27 @@ impl CloudflareHttpClient { ) -> Result> { use worker::{CacheMode, Fetch, Headers, Method, Request}; - // The Cloudflare fetch path cannot honor Image Optimizer metadata, and - // it always buffers the response body (see below). The - // `PlatformHttpRequest` contract requires adapters that cannot honor - // these to reject rather than silently drop the behavior, so surface a - // typed error instead of returning an untransformed or fully-buffered - // response. Only an asset route sets these fields. A route that - // enables the Image Optimizer answers with this error, and none asks - // for a stream, because this client reports that it cannot stream. + // The Cloudflare fetch path cannot honor Image Optimizer metadata. + // The `PlatformHttpRequest` contract requires adapters that cannot + // honor it to reject rather than silently drop the behavior, so + // surface a typed error instead of returning an untransformed + // response. Only an asset route sets it. if request.image_optimizer.is_some() { return Err(Report::new(PlatformError::HttpClient) .attach("Image Optimizer is not supported on the Cloudflare Workers runtime")); } - if request.stream_response { - return Err(Report::new(PlatformError::HttpClient).attach( - "streaming response bodies are not supported on the Cloudflare Workers runtime", - )); - } let cache_mode = outbound_cache_mode(&request.cache_intent); - let response_limit = effective_response_body_limit(request.max_response_body_bytes); + let stream_response = request.stream_response; + // A streamed body holds one chunk at a time, so only the caller's own + // limit applies to it. A buffered body is also held to the ceiling. + let response_limit = if stream_response { + request.max_response_body_bytes + } else { + Some(effective_response_body_limit( + request.max_response_body_bytes, + )) + }; let request_is_head = request.request.method() == edgezero_core::http::Method::HEAD; let uri = request.request.uri().to_string(); @@ -456,23 +480,36 @@ impl CloudflareHttpClient { let status = resp.status_code(); - // Content-Length permits an early refusal, but the stream collector is + // Content-Length permits an early refusal, but the body reader is // authoritative for missing or dishonest lengths. let response_body_expected = response_carries_body(request_is_head, status); + let claimed_len = resp + .headers() + .get("content-length") + .ok() + .flatten() + .and_then(|v| v.trim().parse::().ok()); if response_body_expected - && let Some(claimed_len) = resp - .headers() - .get("content-length") - .ok() - .flatten() - .and_then(|v| v.trim().parse::().ok()) - && claimed_len > response_limit + && let Some(limit) = response_limit + && let Some(claimed_len) = claimed_len + && u64::try_from(limit).is_ok_and(|limit| claimed_len > limit) { return Err(Report::new(PlatformError::HttpClient).attach(format!( "origin Content-Length {claimed_len} exceeds \ - {response_limit}-byte response body limit" + {limit}-byte response body limit" ))); } + // The runtime decodes a content encoding, after which the declared + // length describes bytes the stream never yields. + let body_is_unencoded = resp + .headers() + .get("content-encoding") + .ok() + .flatten() + .is_none_or(|encoding| { + let encoding = encoding.trim(); + encoding.is_empty() || encoding.eq_ignore_ascii_case("identity") + }); let connection_tokens = response_connection_tokens(&resp); let mut edge_builder = edgezero_core::http::response_builder().status(status); @@ -498,18 +535,29 @@ impl CloudflareHttpClient { } edge_builder = edge_builder.header(name.as_str(), value.as_bytes()); } - let body_bytes = if response_body_expected { + if (stream_response || !response_body_expected) + && let Some(length) = streamed_content_length(status, body_is_unencoded, claimed_len) + { + edge_builder = edge_builder.header(edgezero_core::http::header::CONTENT_LENGTH, length); + } + let body = if !response_body_expected { + edgezero_core::body::Body::empty() + } else if stream_response { let stream = resp.stream().change_context(PlatformError::HttpClient)?; - collect_bounded_response_stream(stream, response_limit).await? + let expected_length = claimed_len.filter(|_| body_is_unencoded); + response_stream::bounded_origin_stream(stream, response_limit, expected_length) } else { - Vec::new() + let limit = response_limit.unwrap_or(MAX_PLATFORM_RESPONSE_BODY_BYTES); + let stream = resp.stream().change_context(PlatformError::HttpClient)?; + let body_bytes = collect_bounded_response_stream(stream, limit).await?; + edge_builder = edge_builder.header( + edgezero_core::http::header::CONTENT_LENGTH, + body_bytes.len(), + ); + edgezero_core::body::Body::from(body_bytes) }; - edge_builder = edge_builder.header( - edgezero_core::http::header::CONTENT_LENGTH, - body_bytes.len(), - ); let edge_resp = edge_builder - .body(edgezero_core::body::Body::from(body_bytes)) + .body(body) .change_context(PlatformError::HttpClient)?; Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) @@ -533,10 +581,15 @@ impl PlatformHttpClient for CloudflareHttpClient { false } + fn supports_streaming_responses(&self) -> bool { + true + } + async fn send_async( &self, request: PlatformHttpRequest, ) -> Result> { + reject_buffered_stream_request(request.stream_response)?; let backend_name = request.backend_name.clone(); let response = self.execute(request).await?; @@ -895,11 +948,41 @@ fn reject_multi_provider_fanout(len: usize) -> Result<(), Report> Ok(()) } +/// Refuse a streamed response on the buffered `send_async` path. +/// +/// `send_async` stores a completed, buffered response for `select`, so a +/// request that asks for a stream must go through `send` instead of being +/// silently buffered. The check runs before the fetch, so a refused request +/// never reaches the origin. +#[cfg(any(target_arch = "wasm32", test))] +fn reject_buffered_stream_request(stream_response: bool) -> Result<(), Report> { + if stream_response { + return Err(Report::new(PlatformError::HttpClient).attach( + "CloudflareHttpClient streams responses through send alone; send_async buffers", + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; use edgezero_core::context::RequestContext; + #[test] + fn streamed_lengths_describe_the_identity_representation() { + let large = 64 * 1024 * 1024; + assert_eq!(streamed_content_length(200, true, Some(large)), Some(large)); + assert_eq!(streamed_content_length(206, true, Some(1024)), Some(1024)); + assert_eq!(streamed_content_length(304, true, Some(large)), Some(large)); + assert_eq!(streamed_content_length(200, false, Some(1024)), None); + assert_eq!(streamed_content_length(304, false, Some(1024)), None); + assert_eq!(streamed_content_length(200, true, None), None); + for status in [101, 204, 205] { + assert_eq!(streamed_content_length(status, true, Some(large)), None); + } + } + #[cfg(target_arch = "wasm32")] #[test] fn outbound_request_creation_sets_manual_redirect_mode() { @@ -1206,6 +1289,18 @@ mod tests { ); } + #[test] + fn send_async_refuses_a_streamed_response_and_admits_a_buffered_one() { + assert!( + reject_buffered_stream_request(true).is_err(), + "the buffered send_async path must refuse a streamed response" + ); + assert!( + reject_buffered_stream_request(false).is_ok(), + "a buffered request should still use send_async" + ); + } + #[test] fn reject_multi_provider_fanout_rejects_five_providers() { let err = reject_multi_provider_fanout(5).expect_err("should reject five providers"); diff --git a/crates/trusted-server-adapter-cloudflare/src/platform/response_stream.rs b/crates/trusted-server-adapter-cloudflare/src/platform/response_stream.rs new file mode 100644 index 0000000000..904fff4729 --- /dev/null +++ b/crates/trusted-server-adapter-cloudflare/src/platform/response_stream.rs @@ -0,0 +1,307 @@ +//! Origin response bodies passed on as the Workers runtime delivers them. +//! +//! A streamed response never holds more than the chunk the runtime handed +//! over last. The caller's byte limit and the origin's declared length are +//! checked chunk by chunk, so an oversized, truncated or failed origin body +//! ends the stream with an error instead of reaching the caller as a +//! complete one. + +use std::fmt::Display; +use std::pin::Pin; + +use bytes::Bytes; +use futures::{Stream, StreamExt as _}; + +/// The source and the number of bytes already yielded from it. +struct Progress { + source: Pin>, + yielded: usize, +} + +/// A byte count in the unit of a declared `Content-Length`. A count no +/// `u64` can hold is past any declared length. +fn declared_units(bytes: usize) -> u64 { + u64::try_from(bytes).unwrap_or(u64::MAX) +} + +/// Wrap a runtime body stream as a [`Body::Stream`](edgezero_core::body::Body::Stream). +/// +/// `limit` is the caller's `max_response_body_bytes`. The chunk that would +/// carry the running total past it is replaced by an error, and nothing +/// after it is read. +/// +/// `expected_length` is the origin's `Content-Length` when the runtime hands +/// the body over unchanged. The Workers `ByteStream` reports a JavaScript +/// `aborted` stream as a clean end, so an origin connection cut short would +/// otherwise look like a complete body. A body that ends short of the +/// declared length, or runs past it, therefore ends with an error. Pass +/// `None` when the runtime decoded a content encoding, because the declared +/// length then describes the encoded bytes. +/// +/// A source error is passed on as a stream error and ends the stream. +pub(super) fn bounded_origin_stream( + source: S, + limit: Option, + expected_length: Option, +) -> edgezero_core::body::Body +where + S: Stream> + 'static, + B: Into, + E: Display, +{ + let initial = Progress { + source: Box::pin(source), + yielded: 0, + }; + let stream = futures::stream::unfold(Some(initial), move |state| async move { + let mut progress = state?; + match progress.source.next().await { + None => { + if let Some(expected) = expected_length + && declared_units(progress.yielded) != expected + { + return Some(( + Err(std::io::Error::other(format!( + "Workers response body ended after {} of its declared {expected} bytes", + progress.yielded + ))), + None, + )); + } + None + } + Some(Err(error)) => Some(( + Err(std::io::Error::other(format!( + "failed to read Workers response body: {error}" + ))), + None, + )), + Some(Ok(chunk)) => { + let chunk: Bytes = chunk.into(); + let Some(next_yielded) = progress.yielded.checked_add(chunk.len()) else { + return Some(( + Err(std::io::Error::other( + "Workers response body byte count overflowed", + )), + None, + )); + }; + if let Some(limit) = limit + && next_yielded > limit + { + return Some(( + Err(std::io::Error::other(format!( + "Workers response body exceeded caller limit of {limit} bytes" + ))), + None, + )); + } + if let Some(expected) = expected_length + && declared_units(next_yielded) > expected + { + return Some(( + Err(std::io::Error::other(format!( + "Workers response body ran past its declared {expected} bytes" + ))), + None, + )); + } + progress.yielded = next_yielded; + Some((Ok(chunk), Some(progress))) + } + } + }); + edgezero_core::body::Body::from_stream(stream) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::FutureExt as _; + + type ChunkResult = Result, std::io::Error>; + + fn into_stream( + body: edgezero_core::body::Body, + ) -> futures::stream::LocalBoxStream<'static, Result> { + body.into_stream() + .expect("a streamed origin body should stay a stream") + } + + #[tokio::test] + async fn each_chunk_is_yielded_before_the_origin_finishes() { + let (sender, receiver) = futures::channel::mpsc::unbounded::(); + sender + .unbounded_send(Ok(b"ab".to_vec())) + .expect("should queue the first chunk"); + let mut stream = into_stream(bounded_origin_stream(receiver, None, None)); + + let first = stream + .next() + .await + .expect("the first chunk should arrive") + .expect("the first chunk should be readable"); + assert_eq!( + first.as_ref(), + b"ab", + "should pass the first chunk on as it arrived" + ); + assert!( + stream.next().now_or_never().is_none(), + "should wait for the origin rather than report an end it has not sent" + ); + + sender + .unbounded_send(Ok(b"cd".to_vec())) + .expect("should queue the second chunk"); + drop(sender); + let second = stream + .next() + .await + .expect("the second chunk should arrive") + .expect("the second chunk should be readable"); + assert_eq!(second.as_ref(), b"cd", "should pass the later chunk on"); + assert!(stream.next().await.is_none(), "should end with the origin"); + } + + #[tokio::test] + async fn an_unbounded_stream_passes_more_than_the_buffered_ceiling() { + // Eleven 1 MiB chunks cross the 10 MiB ceiling the buffered path + // enforces. The chunks share one allocation, so the test holds 1 MiB. + let chunk = Bytes::from(vec![7u8; 1024 * 1024]); + let chunks = std::iter::repeat_n(chunk, 11).map(Ok::<_, std::io::Error>); + let mut stream = into_stream(bounded_origin_stream( + futures::stream::iter(chunks), + None, + None, + )); + + let mut total = 0usize; + while let Some(chunk) = stream.next().await { + total += chunk.expect("an unbounded chunk should be readable").len(); + } + assert_eq!(total, 11 * 1024 * 1024, "should pass every byte on"); + } + + #[tokio::test] + async fn the_chunk_that_crosses_the_caller_limit_ends_the_stream() { + let source = futures::stream::iter([ + Ok::<_, std::io::Error>(b"ab".to_vec()), + Ok(b"cd".to_vec()), + Ok(b"e".to_vec()), + Ok(b"never read".to_vec()), + ]); + let mut stream = into_stream(bounded_origin_stream(source, Some(4), None)); + + for expected in [b"ab", b"cd"] { + let chunk = stream + .next() + .await + .expect("a chunk within the limit should arrive") + .expect("a chunk within the limit should be readable"); + assert_eq!( + chunk.as_ref(), + expected, + "should pass chunks up to the exact limit" + ); + } + let error = stream + .next() + .await + .expect("the crossing chunk should surface") + .expect_err("the crossing chunk must be refused"); + assert!( + error.to_string().contains("limit of 4 bytes"), + "should report the enforced limit: {error}" + ); + assert!( + stream.next().await.is_none(), + "should read nothing after the refused chunk" + ); + } + + #[tokio::test] + async fn an_origin_failure_is_passed_on_and_ends_the_stream() { + let source = futures::stream::iter([ + Ok(b"ab".to_vec()), + Err(std::io::Error::other("connection reset by origin")), + Ok(b"never read".to_vec()), + ]); + let mut stream = into_stream(bounded_origin_stream(source, Some(64), None)); + + let first = stream + .next() + .await + .expect("the chunk before the failure should arrive") + .expect("the chunk before the failure should be readable"); + assert_eq!(first.as_ref(), b"ab"); + let error = stream + .next() + .await + .expect("the failure should surface") + .expect_err("the origin failure must not look like data"); + assert!( + error.to_string().contains("connection reset by origin"), + "should carry the origin's failure: {error}" + ); + assert!(stream.next().await.is_none(), "should end at the failure"); + } + + #[tokio::test] + async fn a_body_cut_short_of_its_declared_length_is_an_error() { + // The Workers stream reports an aborted origin as a clean end. + let source = futures::stream::iter([Ok::<_, std::io::Error>(b"ab".to_vec())]); + let mut stream = into_stream(bounded_origin_stream(source, None, Some(4))); + + let first = stream + .next() + .await + .expect("the delivered bytes should arrive") + .expect("the delivered bytes should be readable"); + assert_eq!(first.as_ref(), b"ab"); + let error = stream + .next() + .await + .expect("the early end should surface") + .expect_err("a truncated body must not end cleanly"); + assert!( + error.to_string().contains("2 of its declared 4 bytes"), + "should report how much of the declared body arrived: {error}" + ); + } + + #[tokio::test] + async fn a_body_running_past_its_declared_length_is_an_error() { + let source = + futures::stream::iter([Ok::<_, std::io::Error>(b"abc".to_vec()), Ok(b"de".to_vec())]); + let mut stream = into_stream(bounded_origin_stream(source, None, Some(4))); + + stream + .next() + .await + .expect("the bytes within the declared length should arrive") + .expect("the bytes within the declared length should be readable"); + let error = stream + .next() + .await + .expect("the overrun should surface") + .expect_err("bytes past the declared length must be refused"); + assert!( + error.to_string().contains("declared 4 bytes"), + "should report the declared length: {error}" + ); + } + + #[tokio::test] + async fn a_body_matching_its_declared_length_ends_cleanly() { + let source = + futures::stream::iter([Ok::<_, std::io::Error>(b"ab".to_vec()), Ok(b"cd".to_vec())]); + let mut stream = into_stream(bounded_origin_stream(source, Some(4), Some(4))); + + let mut body = Vec::new(); + while let Some(chunk) = stream.next().await { + body.extend_from_slice(&chunk.expect("a declared-length body should be readable")); + } + assert_eq!(body, b"abcd", "should deliver the whole declared body"); + } +} diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index db6fa5815d..709eef8204 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -614,7 +614,9 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime if let Some(module) = state.registry.device_module() { services = services.with_device_module(module); } - let services = services.with_identifier_modules(state.registry.identifier_modules()); + let services = services + .with_identifier_modules(state.registry.identifier_modules()) + .with_media_signer(state.registry.media_signer()); // The publisher this request resolved to, its tenant included, names // every key in a shared store and every name the browser holds. let prefixes = state.registry.prefixes( @@ -1382,8 +1384,15 @@ async fn dispatch_asset_fallback( let asset_route = &state.settings.proxy.asset_routes[index]; let answer = match state.registry.route_source(&state.settings, index) { Ok(source) => { - handle_asset_proxy_request(&state.settings, services, req, asset_route, source.as_ref()) - .await + handle_asset_proxy_request( + &state.settings, + services, + &state.registry, + req, + asset_route, + source.as_ref(), + ) + .await } Err(report) => Err(report), }; diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 21fa640f2c..6a5c169886 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -296,6 +296,16 @@ fn apply_fastly_image_optimizer_params( Ok(()) } +/// The Image Optimizer query parameter that names the metadata it keeps. +/// Its `c2pa` filter keeps the input's C2PA manifest and adds the +/// transformations the optimizer performed, and `copyright,c2pa` applies +/// both filters. The SDK's `Metadata` options offer only `copyright`, so +/// the filters are sent as the query parameter the optimizer reads beside +/// its options. The optimizer removes the parameters it knows from its +/// origin request, so an origin signature over the address without it +/// still holds. +const IMAGE_OPTIMIZER_METADATA_PARAM: &str = "metadata"; + fn apply_fastly_image_optimizer( req: &mut fastly::Request, options: PlatformImageOptimizerOptions, @@ -305,6 +315,35 @@ fn apply_fastly_image_optimizer( fastly_options.preserve_query_string_on_origin_request = Some(options.preserve_query_string_on_origin_request); apply_fastly_image_optimizer_params(&mut fastly_options, options.params)?; + if options.preserve_c2pa { + // An option outranks a query parameter of the same name, so a + // copyright filter set as an option moves into the query beside + // `c2pa`, as does one the address already asks for. Every other + // parameter keeps its exact bytes. + let mut copyright = fastly_options.metadata.take().is_some(); + let mut url = req.get_url_mut(); + let mut pairs: Vec = Vec::new(); + for pair in url + .query() + .unwrap_or_default() + .split('&') + .filter(|pair| !pair.is_empty()) + { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if key == IMAGE_OPTIMIZER_METADATA_PARAM { + copyright |= value + .to_ascii_lowercase() + .replace("%2c", ",") + .split(',') + .any(|filter| filter.trim() == "copyright"); + } else { + pairs.push(pair.to_owned()); + } + } + let filters = if copyright { "copyright,c2pa" } else { "c2pa" }; + pairs.push(format!("{IMAGE_OPTIMIZER_METADATA_PARAM}={filters}")); + url.set_query(Some(&pairs.join("&"))); + } req.set_image_optimizer(fastly_options); Ok(()) } @@ -1594,6 +1633,53 @@ mod tests { ); } + #[test] + fn image_optimizer_c2pa_preservation_is_asked_only_when_requested() { + let options = || { + PlatformImageOptimizerOptions::new( + "us_east", + PlatformImageOptimizerParams { + width: Some(320), + ..PlatformImageOptimizerParams::default() + }, + ) + }; + + let mut plain = fastly::Request::get("https://assets.example.com/photo.jpg"); + apply_fastly_image_optimizer(&mut plain, options()).expect("should translate the options"); + assert_eq!( + plain.get_query_str(), + None, + "an unsigned route should keep the optimizer's default metadata removal" + ); + + let mut preserved = fastly::Request::get("https://assets.example.com/photo.jpg?v=2"); + apply_fastly_image_optimizer(&mut preserved, options().with_c2pa_preserved()) + .expect("should translate the options"); + assert_eq!( + preserved.get_query_str(), + Some("v=2&metadata=c2pa"), + "should ask for C2PA preservation and keep every other parameter as sent" + ); + + for asked in [ + "metadata=copyright", + "metadata=Copyright%2Cc2pa", + "metadata=c2pa,copyright", + ] { + let mut with_copyright = fastly::Request::get(format!( + "https://assets.example.com/photo.jpg?v=2&{asked}&x-id=a%20b" + )); + apply_fastly_image_optimizer(&mut with_copyright, options().with_c2pa_preserved()) + .expect("should translate the options"); + assert_eq!( + with_copyright.get_query_str(), + Some("v=2&x-id=a%20b&metadata=copyright,c2pa"), + "{asked}: should apply both filters once, keeping the copyright filter" + ); + } + } + #[test] fn fastly_platform_http_client_supports_pending_streaming_responses() { let client = FastlyPlatformHttpClient; diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 333998b51e..1b000cacd3 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -29,6 +29,8 @@ edgezero-core = { workspace = true } error-stack = { workspace = true } flate2 = { workspace = true } futures = { workspace = true } +# Frames for the streamed response body handed to the Spin host (src/lib.rs). +http-body = { workspace = true } http-body-util = { workspace = true } log = { workspace = true } trusted-server-ad-server-iabtl = { workspace = true } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index abb60761c2..2b64aaa968 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -481,7 +481,9 @@ impl AppState { if let Some(module) = self.registry.device_module() { services = services.with_device_module(module); } - services = services.with_identifier_modules(self.registry.identifier_modules()); + services = services + .with_identifier_modules(self.registry.identifier_modules()) + .with_media_signer(self.registry.media_signer()); // The publisher this request resolved to names every key in a shared // store and every name the browser holds. let prefixes = self.registry.prefixes( @@ -1265,8 +1267,15 @@ async fn dispatch_asset_fallback( let asset_route = &state.settings.proxy.asset_routes[index]; let answer = match state.registry.route_source(&state.settings, index) { Ok(source) => { - handle_asset_proxy_request(&state.settings, services, req, asset_route, source.as_ref()) - .await + handle_asset_proxy_request( + &state.settings, + services, + &state.registry, + req, + asset_route, + source.as_ref(), + ) + .await } Err(report) => Err(report), }; diff --git a/crates/trusted-server-adapter-spin/src/lib.rs b/crates/trusted-server-adapter-spin/src/lib.rs index 502940a017..937e35862e 100644 --- a/crates/trusted-server-adapter-spin/src/lib.rs +++ b/crates/trusted-server-adapter-spin/src/lib.rs @@ -5,15 +5,222 @@ pub mod logging; pub mod middleware; pub mod platform; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_adapter_spin::key_value_store::{DEFAULT_MAX_LIST_KEYS, SpinKvStore}; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::app::{Hooks, StoreMetadata}; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::env_config::EnvConfig; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::store_registry::KvRegistry; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use spin_sdk::http::{IntoResponse, Request}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] use spin_sdk::http_service; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +use futures::StreamExt as _; + +/// The body of every response this component sends: a buffered body as the +/// router built it, or a streamed body passed to the host frame by frame. +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +type ResponseBody = http_body_util::Either< + http_body_util::Full, + http_body_util::StreamBody< + futures::stream::LocalBoxStream< + 'static, + Result, anyhow::Error>, + >, + >, +>; + #[cfg(all(feature = "spin", target_arch = "wasm32"))] #[http_service] -// FORCED: edgezero_adapter_spin::run_app returns anyhow::Result — EdgeZero SDK constraint, not a project choice. +// FORCED: the Spin `http_service` boundary takes this `anyhow::Result`, the +// error type `edgezero_adapter_spin::run_app` returned before this streaming +// dispatch replaced it, so failures reach the host exactly as they did. async fn handle(req: Request) -> anyhow::Result { logging::init_logger(); - edgezero_adapter_spin::run_app::(req).await + dispatch::(req).await +} + +/// Dispatch a request through the router and hand any streamed body to the +/// host as it is produced. +/// +/// This replaces `edgezero_adapter_spin::run_app`, whose response conversion +/// collects a `Body::Stream` into one buffer, capped at 16 MiB, before the +/// host sees a byte. `EdgeZero` keeps the registry construction that wrapper +/// performs private, so the declared KV stores are opened here with the +/// same settings and error, see [`kv_registry`]. Request conversion and +/// routing are `EdgeZero`'s own. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +async fn dispatch( + req: Request, +) -> anyhow::Result> { + let app = A::build_app(); + let stores = A::stores(); + // `run_app` would also open declared config and secret stores. This app + // declares neither, so one appearing here must fail loudly rather than + // reach the router unbound. + if stores.config.is_some() || stores.secrets.is_some() { + anyhow::bail!( + "the streaming Spin dispatch binds declared KV stores alone, \ + but the app declares a config or secret store" + ); + } + let env = EnvConfig::from_env(); + let kv_registry = kv_registry(stores.kv, &env).await?; + let mut core_request = edgezero_adapter_spin::request::into_core_request(req).await?; + if let Some(registry) = kv_registry { + core_request.extensions_mut().insert(registry); + } + let response = app.router().oneshot(core_request).await?; + let (parts, body) = response.into_parts(); + Ok(spin_sdk::http::Response::::from_parts( + parts, + response_body(body), + )) +} + +/// Open every declared KV store, as `EdgeZero`'s Spin dispatch does. +/// +/// Each id opens the label `EDGEZERO__STORES__KV____NAME` names, with +/// the list cap `EDGEZERO__STORES__KV____MAX_LIST_KEYS` sets. A declared +/// store that cannot be opened fails the request, because a declared store +/// is required. +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +async fn kv_registry( + meta: Option, + env: &EnvConfig, +) -> anyhow::Result> { + let Some(meta) = meta else { + return Ok(None); + }; + let mut by_id = std::collections::BTreeMap::new(); + for id in meta.ids { + let label = env.store_name("kv", id); + let max_list_keys = env + .store_setting("kv", id, "MAX_LIST_KEYS") + .and_then(|raw| raw.parse::().ok()) + .unwrap_or(DEFAULT_MAX_LIST_KEYS); + let store = SpinKvStore::open_with_max_list_keys(&label, max_list_keys) + .await + .map_err(|err| { + anyhow::anyhow!( + "Spin KV store '{label}' (id `{id}`) is explicitly configured but could not be opened: {err}" + ) + })?; + by_id.insert( + (*id).to_owned(), + edgezero_core::key_value_store::KvHandle::new(std::sync::Arc::new(store)), + ); + } + Ok(KvRegistry::from_parts(by_id, meta.default.to_owned())) +} + +/// The host body for a router body. +/// +/// A buffered body stays one buffer. A streamed body becomes one frame per +/// chunk, read only when the host asks for the next frame, so it never +/// holds more than one chunk. A chunk error is passed on as a body error, +/// which aborts the response instead of ending it as though it were +/// complete. +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +fn response_body(body: edgezero_core::body::Body) -> ResponseBody { + match body { + edgezero_core::body::Body::Once(bytes) => { + http_body_util::Either::Left(http_body_util::Full::new(bytes)) + } + edgezero_core::body::Body::Stream(stream) => { + http_body_util::Either::Right(http_body_util::StreamBody::new( + stream + .map(|chunk| chunk.map(http_body::Frame::data)) + .boxed_local(), + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures::FutureExt as _; + use http_body_util::BodyExt as _; + + #[tokio::test] + async fn a_streamed_body_reaches_the_host_one_chunk_at_a_time() { + let (sender, receiver) = + futures::channel::mpsc::unbounded::>(); + sender + .unbounded_send(Ok(Bytes::from_static(b"first"))) + .expect("should queue the first chunk"); + let mut body = response_body(edgezero_core::body::Body::from_stream(receiver)); + + let first = body + .frame() + .await + .expect("the first frame should arrive") + .expect("the first frame should be readable") + .into_data() + .expect("the first frame should carry data"); + assert_eq!(first.as_ref(), b"first"); + assert!( + body.frame().now_or_never().is_none(), + "should wait for the next chunk rather than end the body" + ); + + sender + .unbounded_send(Ok(Bytes::from_static(b"second"))) + .expect("should queue the second chunk"); + drop(sender); + let second = body + .frame() + .await + .expect("the second frame should arrive") + .expect("the second frame should be readable") + .into_data() + .expect("the second frame should carry data"); + assert_eq!(second.as_ref(), b"second"); + assert!(body.frame().await.is_none(), "should end with the stream"); + } + + #[tokio::test] + async fn a_failed_chunk_aborts_the_host_body() { + let stream = futures::stream::iter([ + Ok(Bytes::from_static(b"partial")), + Err(std::io::Error::other("origin connection reset")), + ]); + let mut body = response_body(edgezero_core::body::Body::from_stream(stream)); + + body.frame() + .await + .expect("the chunk before the failure should arrive") + .expect("the chunk before the failure should be readable"); + let error = body + .frame() + .await + .expect("the failure should surface") + .expect_err("a failed chunk must not look like the end of the body"); + assert!( + error.to_string().contains("origin connection reset"), + "should carry the failure: {error}" + ); + } + + #[tokio::test] + async fn a_buffered_body_stays_one_buffer() { + let body = response_body(edgezero_core::body::Body::from(b"whole".to_vec())); + + assert!( + matches!(body, http_body_util::Either::Left(_)), + "a buffered router body should not become a stream" + ); + let collected = body + .collect() + .await + .expect("a buffered body should be readable") + .to_bytes(); + assert_eq!(collected.as_ref(), b"whole"); + } } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 5a553a6328..e16091b385 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -18,6 +18,8 @@ use trusted_server_core::platform::{ #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] use trusted_server_core::platform::UnavailableHttpClient; +#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use spin_sdk::http::body::IncomingBodyExt as _; #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] use std::io::Read as _; #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] @@ -27,6 +29,9 @@ use trusted_server_core::platform::{ PlatformPendingRequest, PlatformResponse, PlatformSelectResult, }; +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +mod response_stream; + // 8 MiB ceiling: conservative for ad-server responses while leaving headroom in // the Spin WASM component heap. Larger responses from misbehaving origins are // rejected with a typed error rather than OOMing the component. @@ -399,9 +404,22 @@ fn reject_unsupported_request_contracts( return Err(Report::new(PlatformError::HttpClient) .attach("Spin outbound HTTP does not support Image Optimizer metadata")); } + Ok(()) +} + +/// Refuse a streamed response on the buffered `send_async` path. +/// +/// `send_async` stores a completed, buffered response for `select`, so a +/// request that asks for a stream must go through `send` instead of being +/// silently buffered. The check runs before the outbound request is sent. +#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] +fn reject_buffered_stream_request( + request: &PlatformHttpRequest, +) -> Result<(), Report> { if request.stream_response { - return Err(Report::new(PlatformError::HttpClient) - .attach("Spin outbound HTTP does not support streaming responses")); + return Err(Report::new(PlatformError::HttpClient).attach( + "Spin outbound HTTP streams responses through send alone; send_async buffers", + )); } Ok(()) } @@ -527,6 +545,15 @@ struct SpinPendingResponse { /// `spin_sdk::http::send`-backed HTTP client for the Spin runtime. /// +/// [`send`](PlatformHttpClient::send) preserves the origin body as a +/// `Body::Stream` when the request asks for it, reading the WASI incoming +/// body one host chunk at a time, see +/// [`response_stream::bounded_origin_stream`]. A streamed body is the +/// origin's bytes as sent, so it keeps its `Content-Encoding` and +/// `Content-Length`, and it honors the caller's exact +/// `max_response_body_bytes`, if any, instead of the buffered path's 8 MiB +/// ceiling. The buffered path still decodes gzip and Brotli bodies. +/// /// `send_async` eagerly executes each request before returning, so /// [`PlatformHttpClient::supports_concurrent_fanout`] reports `false` and the /// auction orchestrator rejects multi-provider configurations before any @@ -557,7 +584,9 @@ impl SpinPlatformHttpClient { request: PlatformHttpRequest, ) -> Result> { reject_unsupported_request_contracts(&request)?; - let response_limit = effective_response_body_limit(request.max_response_body_bytes); + let stream_response = request.stream_response; + let caller_limit = request.max_response_body_bytes; + let response_limit = effective_response_body_limit(caller_limit); let method = request.request.method().clone(); let uri = request.request.uri().to_string(); @@ -616,34 +645,61 @@ impl SpinPlatformHttpClient { .map(|(name, value)| (name.to_string(), value.as_bytes().to_vec())) .collect(); let response_has_body = !response_must_not_have_body(&method, status); + let claimed_len = headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| std::str::from_utf8(value).ok()) + .and_then(|value| value.trim().parse::().ok()); + // A streamed body holds one chunk at a time, so only the caller's own + // limit applies to it. A buffered body is also held to the ceiling. + let enforced_limit = if stream_response { + caller_limit + } else { + Some(response_limit) + }; if response_has_body - && headers - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) - .and_then(|(_, value)| std::str::from_utf8(value).ok()) - .and_then(|value| value.trim().parse::().ok()) - .is_some_and(|length| length > response_limit) + && let Some(limit) = enforced_limit + && let Some(claimed_len) = claimed_len + && u64::try_from(limit).is_ok_and(|limit| claimed_len > limit) { return Err(Report::new(PlatformError::HttpClient).attach(format!( - "origin Content-Length exceeds {response_limit}-byte response body limit" + "origin Content-Length exceeds {limit}-byte response body limit" ))); } - let body = if response_has_body { - use spin_sdk::http::body::IncomingBodyExt as _; - collect_bounded_response_stream(spin_response.into_body().stream(), response_limit) - .await? + + let (headers, body) = if stream_response { + let body = if response_has_body { + response_stream::bounded_origin_stream( + spin_response.into_body().stream(), + caller_limit, + claimed_len, + ) + } else { + edgezero_core::body::Body::empty() + }; + (sanitize_response_headers(headers), body) } else { - Vec::new() + let body = if response_has_body { + collect_bounded_response_stream(spin_response.into_body().stream(), response_limit) + .await? + } else { + Vec::new() + }; + let (headers, body) = apply_spin_response_policy_with_limit( + &method, + status, + headers, + body, + response_limit, + )?; + (headers, edgezero_core::body::Body::from(body)) }; - - let (headers, body) = - apply_spin_response_policy_with_limit(&method, status, headers, body, response_limit)?; let mut edge_builder = edgezero_core::http::response_builder().status(status); for (name, value) in headers { edge_builder = edge_builder.header(name.as_str(), value.as_slice()); } let edge_resp = edge_builder - .body(edgezero_core::body::Body::from(body)) + .body(body) .change_context(PlatformError::HttpClient)?; Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) @@ -660,6 +716,10 @@ impl PlatformHttpClient for SpinPlatformHttpClient { false } + fn supports_streaming_responses(&self) -> bool { + true + } + async fn send( &self, request: PlatformHttpRequest, @@ -671,6 +731,7 @@ impl PlatformHttpClient for SpinPlatformHttpClient { &self, request: PlatformHttpRequest, ) -> Result> { + reject_buffered_stream_request(&request)?; let backend_name = request.backend_name.clone(); let response = self.execute(request).await?; @@ -1340,12 +1401,20 @@ mod tests { } #[test] - fn unsupported_request_contracts_reject_stream_response() { + fn send_streams_a_response_and_send_async_refuses_to_buffer_one() { let request = platform_request().with_stream_response(); assert!( - reject_unsupported_request_contracts(&request).is_err(), - "Spin must reject streaming-response requests instead of buffering silently" + reject_unsupported_request_contracts(&request).is_ok(), + "send should accept a streamed-response request" + ); + assert!( + reject_buffered_stream_request(&request).is_err(), + "the buffered send_async path must refuse it rather than buffer silently" + ); + assert!( + reject_buffered_stream_request(&platform_request()).is_ok(), + "a buffered request should still use send_async" ); } diff --git a/crates/trusted-server-adapter-spin/src/platform/response_stream.rs b/crates/trusted-server-adapter-spin/src/platform/response_stream.rs new file mode 100644 index 0000000000..df6474af64 --- /dev/null +++ b/crates/trusted-server-adapter-spin/src/platform/response_stream.rs @@ -0,0 +1,306 @@ +//! Origin response bodies passed on as Spin's outbound HTTP delivers them. +//! +//! A streamed response never holds more than the chunk the runtime handed +//! over last. The caller's byte limit and the origin's declared length are +//! checked chunk by chunk, so an oversized, truncated or failed origin body +//! ends the stream with an error instead of reaching the caller as a +//! complete one. + +use std::fmt::Display; +use std::pin::Pin; + +use bytes::Bytes; +use futures::{Stream, StreamExt as _}; + +/// The source and the number of bytes already yielded from it. +struct Progress { + source: Pin>, + yielded: usize, +} + +/// A byte count in the unit of a declared `Content-Length`. A count no +/// `u64` can hold is past any declared length. +fn declared_units(bytes: usize) -> u64 { + u64::try_from(bytes).unwrap_or(u64::MAX) +} + +/// Wrap a runtime body stream as a [`Body::Stream`](edgezero_core::body::Body::Stream). +/// +/// `limit` is the caller's `max_response_body_bytes`. The chunk that would +/// carry the running total past it is replaced by an error, and nothing +/// after it is read. +/// +/// `expected_length` is the origin's `Content-Length`. Spin hands the body +/// over exactly as the origin encoded it, so the declared length describes +/// the bytes this stream yields. A body that ends short of the declared +/// length, or runs past it, ends with an error rather than reaching the +/// caller as a complete one, whatever the host's own framing checks report. +/// Pass `None` when the origin declared no length. +/// +/// A source error is passed on as a stream error and ends the stream. +pub(super) fn bounded_origin_stream( + source: S, + limit: Option, + expected_length: Option, +) -> edgezero_core::body::Body +where + S: Stream> + 'static, + B: Into, + E: Display, +{ + let initial = Progress { + source: Box::pin(source), + yielded: 0, + }; + let stream = futures::stream::unfold(Some(initial), move |state| async move { + let mut progress = state?; + match progress.source.next().await { + None => { + if let Some(expected) = expected_length + && declared_units(progress.yielded) != expected + { + return Some(( + Err(std::io::Error::other(format!( + "Spin outbound response body ended after {} of its declared {expected} bytes", + progress.yielded + ))), + None, + )); + } + None + } + Some(Err(error)) => Some(( + Err(std::io::Error::other(format!( + "failed to read Spin outbound response body: {error}" + ))), + None, + )), + Some(Ok(chunk)) => { + let chunk: Bytes = chunk.into(); + let Some(next_yielded) = progress.yielded.checked_add(chunk.len()) else { + return Some(( + Err(std::io::Error::other( + "Spin outbound response body byte count overflowed", + )), + None, + )); + }; + if let Some(limit) = limit + && next_yielded > limit + { + return Some(( + Err(std::io::Error::other(format!( + "Spin outbound response body exceeded caller limit of {limit} bytes" + ))), + None, + )); + } + if let Some(expected) = expected_length + && declared_units(next_yielded) > expected + { + return Some(( + Err(std::io::Error::other(format!( + "Spin outbound response body ran past its declared {expected} bytes" + ))), + None, + )); + } + progress.yielded = next_yielded; + Some((Ok(chunk), Some(progress))) + } + } + }); + edgezero_core::body::Body::from_stream(stream) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::FutureExt as _; + + type ChunkResult = Result, std::io::Error>; + + fn into_stream( + body: edgezero_core::body::Body, + ) -> futures::stream::LocalBoxStream<'static, Result> { + body.into_stream() + .expect("a streamed origin body should stay a stream") + } + + #[tokio::test] + async fn each_chunk_is_yielded_before_the_origin_finishes() { + let (sender, receiver) = futures::channel::mpsc::unbounded::(); + sender + .unbounded_send(Ok(b"ab".to_vec())) + .expect("should queue the first chunk"); + let mut stream = into_stream(bounded_origin_stream(receiver, None, None)); + + let first = stream + .next() + .await + .expect("the first chunk should arrive") + .expect("the first chunk should be readable"); + assert_eq!( + first.as_ref(), + b"ab", + "should pass the first chunk on as it arrived" + ); + assert!( + stream.next().now_or_never().is_none(), + "should wait for the origin rather than report an end it has not sent" + ); + + sender + .unbounded_send(Ok(b"cd".to_vec())) + .expect("should queue the second chunk"); + drop(sender); + let second = stream + .next() + .await + .expect("the second chunk should arrive") + .expect("the second chunk should be readable"); + assert_eq!(second.as_ref(), b"cd", "should pass the later chunk on"); + assert!(stream.next().await.is_none(), "should end with the origin"); + } + + #[tokio::test] + async fn an_unbounded_stream_passes_more_than_the_buffered_ceiling() { + // Eleven 1 MiB chunks cross the 8 MiB ceiling the buffered path + // enforces. The chunks share one allocation, so the test holds 1 MiB. + let chunk = Bytes::from(vec![7u8; 1024 * 1024]); + let chunks = std::iter::repeat_n(chunk, 11).map(Ok::<_, std::io::Error>); + let mut stream = into_stream(bounded_origin_stream( + futures::stream::iter(chunks), + None, + None, + )); + + let mut total = 0usize; + while let Some(chunk) = stream.next().await { + total += chunk.expect("an unbounded chunk should be readable").len(); + } + assert_eq!(total, 11 * 1024 * 1024, "should pass every byte on"); + } + + #[tokio::test] + async fn the_chunk_that_crosses_the_caller_limit_ends_the_stream() { + let source = futures::stream::iter([ + Ok::<_, std::io::Error>(b"ab".to_vec()), + Ok(b"cd".to_vec()), + Ok(b"e".to_vec()), + Ok(b"never read".to_vec()), + ]); + let mut stream = into_stream(bounded_origin_stream(source, Some(4), None)); + + for expected in [b"ab", b"cd"] { + let chunk = stream + .next() + .await + .expect("a chunk within the limit should arrive") + .expect("a chunk within the limit should be readable"); + assert_eq!( + chunk.as_ref(), + expected, + "should pass chunks up to the exact limit" + ); + } + let error = stream + .next() + .await + .expect("the crossing chunk should surface") + .expect_err("the crossing chunk must be refused"); + assert!( + error.to_string().contains("limit of 4 bytes"), + "should report the enforced limit: {error}" + ); + assert!( + stream.next().await.is_none(), + "should read nothing after the refused chunk" + ); + } + + #[tokio::test] + async fn an_origin_failure_is_passed_on_and_ends_the_stream() { + let source = futures::stream::iter([ + Ok(b"ab".to_vec()), + Err(std::io::Error::other("connection reset by origin")), + Ok(b"never read".to_vec()), + ]); + let mut stream = into_stream(bounded_origin_stream(source, Some(64), None)); + + let first = stream + .next() + .await + .expect("the chunk before the failure should arrive") + .expect("the chunk before the failure should be readable"); + assert_eq!(first.as_ref(), b"ab"); + let error = stream + .next() + .await + .expect("the failure should surface") + .expect_err("the origin failure must not look like data"); + assert!( + error.to_string().contains("connection reset by origin"), + "should carry the origin's failure: {error}" + ); + assert!(stream.next().await.is_none(), "should end at the failure"); + } + + #[tokio::test] + async fn a_body_cut_short_of_its_declared_length_is_an_error() { + // An origin connection that closes early must not end the body cleanly. + let source = futures::stream::iter([Ok::<_, std::io::Error>(b"ab".to_vec())]); + let mut stream = into_stream(bounded_origin_stream(source, None, Some(4))); + + let first = stream + .next() + .await + .expect("the delivered bytes should arrive") + .expect("the delivered bytes should be readable"); + assert_eq!(first.as_ref(), b"ab"); + let error = stream + .next() + .await + .expect("the early end should surface") + .expect_err("a truncated body must not end cleanly"); + assert!( + error.to_string().contains("2 of its declared 4 bytes"), + "should report how much of the declared body arrived: {error}" + ); + } + + #[tokio::test] + async fn a_body_running_past_its_declared_length_is_an_error() { + let source = + futures::stream::iter([Ok::<_, std::io::Error>(b"abc".to_vec()), Ok(b"de".to_vec())]); + let mut stream = into_stream(bounded_origin_stream(source, None, Some(4))); + + stream + .next() + .await + .expect("the bytes within the declared length should arrive") + .expect("the bytes within the declared length should be readable"); + let error = stream + .next() + .await + .expect("the overrun should surface") + .expect_err("bytes past the declared length must be refused"); + assert!( + error.to_string().contains("declared 4 bytes"), + "should report the declared length: {error}" + ); + } + + #[tokio::test] + async fn a_body_matching_its_declared_length_ends_cleanly() { + let source = + futures::stream::iter([Ok::<_, std::io::Error>(b"ab".to_vec()), Ok(b"cd".to_vec())]); + let mut stream = into_stream(bounded_origin_stream(source, Some(4), Some(4))); + + let mut body = Vec::new(); + while let Some(chunk) = stream.next().await { + body.extend_from_slice(&chunk.expect("a declared-length body should be readable")); + } + assert_eq!(body, b"abcd", "should deliver the whole declared body"); + } +} diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index ec64a217dd..935f0e1aa6 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -29,6 +29,7 @@ futures = { workspace = true } glob = { workspace = true } hex = { workspace = true } hmac = { workspace = true } +html5ever = { workspace = true } http = { workspace = true } httpdate = { workspace = true } jose-jwk = { workspace = true } diff --git a/crates/trusted-server-core/benches/html_processor_bench.rs b/crates/trusted-server-core/benches/html_processor_bench.rs index 962001fc22..39753700c3 100644 --- a/crates/trusted-server-core/benches/html_processor_bench.rs +++ b/crates/trusted-server-core/benches/html_processor_bench.rs @@ -8,6 +8,8 @@ use trusted_server_core::streaming_processor::StreamProcessor as _; fn make_config() -> HtmlProcessorConfig { HtmlProcessorConfig { csp_nonce_observed: None, + rehost: trusted_server_core::rehost::PageRehost::default(), + response_headers: None, public_prefix: None, origin_host: "origin.bench.example.com".to_string(), request_host: "proxy.bench.example.com".to_string(), diff --git a/crates/trusted-server-core/src/asset_image_optimizer.rs b/crates/trusted-server-core/src/asset_image_optimizer.rs index bbc2393fb4..971c0ecf47 100644 --- a/crates/trusted-server-core/src/asset_image_optimizer.rs +++ b/crates/trusted-server-core/src/asset_image_optimizer.rs @@ -418,6 +418,7 @@ mod tests { region: "us_east".to_string(), profile_set: "default_images".to_string(), origin_query: None, + signed_source: false, }); let err = options_for_asset_request(&settings, &route, "") diff --git a/crates/trusted-server-core/src/creative.rs b/crates/trusted-server-core/src/creative.rs index e37093b2f3..235f3e627c 100644 --- a/crates/trusted-server-core/src/creative.rs +++ b/crates/trusted-server-core/src/creative.rs @@ -180,12 +180,71 @@ fn rewrite_style_urls_in_context( base_origin: &str, allows_import_rules: bool, output_limit: usize, +) -> Result { + rewrite_css_urls( + &mut CreativeProxy { + settings, + base_origin, + }, + style, + allows_import_rules, + output_limit, + ) +} + +/// Where in a stylesheet a URL was read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CssUrlContext { + /// The stylesheet an `@import` loads. + Import, + /// Anything else the browser fetches: an image, a font, a cursor. + Resource, +} + +/// Decides what each URL a stylesheet names becomes, as +/// [`rewrite_css_urls`] reads them. +pub(crate) trait CssUrlPolicy { + /// The address to write in place of `value`, the URL as the browser reads + /// it with its escapes resolved, or `None` to keep its original bytes. + fn replace(&mut self, value: &str, context: CssUrlContext) -> Option; +} + +/// The creative policy: an absolute or protocol-relative address goes +/// through the signed first-party proxy, see [`build_proxy_url`]. +struct CreativeProxy<'a> { + settings: &'a Settings, + base_origin: &'a str, +} + +impl CssUrlPolicy for CreativeProxy<'_> { + fn replace(&mut self, value: &str, _context: CssUrlContext) -> Option { + let absolute = to_abs(self.settings, value)?; + Some(build_proxy_url(self.settings, &absolute, self.base_origin)) + } +} + +/// Rewrites every URL `style` names as `policy` decides, within the nesting +/// and output-size limits, keeping every other byte. A reference keeps the +/// shape it was read in, see [`rewrite_style_urls`]. +/// +/// `allows_import_rules` says whether `style` is a stylesheet, where +/// `@import` can begin a rule, rather than a style attribute. +/// +/// # Errors +/// +/// When the CSS nests past [`MAX_CSS_NESTING_DEPTH`] or the output would be +/// larger than `output_limit`. +pub(crate) fn rewrite_css_urls( + policy: &mut dyn CssUrlPolicy, + style: &str, + allows_import_rules: bool, + output_limit: usize, ) -> Result { let mut rewriter = CssUrlRewriter { - settings, + policy, style, - base_origin, allows_import_rules, + in_import: false, out: CssOutput { value: String::with_capacity(style.len().min(output_limit)), limit: output_limit, @@ -229,13 +288,15 @@ impl fmt::Write for CssOutput { } } -/// Splices rewritten `url()` values into a copy of the original CSS. +/// Splices the URLs a policy replaces into a copy of the original CSS. struct CssUrlRewriter<'a> { - settings: &'a Settings, + policy: &'a mut dyn CssUrlPolicy, style: &'a str, - base_origin: &'a str, /// Whether the outermost scope is a stylesheet that may contain `@import`. allows_import_rules: bool, + /// Whether the walk is inside an `@import` prelude, whose URL names a + /// stylesheet. + in_import: bool, out: CssOutput, write_pos: usize, /// Any refusal invalidates the entire rewrite. @@ -340,7 +401,9 @@ impl CssUrlRewriter<'_> { let _ = parser.parse_until_before( cssparser::Delimiter::Semicolon | cssparser::Delimiter::CurlyBracketBlock, |prelude| -> Result<(), cssparser::ParseError<'_, ()>> { + let outer = std::mem::replace(&mut self.in_import, true); self.descend(prelude, depth, BareStringUrls::FirstValue); + self.in_import = outer; Ok(()) }, ); @@ -390,8 +453,8 @@ impl CssUrlRewriter<'_> { self.walk(parser, depth + 1, strings_are_urls); } - /// Replaces the span just consumed with a proxied reference in `shape`, or - /// leaves it. + /// Replaces the span just consumed with the policy's address in `shape`, + /// or leaves it. fn rewrite( &mut self, value: &str, @@ -399,14 +462,19 @@ impl CssUrlRewriter<'_> { parser: &cssparser::Parser<'_, '_>, shape: UrlShape, ) { - let Some(absolute) = to_abs(self.settings, value) else { + let context = if self.in_import { + CssUrlContext::Import + } else { + CssUrlContext::Resource + }; + let Some(replacement) = self.policy.replace(value, context) else { return; }; let token_end = parser.position().byte_index(); let result = (|| -> fmt::Result { self.out .write_str(&self.style[self.write_pos..token_start])?; - let proxied = build_proxy_url(self.settings, &absolute, self.base_origin); + let proxied = replacement; if let UrlShape::Function(name) = shape { self.out.write_str(name)?; self.out.write_char('(')?; diff --git a/crates/trusted-server-core/src/css_relocation.rs b/crates/trusted-server-core/src/css_relocation.rs new file mode 100644 index 0000000000..c0386421ca --- /dev/null +++ b/crates/trusted-server-core/src/css_relocation.rs @@ -0,0 +1,231 @@ +//! A stylesheet a rehosting route serves from the reader's host, its own +//! addresses moved with it and its `[[serve]]` entry run on it, see +//! [`crate::rehost`]. +//! +//! A stylesheet's relative and root-relative addresses resolve against the +//! address it is fetched from, so serving it from another address changes +//! what they name. Every `url()`, `src()`, `image-set()` candidate and +//! `@import` the browser would fetch is read with core's CSS tokenizer, +//! resolved against the stylesheet's upstream address, and written as the +//! address that fetches the same thing: a rehosting route's address where +//! one serves it, the absolute upstream address otherwise. A fragment-only +//! reference and `data:`, `blob:` and `about:` addresses are left as written. +//! An `@import` moves to a route only where that route moves stylesheets. + +use bytes::Bytes; +use error_stack::Report; +use http::Method; +use url::Url; + +use crate::cache_policy::{CachePolicy, CacheVisibility}; +use crate::creative::{CssUrlContext, CssUrlPolicy, rewrite_css_urls}; +use crate::error::TrustedServerError; +use crate::integrations::{IntegrationRegistry, IntegrationRequestState}; +use crate::media_signing::OriginFreshness; +use crate::middleware::{MiddlewarePhase, Prepared, ResponseHeader}; +use crate::module_context::{ModuleContext, ModuleRequest}; +use crate::platform::RuntimeServices; +use crate::rehost::{PageAddress, Reference, RehostRoutes}; +use crate::settings::Settings; +use crate::streaming_processor::StreamProcessor as _; + +/// The largest stylesheet a rehosting route moves, before and after its +/// addresses are moved. +pub(crate) const MAX_STYLESHEET_BYTES: usize = 10 * 1024 * 1024; + +/// Keep buffered bytes without copying. Streamed chunks are checked against +/// the remaining capacity by the shared upstream collector before appending. +pub(crate) async fn collect_stylesheet( + body: edgezero_core::body::Body, +) -> Result> { + match body { + edgezero_core::body::Body::Once(bytes) if bytes.len() <= MAX_STYLESHEET_BYTES => Ok(bytes), + body => crate::integrations::collect_response_bounded( + body, + MAX_STYLESHEET_BYTES, + "stylesheet_relocation", + ) + .await + .map(Bytes::from), + } +} + +/// A relocated answer cannot outlive its source or the route's policy. +/// Stale allowances and immutability do not transfer to these mutable URLs. +pub(crate) fn bounded_cache_policy( + remaining: OriginFreshness, + route: Option, +) -> CachePolicy { + CachePolicy { + visibility: route.map_or(CacheVisibility::Public, |policy| policy.visibility), + browser_ttl: Some( + route + .and_then(|policy| policy.browser_ttl) + .map_or(remaining.browser, |cap| remaining.browser.min(cap)), + ), + edge_ttl: Some( + route + .and_then(|policy| policy.edge_ttl) + .map_or(remaining.shared, |cap| remaining.shared.min(cap)), + ), + stale_while_revalidate: None, + stale_if_error: None, + immutable: false, + } +} + +/// The route policy: each address resolved against the stylesheet's +/// upstream address and written as the address that fetches the same thing. +struct RouteAddresses<'a> { + stylesheet: &'a Url, + routes: &'a RehostRoutes, + page: PageAddress<'a>, +} + +impl CssUrlPolicy for RouteAddresses<'_> { + fn replace(&mut self, value: &str, context: CssUrlContext) -> Option { + let value = value.trim(); + if value.is_empty() || value.starts_with('#') { + return None; + } + let resolved = self.stylesheet.join(value).ok()?; + if !matches!(resolved.scheme(), "http" | "https") { + return None; + } + let reference = match context { + CssUrlContext::Import => Reference::Style, + CssUrlContext::Resource => Reference::Media, + }; + Some( + self.routes + .rehosted(&resolved, reference, &self.page) + .unwrap_or_else(|| resolved.to_string()), + ) + } +} + +/// Why a rehosted stylesheet goes to the reader at its upstream address +/// instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum NotRelocated { + /// The body is not UTF-8, or names another charset with `@charset`. + #[display("stylesheet_not_utf8")] + NotUtf8, + /// The tokenizer refused it: nested too deep or grown too large. + #[display("stylesheet_not_rewritable")] + NotRewritable, + /// A middleware of its `[[serve]]` entry refused it. + #[display("stylesheet_refused")] + Refused, +} + +/// One stylesheet a rehosting route serves. +pub(crate) struct Stylesheet<'a> { + pub settings: &'a Settings, + pub services: &'a RuntimeServices, + pub integration_registry: &'a IntegrationRegistry, + /// The address the stylesheet was fetched from. + pub upstream: &'a Url, + /// Its `Content-Type`. + pub content_type: &'a str, + /// The route path the reader asked for, after any prefix request + /// addressing removed. + pub route_path: &'a str, + pub page: PageAddress<'a>, + /// The headers of the reader's response, as the middleware are told + /// them. + pub response_headers: &'a http::HeaderMap, +} + +impl Stylesheet<'_> { + /// `body` with its addresses moved and its `[[serve]]` entry's + /// middleware run on it, with the headers those middleware write. + /// + /// # Errors + /// + /// Why the stylesheet cannot be served from the route. + pub(crate) async fn relocate( + &self, + body: &Bytes, + ) -> Result<(Vec, Vec), NotRelocated> { + let text = std::str::from_utf8(body).map_err(|_| NotRelocated::NotUtf8)?; + if names_another_charset(text) { + return Err(NotRelocated::NotUtf8); + } + let routes = RehostRoutes::of(self.settings); + let relocated = rewrite_css_urls( + &mut RouteAddresses { + stylesheet: self.upstream, + routes: &routes, + page: self.page, + }, + text, + true, + MAX_STYLESHEET_BYTES, + ) + .map_err(|_| NotRelocated::NotRewritable)?; + + let prepared = Prepared::default(); + let request_state = IntegrationRequestState::default(); + let document = crate::publisher::TextResponse { + content_type: self.content_type, + status: http::StatusCode::OK, + origin_private: false, + response_headers: Some(self.response_headers), + may_be_stored: false, + request_path: self.route_path, + request_host: self.page.host, + request_scheme: self.page.scheme, + origin_host: self.upstream.host_str().unwrap_or_default(), + request_state: &request_state, + max_buffered_script_bytes: self.settings.publisher.max_buffered_body_bytes, + prepared: &prepared, + }; + // Served to every reader alike, so prepared with no reader's state. + let services = self.services.clone().without_connection(); + let removed_prefix = + (!self.page.removed_prefix.is_empty()).then_some(self.page.removed_prefix); + let method = Method::GET; + let context = ModuleContext::new( + ModuleRequest::new(&method, self.page.host, self.page.scheme, self.route_path) + .with_removed_prefix(removed_prefix), + ) + .with_settings(self.settings) + .with_services(&services); + crate::publisher::prepare_document( + &document, + &[(MiddlewarePhase::Serve, &context)], + self.settings, + self.integration_registry, + ) + .await + .map_err(|_| NotRelocated::Refused)?; + let (mut processor, headers) = crate::publisher::middleware_stream_processors( + &document, + &[MiddlewarePhase::Serve], + self.settings, + self.integration_registry, + ) + .map_err(|_| NotRelocated::Refused)? + .into_middleware_processor(); + let signed = processor + .finish(relocated.as_bytes(), self.services) + .await + .map_err(|_| NotRelocated::Refused)?; + Ok((signed, headers)) + } +} + +/// Whether a stylesheet's leading `@charset` names an encoding other than +/// UTF-8. +fn names_another_charset(text: &str) -> bool { + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let Some(rest) = text.strip_prefix("@charset \"") else { + return false; + }; + let name = rest.split('"').next().unwrap_or_default(); + !name.eq_ignore_ascii_case("utf-8") +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-core/src/css_relocation/tests.rs b/crates/trusted-server-core/src/css_relocation/tests.rs new file mode 100644 index 0000000000..a7c5614b43 --- /dev/null +++ b/crates/trusted-server-core/src/css_relocation/tests.rs @@ -0,0 +1,211 @@ +use super::*; +use crate::middleware::{PhaseEntries, PhaseEntry}; +use crate::settings::ProxyAssetRoute; +use crate::test_support::tests::create_test_settings; +use edgezero_core::body::Body as EdgeBody; +use std::time::Duration; + +const ROUTE: &str = "/_rehost/cdn.example.net/npm/"; + +fn settings_rehosting(stylesheets_under: &str) -> Settings { + let mut settings = create_test_settings(); + let mut route = ProxyAssetRoute::new(ROUTE, "https://cdn.example.net/npm"); + route.rehost_page_references = true; + settings.proxy.asset_routes.push(route); + settings.serve = PhaseEntries::new(vec![PhaseEntry { + media_type: "text/css".to_owned(), + path: Some(format!("{ROUTE}{stylesheets_under}")), + middleware: vec!["provenance.encypher.stylesheet".to_owned()], + }]); + settings +} + +fn relocated(settings: &Settings, css: &str) -> String { + let routes = RehostRoutes::of(settings); + let stylesheet = + Url::parse("https://cdn.example.net/npm/slick@1/slick/theme.css?ver=1").expect("url"); + rewrite_css_urls( + &mut RouteAddresses { + stylesheet: &stylesheet, + routes: &routes, + page: PageAddress { + scheme: "https", + host: "reader.example.com", + removed_prefix: "/www.publisher.example", + base: None, + }, + }, + css, + true, + MAX_STYLESHEET_BYTES, + ) + .expect("should rewrite") +} + +#[test] +fn a_moved_stylesheet_s_addresses_keep_naming_what_they_named() { + let css = concat!( + "@import \"../shared/base.css\" layer(theme);\n", + ".a{background:url(./ajax-loader.gif)}\n", + "@font-face{src:url(\"fonts/slick.woff\") format(\"woff\"),url('fonts/slick.svg#slick')}\n", + ".b{background:url(/root/x.png)}\n", + ".c{background:image-set(\"https://elsewhere.example/a.png\" 1x)}\n", + ".d{filter:url(#blur);background:url(data:image/png;base64,AAAA)}\n", + ".e{background:url(\"fonts\\2f esc.png\")}\n", + ); + let out = relocated(&settings_rehosting("slick@1/shared/"), css); + let route = format!("https://reader.example.com/www.publisher.example{ROUTE}"); + for expected in [ + // An approved stylesheet path moves with the route. + format!("@import \"{route}slick@1/shared/base.css\" layer(theme);"), + format!("url(\"{route}slick@1/slick/ajax-loader.gif\")"), + format!("url(\"{route}slick@1/slick/fonts/slick.woff\")"), + format!("url(\"{route}slick@1/slick/fonts/slick.svg#slick\")"), + format!("url(\"{route}slick@1/slick/fonts/esc.png\")"), + // Outside the folder, so the absolute upstream address. + "url(\"https://cdn.example.net/root/x.png\")".to_owned(), + "\"https://elsewhere.example/a.png\" 1x".to_owned(), + // Document-local and inline references stay as written. + "url(#blur)".to_owned(), + "url(data:image/png;base64,AAAA)".to_owned(), + ] { + assert!(out.contains(&expected), "should contain {expected}: {out}"); + } +} + +#[test] +fn an_import_no_entry_moves_keeps_its_upstream_address() { + let out = relocated( + &settings_rehosting("other/"), + "@import url(\"../shared/base.css\");", + ); + assert!( + out.contains("url(\"https://cdn.example.net/npm/slick@1/shared/base.css\")"), + "a stylesheet no entry moves is fetched where it lives: {out}" + ); +} + +#[test] +fn a_stylesheet_naming_another_charset_is_not_moved() { + assert!(names_another_charset("@charset \"iso-8859-1\";\n.a{}")); + assert!(!names_another_charset("@charset \"UTF-8\";\n.a{}")); + assert!(!names_another_charset(".a{}")); +} + +#[test] +fn relocated_cache_lifetimes_are_bounded_by_both_origin_and_route() { + let route = CachePolicy { + browser_ttl: Some(Duration::from_secs(60)), + edge_ttl: Some(Duration::from_secs(600)), + ..CachePolicy::public_immutable(Duration::from_secs(60)) + }; + for (browser, shared, expected_browser, expected_shared) in [ + (300, 3600, 60, 600), + (20, 300, 20, 300), + (20, 3600, 20, 600), + (300, 20, 60, 20), + (0, 300, 0, 300), + (20, 0, 20, 0), + ] { + let policy = bounded_cache_policy( + OriginFreshness { + browser: Duration::from_secs(browser), + shared: Duration::from_secs(shared), + }, + Some(route), + ); + assert_eq!( + policy.browser_ttl, + Some(Duration::from_secs(expected_browser)) + ); + assert_eq!(policy.edge_ttl, Some(Duration::from_secs(expected_shared))); + assert!(!policy.immutable); + assert_eq!(policy.stale_while_revalidate, None); + assert_eq!(policy.stale_if_error, None); + } + let policy = bounded_cache_policy( + OriginFreshness { + browser: Duration::from_secs(20), + shared: Duration::from_secs(300), + }, + None, + ); + assert_eq!(policy.browser_ttl, Some(Duration::from_secs(20))); + assert_eq!(policy.edge_ttl, Some(Duration::from_secs(300))); +} + +#[test] +fn buffered_stylesheets_keep_their_allocation_up_to_the_bound() { + futures::executor::block_on(async { + for length in [0, 16, MAX_STYLESHEET_BYTES] { + let bytes = Bytes::from(vec![b' '; length]); + let pointer = bytes.as_ptr(); + let collected = collect_stylesheet(EdgeBody::Once(bytes)) + .await + .expect("bounded buffered stylesheet"); + assert_eq!(collected.len(), length); + assert_eq!(collected.as_ptr(), pointer, "buffered bytes stay zero-copy"); + } + assert!( + collect_stylesheet(EdgeBody::Once(Bytes::from(vec![ + 0; + MAX_STYLESHEET_BYTES + 1 + ]))) + .await + .is_err(), + "oversized buffered bytes are refused" + ); + }); +} + +#[test] +fn streamed_stylesheets_refuse_oversized_chunks_without_reading_on() { + futures::executor::block_on(async { + for first_length in [0, MAX_STYLESHEET_BYTES - 1] { + let body = EdgeBody::from_stream(async_stream::stream! { + if first_length != 0 { + yield Ok::(Bytes::from(vec![0; first_length])); + } + // Includes both a single oversized chunk and a chunk larger + // than the remaining capacity after a valid prefix. + let length = if first_length == 0 { MAX_STYLESHEET_BYTES + 1 } else { 2 }; + yield Ok(Bytes::from(vec![0; length])); + panic!("must refuse the oversized chunk before reading another"); + }); + let error = collect_stylesheet(body) + .await + .expect_err("must refuse excess"); + assert!(matches!( + error.current_context(), + TrustedServerError::Integration { .. } + )); + } + }); +} + +#[test] +fn streamed_stylesheets_accept_the_exact_bound_and_report_upstream_errors() { + futures::executor::block_on(async { + let body = EdgeBody::stream(futures::stream::iter([ + Bytes::from(vec![b'a'; MAX_STYLESHEET_BYTES - 1]), + Bytes::from_static(b"b"), + ])); + let bytes = collect_stylesheet(body) + .await + .expect("exactly bounded stream"); + assert_eq!(bytes.len(), MAX_STYLESHEET_BYTES); + assert_eq!(bytes.last(), Some(&b'b')); + + let body = EdgeBody::from_stream(futures::stream::iter([ + Ok(Bytes::from_static(b"partial")), + Err(std::io::Error::other("origin reset")), + ])); + let error = collect_stylesheet(body) + .await + .expect_err("upstream failure"); + assert!(matches!( + error.current_context(), + TrustedServerError::Integration { .. } + )); + }); +} diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 44929b7825..eb9ea6cbce 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -1,6 +1,7 @@ //! Simplified HTML processor that combines URL replacement and integration injection //! //! This module provides a `StreamProcessor` implementation for HTML content. +use std::borrow::Cow; use std::cell::{Cell, RefCell}; use std::io; use std::rc::Rc; @@ -27,13 +28,16 @@ use crate::middleware::{ use crate::platform::RuntimeServices; use crate::prefix::{PUBLIC_PREFIX_ATTRIBUTE, PublicPrefix}; use crate::publisher::build_empty_bids_script; +use crate::rehost::{Decision, PageAddress, PageRehost, Reference}; use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; mod script_text; +mod source_protection; use script_text::ScriptTextGuard; +use source_protection::SourceProtected; /// The HTML rewriter followed by the middleware's stream processors, each /// handed what the one before it produced. @@ -152,6 +156,13 @@ pub struct HtmlProcessorConfig { pub status: StatusCode, /// See [`MiddlewareContext::origin_private`]. pub origin_private: bool, + /// The reader's response headers, see + /// [`MiddlewareContext::response_headers`]. `None` where the document may + /// be stored and replayed to other readers under other headers. + pub response_headers: Option>, + /// The routes that rehost third-party addresses on this page, and the + /// prefix the page's own address carries, see [`crate::rehost`]. + pub rehost: crate::rehost::PageRehost, } impl HtmlProcessorConfig { @@ -180,6 +191,12 @@ impl HtmlProcessorConfig { prepared: Arc::new(Prepared::default()), status: StatusCode::OK, origin_private: false, + response_headers: None, + rehost: crate::rehost::PageRehost { + routes: crate::rehost::RehostRoutes::of(settings), + removed_prefix: String::new(), + base: None, + }, } } @@ -201,6 +218,14 @@ impl HtmlProcessorConfig { self } + /// Hands the serve chain, and the fetch chain of a document that is not + /// stored, the headers of the reader's response. + #[must_use] + pub fn with_response_headers(mut self, headers: Option>) -> Self { + self.response_headers = headers; + self + } + /// Attach the head script carrying this request's permission state. /// /// Separate from [`with_ad_state`](Self::with_ad_state) because the two are @@ -247,6 +272,24 @@ impl HtmlProcessorConfig { self } + /// Sets the path prefix request addressing removed from the page's + /// address, which a rehosted address is written under, see + /// [`crate::rehost`]. + #[must_use] + pub fn with_removed_prefix(mut self, removed_prefix: &str) -> Self { + removed_prefix.clone_into(&mut self.rehost.removed_prefix); + self + } + + /// Sets the effective base resolved from the complete original document. + /// Publisher callers must perform that bounded prepass before streaming + /// any rewritten HTML. + #[must_use] + pub fn with_document_base(mut self, base: url::Url) -> Self { + self.rehost.base = Some(base); + self + } + /// Attach what the middleware prepared for this document. #[must_use] pub fn with_prepared(mut self, prepared: Arc) -> Self { @@ -333,6 +376,24 @@ pub fn create_html_processor_with_middleware( fetch: &MiddlewareChain, serve: Option<&MiddlewareChain>, ) -> Result<(impl StreamProcessor + use<>, Vec), String> { + let mut protection = vec![fetch]; + protection.extend(serve); + create_html_processor_with_source_protection(config, fetch, serve, &protection) +} + +/// Builds the HTML pass with original-source protection selected from both +/// applicable phases, even when serve actions are deferred until cache replay. +/// +/// # Errors +/// +/// Returns the same planning errors as [`create_html_processor_with_middleware`]. +pub fn create_html_processor_with_source_protection( + config: HtmlProcessorConfig, + fetch: &MiddlewareChain, + serve: Option<&MiddlewareChain>, + protection: &[&MiddlewareChain], +) -> Result<(impl StreamProcessor + use<>, Vec), String> { + let limit = config.max_buffered_body_bytes; let document_state = IntegrationDocumentState::default(); let plan = fetch.plan(&MiddlewareContext { phase: fetch.phase(), @@ -341,13 +402,16 @@ pub fn create_html_processor_with_middleware( origin_host: &config.origin_host, status: config.status, origin_private: config.origin_private, + // A document a serve chain is not run with may be stored, so its fetch + // chain is told nothing of this reader's headers. + response_headers: serve.and(config.response_headers.as_deref()), platform: config.integrations.platform(), document_state: &document_state, max_buffered_script_bytes: config.max_buffered_body_bytes, prepared: &config.prepared, })?; let reader = match serve { - Some(serve) => create_serve_processor( + Some(serve) => create_serve_processor_unprotected( serve, &ReaderDocument { request_host: &config.request_host, @@ -355,6 +419,7 @@ pub fn create_html_processor_with_middleware( origin_host: &config.origin_host, status: config.status, origin_private: config.origin_private, + response_headers: config.response_headers.as_deref(), platform: config.integrations.platform(), request_state: &config.request_state, max_buffered_script_bytes: config.max_buffered_body_bytes, @@ -365,7 +430,10 @@ pub fn create_html_processor_with_middleware( }; let mut processor = build_html_processor(config, plan); processor.processors.extend(reader.processor); - Ok((processor, reader.response_headers)) + Ok(( + SourceProtected::new(processor, protection, limit), + reader.response_headers, + )) } /// What a serve chain is told about the reader's copy it works on. @@ -382,6 +450,8 @@ pub struct ReaderDocument<'a> { pub status: StatusCode, /// See [`MiddlewareContext::origin_private`]. pub origin_private: bool, + /// See [`MiddlewareContext::response_headers`]. + pub response_headers: Option<&'a http::HeaderMap>, /// What the adapter reports about the platform the deployment runs on. pub platform: &'a PlatformIdentity, /// What modules left on this reader's request for their page changes. @@ -407,6 +477,38 @@ pub struct ReaderDocument<'a> { pub fn create_serve_processor( chain: &MiddlewareChain, document: &ReaderDocument<'_>, +) -> Result { + create_serve_processor_with_source_protection(chain, document, &[chain]) +} + +/// Protects a cached reader copy using both applicable phases' source policies. +/// +/// # Errors +/// +/// Returns the same planning errors as [`create_serve_processor`]. +pub fn create_serve_processor_with_source_protection( + chain: &MiddlewareChain, + document: &ReaderDocument<'_>, + protection: &[&MiddlewareChain], +) -> Result { + let mut pass = create_serve_processor_unprotected(chain, document)?; + if let Some(processor) = pass.processor.take() { + let inner = HtmlWithStreamingProcessors { + inner: processor, + processors: Vec::new(), + }; + pass.processor = Some(Box::new(SourceProtected::new( + inner, + protection, + document.max_buffered_script_bytes, + ))); + } + Ok(pass) +} + +fn create_serve_processor_unprotected( + chain: &MiddlewareChain, + document: &ReaderDocument<'_>, ) -> Result { if chain.is_empty() { return Ok(ServePass::default()); @@ -420,6 +522,7 @@ pub fn create_serve_processor( origin_host: document.origin_host, status: document.status, origin_private: document.origin_private, + response_headers: document.response_headers, platform: document.platform, document_state: &document_state, max_buffered_script_bytes: document.max_buffered_script_bytes, @@ -457,6 +560,56 @@ type ContentHandler = ( lol_html::ElementContentHandlers<'static>, ); +/// The same structural safety observation applies before rewriting and after +/// restoring protected ranges. Text mentioning nonce attributes is not markup. +fn markup_nonce_handlers(observed: Arc) -> Vec { + let meta_observed = Arc::clone(&observed); + vec![ + element!("meta[http-equiv][content]", move |el| { + let delivers_csp = el.get_attribute("http-equiv").is_some_and(|equiv| { + matches!( + equiv.trim().to_ascii_lowercase().as_str(), + "content-security-policy" | "content-security-policy-report-only" + ) + }); + if delivers_csp + && el + .get_attribute("content") + .is_some_and(|policy| policy.to_ascii_lowercase().contains("'nonce-")) + { + meta_observed.store(true, Ordering::SeqCst); + } + Ok(()) + }), + // lol_html does not decode quoted meta content. Observe nonce attributes + // independently so encoded policies cannot hide nonce-bound elements. + element!("[nonce]", move |_el| { + observed.store(true, Ordering::SeqCst); + Ok(()) + }), + ] +} + +/// Observe fully restored markup before shared-template admission, including +/// protected originals and middleware-generated elements. Never rewrite it. +pub(crate) fn restored_markup_has_nonce(bytes: &[u8]) -> Result { + let observed = Arc::new(AtomicBool::new(false)); + let mut parser = lol_html::HtmlRewriter::new( + RewriterSettings { + element_content_handlers: markup_nonce_handlers(Arc::clone(&observed)), + ..RewriterSettings::default() + }, + |_: &[u8]| {}, + ); + parser + .write(bytes) + .map_err(|error| io::Error::other(error.to_string()))?; + parser + .end() + .map_err(|error| io::Error::other(error.to_string()))?; + Ok(observed.load(Ordering::SeqCst)) +} + /// The handlers that carry out a plan's element and text handlers. /// /// The text handlers share `edits` with every text handler registered before @@ -596,6 +749,117 @@ fn build_serve_processor(plan: MiddlewarePlan) -> HtmlWithStreamingProcessors { } } +/// Decodes an HTML attribute with the tokenizer's attribute-value rules. +/// Only the attribute is tokenized; the document is never copied. +fn decode_html_attribute(value: &str) -> Cow<'_, str> { + if !value.contains(['&', '\r', '\0']) { + return Cow::Borrowed(value); + } + use html5ever::tokenizer::{ + BufferQueue, Token, TokenSink, TokenSinkResult, Tokenizer, TokenizerOpts, + }; + #[derive(Default)] + struct AttributeValue(RefCell); + impl TokenSink for AttributeValue { + type Handle = (); + + fn process_token(&self, token: Token, _: u64) -> TokenSinkResult<()> { + if let Token::TagToken(tag) = token + && let Some(attribute) = tag.attrs.into_iter().next() + { + *self.0.borrow_mut() = attribute.value.to_string(); + } + TokenSinkResult::Continue + } + } + // Protect literal quotes, leaving references to be decoded exactly once. + let input = BufferQueue::default(); + input.push_back(format!("", value.replace('"', """)).into()); + let tokenizer = Tokenizer::new(AttributeValue::default(), TokenizerOpts::default()); + let _ = tokenizer.feed(&input); + tokenizer.end(); + Cow::Owned(tokenizer.sink.0.into_inner()) +} + +/// `lol_html` escapes quotes but leaves ampersands alone. Serialize a decoded +/// URL once so its literal ampersands cannot create new HTML references. +fn url_attribute(value: String) -> String { + if value.contains('&') { + value.replace('&', "&") + } else { + value + } +} + +/// Reads the first effective HTML base from complete original bytes. The +/// parser discards output and builds no DOM. Template contents and foreign +/// base elements do not set the document's base. +pub(crate) fn effective_document_base( + original: &[u8], + document_url: &url::Url, +) -> Result { + let base = Rc::new(RefCell::new(document_url.clone())); + let seen = Rc::new(Cell::new(false)); + let template_depth = Rc::new(Cell::new(0usize)); + let handlers = vec![ + element!("template", { + let depth = Rc::clone(&template_depth); + move |el| { + if el.namespace_uri() == "http://www.w3.org/1999/xhtml" { + depth.set(depth.get() + 1); + let depth = Rc::clone(&depth); + if let Some(handlers) = el.end_tag_handlers() { + let handler: EndTagHandler<'static> = Box::new(move |_| { + depth.set(depth.get().saturating_sub(1)); + Ok(()) + }); + handlers.push(handler); + } + } + Ok(()) + } + }), + element!("base[href]", { + let base = Rc::clone(&base); + let seen = Rc::clone(&seen); + let template_depth = Rc::clone(&template_depth); + move |el| { + if el.namespace_uri() != "http://www.w3.org/1999/xhtml" + || template_depth.get() != 0 + || seen.replace(true) + { + return Ok(()); + } + if let Some(href) = el.get_attribute("href") { + let resolved = base.borrow().join(&decode_html_attribute(&href)); + if let Ok(resolved) = resolved + && !matches!(resolved.scheme(), "data" | "javascript") + { + base.replace(resolved); + } + } + Ok(()) + } + }), + ]; + let mut parser = lol_html::HtmlRewriter::new( + RewriterSettings { + element_content_handlers: handlers, + ..RewriterSettings::default() + }, + |_: &[u8]| {}, + ); + parser + .write(original) + .map_err(|error| io::Error::other(error.to_string()))?; + parser + .end() + .map_err(|error| io::Error::other(error.to_string()))?; + Ok(Rc::try_unwrap(base) + .expect("base parser released its handlers") + .into_inner()) +} + /// `value` with each occurrence of a form's text replaced by that form's /// replacement, in one pass from the start, so no replacement is read again. /// @@ -624,6 +888,104 @@ fn replace_each_once(value: &str, forms: &[(String, String)]) -> String { out } +/// What an element's address in `attribute` is fetched as, where a +/// rehosting route may serve it, or `None` for an address no rehosting +/// route moves, such as a link a reader follows. `tag` is the element's +/// name in lower case; `rel`, `link_as` and `input_type` are its `rel`, `as` +/// and `type` attributes. +fn rehost_reference( + tag: &str, + attribute: &str, + rel: Option<&str>, + link_as: Option<&str>, + input_type: Option<&str>, +) -> Option { + let has_rel = |wanted: &str| { + rel.is_some_and(|rel| { + rel.split_ascii_whitespace() + .any(|token| token.eq_ignore_ascii_case(wanted)) + }) + }; + match (tag, attribute) { + ("link", "href") if has_rel("stylesheet") => Some(Reference::Style), + ("link", "href") if has_rel("preload") || has_rel("prefetch") => { + match link_as + .map(|value| value.trim().to_ascii_lowercase()) + .as_deref() + { + Some("style") => Some(Reference::Style), + Some("image" | "font" | "audio" | "video" | "track") => Some(Reference::Media), + _ => None, + } + } + ("link", "imagesrcset") + | ("img" | "source" | "video" | "audio" | "track", "src") + | ("img" | "source", "srcset") + | ("video", "poster") => Some(Reference::Media), + ("input", "src") + if input_type.is_some_and(|value| value.trim().eq_ignore_ascii_case("image")) => + { + Some(Reference::Media) + } + _ => None, + } +} + +/// What the page's rehosting routes decide about `value`, an element's +/// address in `attribute`, see [`crate::rehost::RehostRoutes::decide`]. +fn rehost_decision( + rehost: &PageRehost, + page: &PageAddress<'_>, + el: &lol_html::html_content::Element<'_, '_>, + attribute: &str, + value: &str, +) -> Decision { + if rehost.routes.is_empty() { + return Decision::Unmatched; + } + let tag = el.tag_name().to_ascii_lowercase(); + let rel = el.get_attribute("rel"); + let link_as = el.get_attribute("as"); + let input_type = el.get_attribute("type"); + let Some(reference) = rehost_reference( + &tag, + attribute, + rel.as_deref(), + link_as.as_deref(), + input_type.as_deref(), + ) else { + return Decision::Unmatched; + }; + rehost + .routes + .decide(value, reference, el.has_attribute("integrity"), page) +} + +/// `value`, a `srcset` or `imagesrcset`, with each candidate's address +/// decided on its own: one a rehosting route serves is written as the +/// route's address or kept as written, and `unmatched` rewrites the others. +/// Every byte between the addresses is kept. +fn rewrite_srcset_candidates( + value: &str, + decide: impl Fn(&str) -> Decision, + unmatched: impl Fn(&str) -> String, +) -> String { + let mut out = String::with_capacity(value.len()); + let mut written = 0; + for range in crate::rehost::srcset_address_ranges(value) { + out.push_str(&value[written..range.start]); + let address = &value[range.clone()]; + match decide(address) { + Decision::Rehost(rehosted) => out.push_str(&rehosted), + Decision::Keep => out.push_str(address), + Decision::Unmatched => out.push_str(&unmatched(address)), + } + written = range.end; + } + out.push_str(&value[written..]); + out +} + /// The processor behind [`create_html_processor`] and /// [`create_html_processor_with_middleware`], carrying out `plan`. /// @@ -680,6 +1042,22 @@ fn build_html_processor( } } + /// Moves only a leading authority (or the supported bare host), never + /// a publisher host occurring inside a path, query or fragment. + fn rewrite_srcset_address(&self, value: &str) -> String { + self.srcset_forms + .iter() + .find_map(|(form, replacement)| { + let rest = value + .get(..form.len())? + .eq_ignore_ascii_case(form) + .then(|| &value[form.len()..])?; + (rest.is_empty() || matches!(rest.as_bytes().first(), Some(b'/' | b'?' | b'#'))) + .then(|| format!("{replacement}{rest}")) + }) + .unwrap_or_else(|| value.to_owned()) + } + fn rewrite_url_value(&self, value: &str) -> Option { if !value.contains(&self.origin_host) { return None; @@ -712,6 +1090,49 @@ fn build_html_processor( &config.request_scheme, )); + // A media or stylesheet address on a rehosting route's third-party folder + // is decided once, on the value the page wrote, before the origin's + // address is moved, so neither rewrite reads the other's output, see + // `crate::rehost`. + struct RehostScope { + rehost: PageRehost, + scheme: String, + host: String, + } + impl RehostScope { + fn decide( + &self, + el: &lol_html::html_content::Element<'_, '_>, + attribute: &str, + original: &str, + ) -> Decision { + if self.rehost.routes.is_empty() { + return Decision::Unmatched; + } + self.decide_decoded(el, attribute, &decode_html_attribute(original)) + } + + fn decide_decoded( + &self, + el: &lol_html::html_content::Element<'_, '_>, + attribute: &str, + value: &str, + ) -> Decision { + let page = PageAddress { + scheme: &self.scheme, + host: &self.host, + removed_prefix: &self.rehost.removed_prefix, + base: self.rehost.base.as_ref(), + }; + rehost_decision(&self.rehost, &page, el, attribute, value) + } + } + let rehost = Rc::new(RehostScope { + rehost: config.rehost.clone(), + scheme: config.request_scheme.clone(), + host: config.request_host.clone(), + }); + let injected_tsjs = Rc::new(Cell::new(false)); let injected_bids = Arc::new(AtomicBool::new(false)); let integration_registry = config.integrations.clone(); @@ -866,11 +1287,20 @@ fn build_html_processor( // Replace URLs in href attributes element!("[href]", { let patterns = patterns.clone(); + let rehost = rehost.clone(); move |el| { - if let Some(href) = el.get_attribute("href") - && let Some(rewritten) = patterns.rewrite_url_value(&href) - { - el.set_attribute("href", &rewritten)?; + if let Some(href) = el.get_attribute("href") { + match rehost.decide(el, "href", &href) { + Decision::Rehost(rehosted) => { + el.set_attribute("href", &url_attribute(rehosted))? + } + Decision::Keep => {} + Decision::Unmatched => { + if let Some(rewritten) = patterns.rewrite_url_value(&href) { + el.set_attribute("href", &rewritten)?; + } + } + } } Ok(()) } @@ -878,11 +1308,32 @@ fn build_html_processor( // Replace URLs in src attributes element!("[src]", { let patterns = patterns.clone(); + let rehost = rehost.clone(); move |el| { - if let Some(src) = el.get_attribute("src") - && let Some(rewritten) = patterns.rewrite_url_value(&src) + if let Some(src) = el.get_attribute("src") { + match rehost.decide(el, "src", &src) { + Decision::Rehost(rehosted) => { + el.set_attribute("src", &url_attribute(rehosted))? + } + Decision::Keep => {} + Decision::Unmatched => { + if let Some(rewritten) = patterns.rewrite_url_value(&src) { + el.set_attribute("src", &rewritten)?; + } + } + } + } + Ok(()) + } + }), + // A video's poster, which only a rehosting route moves. + element!("video[poster]", { + let rehost = rehost.clone(); + move |el| { + if let Some(poster) = el.get_attribute("poster") + && let Decision::Rehost(rehosted) = rehost.decide(el, "poster", &poster) { - el.set_attribute("src", &rewritten)?; + el.set_attribute("poster", &url_attribute(rehosted))?; } Ok(()) } @@ -902,11 +1353,17 @@ fn build_html_processor( // Replace URLs in srcset attributes (for responsive images) element!("[srcset]", { let patterns = patterns.clone(); + let rehost = rehost.clone(); move |el| { if let Some(srcset) = el.get_attribute("srcset") { - let rewritten = replace_each_once(&srcset, &patterns.srcset_forms); - if rewritten != srcset { - el.set_attribute("srcset", &rewritten)?; + let decoded = decode_html_attribute(&srcset); + let rewritten = rewrite_srcset_candidates( + &decoded, + |address| rehost.decide_decoded(&*el, "srcset", address), + |address| patterns.rewrite_srcset_address(address), + ); + if rewritten != decoded.as_ref() { + el.set_attribute("srcset", &url_attribute(rewritten))?; } } Ok(()) @@ -915,11 +1372,17 @@ fn build_html_processor( // Replace URLs in imagesrcset attributes (for link preload) element!("[imagesrcset]", { let patterns = patterns.clone(); + let rehost = rehost.clone(); move |el| { if let Some(imagesrcset) = el.get_attribute("imagesrcset") { - let rewritten = replace_each_once(&imagesrcset, &patterns.url_forms); - if rewritten != imagesrcset { - el.set_attribute("imagesrcset", &rewritten)?; + let decoded = decode_html_attribute(&imagesrcset); + let rewritten = rewrite_srcset_candidates( + &decoded, + |address| rehost.decide_decoded(&*el, "imagesrcset", address), + |address| patterns.rewrite_srcset_address(address), + ); + if rewritten != decoded.as_ref() { + el.set_attribute("imagesrcset", &url_attribute(rewritten))?; } } Ok(()) @@ -927,35 +1390,8 @@ fn build_html_processor( }), ]; - // A response-bound nonce is only safe for the response that carried it, and the - // response-header gate cannot see one the origin delivered in the markup instead. - // Observed structurally rather than by scanning the output bytes, which cannot tell a - // `nonce` attribute from the same word inside a script. if let Some(observed) = config.csp_nonce_observed.clone() { - let meta_observed = Arc::clone(&observed); - element_content_handlers.push(element!("meta[http-equiv][content]", move |el| { - let delivers_csp = el.get_attribute("http-equiv").is_some_and(|equiv| { - matches!( - equiv.trim().to_ascii_lowercase().as_str(), - "content-security-policy" | "content-security-policy-report-only" - ) - }); - if delivers_csp - && el - .get_attribute("content") - .is_some_and(|policy| policy.to_ascii_lowercase().contains("'nonce-")) - { - meta_observed.store(true, Ordering::SeqCst); - } - Ok(()) - })); - // `lol_html` does not entity-decode quoted meta CSP content for the check above. - // Reject nonce attributes independently so an entity-encoded meta policy cannot - // hide executable nonce-bound content from the template-cache safety scan. - element_content_handlers.push(element!("[nonce]", move |_el| { - observed.store(true, Ordering::SeqCst); - Ok(()) - })); + element_content_handlers.extend(markup_nonce_handlers(observed)); } // Shared by every text handler, so each is handed a chunk as the one @@ -1285,7 +1721,9 @@ mod tests { fn create_test_config() -> HtmlProcessorConfig { HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: None, + response_headers: None, public_prefix: None, prepared: Arc::new(Prepared::default()), status: StatusCode::OK, @@ -2038,7 +2476,9 @@ mod tests { #[test] fn injects_ad_slots_at_head_open() { let config = HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: None, + response_headers: None, public_prefix: None, prepared: Arc::new(Prepared::default()), status: StatusCode::OK, @@ -2120,7 +2560,9 @@ mod tests { let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: None, + response_headers: None, public_prefix: None, prepared: Arc::new(Prepared::default()), status: StatusCode::OK, @@ -2163,7 +2605,9 @@ mod tests { let bids_script = r#""#; let state = std::sync::Arc::new(std::sync::Mutex::new(Some(bids_script.to_string()))); let config = HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: None, + response_headers: None, public_prefix: None, prepared: Arc::new(Prepared::default()), status: StatusCode::OK, @@ -2207,7 +2651,9 @@ mod tests { let request_host = "proxy.test-publisher.example.com"; let config = HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: None, + response_headers: None, public_prefix: None, prepared: Arc::new(Prepared::default()), status: StatusCode::OK, @@ -2265,7 +2711,9 @@ mod tests { // (state is None) — e.g. auction timed out with zero bids. Fallback to {}. let state = std::sync::Arc::new(std::sync::Mutex::new(None)); let config = HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: None, + response_headers: None, public_prefix: None, prepared: Arc::new(Prepared::default()), status: StatusCode::OK, @@ -2301,7 +2749,9 @@ mod tests { // unmodified (spec §8: "Existing client-side Prebid/GPT flow runs unmodified"). let state = std::sync::Arc::new(std::sync::Mutex::new(None)); let config = HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: None, + response_headers: None, public_prefix: None, prepared: Arc::new(Prepared::default()), status: StatusCode::OK, @@ -2330,7 +2780,9 @@ mod tests { fn marker_mode_config(marker: &str, observer: Option>) -> HtmlProcessorConfig { HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: observer, + response_headers: None, public_prefix: None, body_close: BodyCloseInjection::Marker(marker.to_string()), origin_host: "origin.example.com".to_string(), @@ -2551,7 +3003,9 @@ mod tests { fn bodyless_marker_mode_emits_an_owned_terminal_seam_even_after_source_bytes() { const MARKER: &str = ""; let config = HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: None, + response_headers: None, public_prefix: None, prepared: Arc::new(Prepared::default()), status: StatusCode::OK, @@ -2610,3 +3064,246 @@ mod tests { ); } } + +#[cfg(test)] +mod rehost_tests { + use std::io::Cursor; + + use super::HtmlProcessorConfig; + use super::test_support::{ORIGIN_HOST, REQUEST_HOST, create_page_processor}; + use crate::integrations::IntegrationRegistry; + use crate::middleware::{PhaseEntries, PhaseEntry}; + use crate::settings::{ProxyAssetRoute, Settings}; + use crate::streaming_processor::{Compression, PipelineConfig, StreamingPipeline}; + use crate::test_support::tests::create_test_settings; + + const ROUTE: &str = "/_rehost/cdn.example.net/npm/"; + const PAGE_PREFIX: &str = "/www.publisher.example"; + + fn rehosting_settings() -> Settings { + let mut settings = create_test_settings(); + let mut route = ProxyAssetRoute::new(ROUTE, "https://cdn.example.net/npm"); + route.rehost_page_references = true; + settings.proxy.asset_routes.push(route); + settings.serve = PhaseEntries::new(vec![PhaseEntry { + media_type: "text/css".to_owned(), + path: Some(format!("{ROUTE}moved/")), + middleware: vec!["provenance.encypher.stylesheet".to_owned()], + }]); + settings + } + + fn rendered(settings: &Settings, html: &str) -> String { + rendered_at( + settings, + html, + "https://origin.example.com/articles/page.html?view=1", + ) + } + + fn rendered_at(settings: &Settings, html: &str, document_url: &str) -> String { + let document_url = url::Url::parse(document_url).expect("document URL"); + let base = super::effective_document_base(html.as_bytes(), &document_url) + .expect("should read the original base"); + let registry = IntegrationRegistry::default(); + let config = HtmlProcessorConfig::from_settings( + settings, + ®istry, + ORIGIN_HOST, + REQUEST_HOST, + "https", + ) + .with_removed_prefix(PAGE_PREFIX) + .with_document_base(base); + let mut pipeline = StreamingPipeline::new( + PipelineConfig { + input_compression: Compression::None, + output_compression: Compression::None, + chunk_size: 64, + }, + create_page_processor(settings, ®istry, config), + ); + let mut output = Vec::new(); + pipeline + .process(Cursor::new(html.as_bytes()), &mut output) + .expect("should process the page"); + String::from_utf8(output).expect("should leave the page UTF-8") + } + + #[test] + fn third_party_media_and_approved_stylesheets_point_at_the_rehosting_route() { + let page = rendered( + &rehosting_settings(), + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#"a"#, + r#""#, + ), + ); + let reader = format!("https://{REQUEST_HOST}{PAGE_PREFIX}{ROUTE}"); + for expected in [ + format!(r#"href="{reader}moved/a.css?ver=2#x""#), + // A stylesheet no entry moves is kept as written, its query too. + r#"href="//cdn.example.net/npm/kept/b.css?ret=https://origin.example.com/p""# + .to_owned(), + // Integrity is never broken. + r#"href="https://cdn.example.net/npm/moved/c.css?ret=https://origin.example.com/p""# + .to_owned(), + format!(r#"src="{reader}i.png""#), + format!(r#"srcset="{reader}i.png?w=1,2 1x, https://{REQUEST_HOST}/o.png 2x""#), + format!(r#"poster="{reader}p.jpg""#), + // A link a reader follows is not media. + r#"href="https://cdn.example.net/npm/page.html""#.to_owned(), + format!(r#"src="https://{REQUEST_HOST}/own.png""#), + ] { + assert!( + page.contains(&expected), + "should contain {expected}: {page}" + ); + } + } + + #[test] + fn a_page_with_no_rehosting_route_preserves_publisher_names_inside_other_urls() { + let page = rendered( + &create_test_settings(), + r#""#, + ); + assert!( + page.contains(r#"srcset="https://cdn.example.net/x.png?u=origin.example.com 1x""#), + "a publisher host inside another URL's query is not an authority: {page}", + ); + } + + #[test] + fn source_set_fallback_changes_only_the_leading_authority_or_bare_host() { + let value = concat!( + "https://reader.example/www.publisher.example/origin.example.com/p.png?q=origin.example.com#f 1x, ", + "https://third.example/i.png?u=https://origin.example.com/a#origin.example.com 2x, ", + "https://origin.example.com.evil/i.png 3x, ", + "origin.example.com.evil/i.png 4x, ", + "https://origin.example.com/a/origin.example.com.png?q=%2f&x=origin.example.com#f 5x, ", + "//origin.example.com/b.png?x=1,2 6x, ", + "origin.example.com/c.png?q=origin.example.com#f 7x" + ); + let expected = format!( + concat!( + "https://reader.example/www.publisher.example/origin.example.com/p.png?q=origin.example.com#f 1x, ", + "https://third.example/i.png?u=https://origin.example.com/a#origin.example.com 2x, ", + "https://origin.example.com.evil/i.png 3x, ", + "origin.example.com.evil/i.png 4x, ", + "https://{REQUEST_HOST}/a/origin.example.com.png?q=%2f&x=origin.example.com#f 5x, ", + "//{REQUEST_HOST}/b.png?x=1,2 6x, ", + "{REQUEST_HOST}/c.png?q=origin.example.com#f 7x" + ), + REQUEST_HOST = REQUEST_HOST, + ); + for settings in [create_test_settings(), rehosting_settings()] { + let page = rendered( + &settings, + &format!( + r#""# + ), + ); + assert!(page.contains(&format!(r#"srcset="{expected}""#)), "{page}"); + assert!( + page.contains(&format!(r#"imagesrcset="{expected}""#)), + "{page}" + ); + } + } + + #[test] + fn late_first_base_resolves_earlier_references_and_later_bases_do_not_win() { + let page = rendered( + &rehosting_settings(), + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + ), + ); + let reader = format!("https://{REQUEST_HOST}{PAGE_PREFIX}{ROUTE}lib/"); + assert!( + page.contains(&format!(r#"src="{reader}i.png?q=%2f&x=1#f""#)), + "{page}" + ); + assert!( + page.contains(&format!(r#"src="{reader}second.png""#)), + "{page}" + ); + } + + #[test] + fn protocol_relative_references_follow_the_effective_base_scheme() { + let page = rendered( + &rehosting_settings(), + concat!( + r#""#, + r#""#, + ), + ); + assert!( + page.contains(r#"src="//cdn.example.net/npm/a.png""#), + "{page}" + ); + assert!( + !page.contains(&format!("{PAGE_PREFIX}{ROUTE}a.png")), + "{page}" + ); + } + + #[test] + fn base_scan_ignores_comments_rawtext_templates_and_foreign_elements() { + let document = url::Url::parse("https://cdn.example.net/npm/articles/page.html?q=1") + .expect("document URL"); + let source = concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ); + let base = super::effective_document_base(source.as_bytes(), &document).expect("base"); + assert_eq!( + base.as_str(), + "https://cdn.example.net/npm/lib/?x=1&y=%2f#f" + ); + let invalid = super::effective_document_base( + br#""#, + &document, + ) + .expect("invalid first base falls back"); + assert_eq!(invalid, document); + } + + #[test] + fn decoded_query_bytes_are_serialized_once_in_each_rehosted_attribute() { + let value = "https://cdn.example.net/npm/a.png?q=%2f&literal=&amp;#f%2F"; + let page = rendered( + &rehosting_settings(), + &format!( + r#""# + ), + ); + let emitted = format!( + "https://{REQUEST_HOST}{PAGE_PREFIX}{ROUTE}a.png?q=%2f&literal=&amp;#f%2F" + ); + assert!(page.contains(&format!(r#"src="{emitted}""#)), "{page}"); + assert!( + page.contains(&format!(r#"srcset="{emitted} 1x""#)), + "{page}" + ); + assert!( + page.contains(&format!(r#"imagesrcset="{emitted} 2x""#)), + "{page}" + ); + } +} diff --git a/crates/trusted-server-core/src/html_processor/source_protection.rs b/crates/trusted-server-core/src/html_processor/source_protection.rs new file mode 100644 index 0000000000..7e926e0ad7 --- /dev/null +++ b/crates/trusted-server-core/src/html_processor/source_protection.rs @@ -0,0 +1,396 @@ +//! Bounded original-source hold around the entire HTML transformation pass. +//! Classification belongs to the selected middleware, not to core. + +use std::io; +use std::ops::Range; + +use futures::future::LocalBoxFuture; + +use crate::middleware::{MiddlewareChain, SourceProtection}; +use crate::platform::RuntimeServices; +use crate::streaming_processor::StreamProcessor; + +pub(super) struct SourceProtected

{ + inner: P, + classifiers: Vec, + original: Vec, + limit: usize, + ended: bool, +} + +impl

SourceProtected

{ + pub(super) fn new(inner: P, chains: &[&MiddlewareChain], limit: usize) -> Self { + Self { + inner, + classifiers: chains + .iter() + .flat_map(|chain| chain.source_protections()) + .collect(), + original: Vec::new(), + limit, + ended: false, + } + } + + fn hold(&mut self, chunk: &[u8]) -> io::Result<()> { + if self.ended { + return Err(refuse("source protection processor already ended")); + } + append(&mut self.original, chunk, self.limit) + } + + fn mask(&mut self) -> io::Result { + self.ended = true; + let mut ranges: Vec> = Vec::new(); + for classify in &self.classifiers { + let classified = classify(&self.original)?; + let mut previous_end = 0; + for range in &classified { + if range.start < previous_end + || range.start >= range.end + || range.end > self.original.len() + { + return Err(refuse("source protection returned invalid ranges")); + } + previous_end = range.end; + } + ranges.extend(classified); + } + ranges.sort_unstable_by_key(|range| (range.start, range.end)); + let mut merged: Vec> = Vec::with_capacity(ranges.len()); + for range in ranges { + if let Some(last) = merged.last_mut().filter(|last| range.start <= last.end) { + last.end = last.end.max(range.end); + } else { + merged.push(range); + } + } + MaskedDocument::new(std::mem::take(&mut self.original), merged, self.limit) + } +} + +impl StreamProcessor for SourceProtected

{ + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> io::Result> { + if self.classifiers.is_empty() { + return self.inner.process_chunk(chunk, is_last); + } + self.hold(chunk)?; + if !is_last { + return Ok(Vec::new()); + } + let masked = self.mask()?; + let input = masked.input(self.limit)?; + let output = self.inner.process_chunk(&input, true)?; + masked.restore(output, self.limit) + } + + fn finish<'a>( + &'a mut self, + chunk: &'a [u8], + services: &'a RuntimeServices, + ) -> LocalBoxFuture<'a, io::Result>> { + Box::pin(async move { + if self.classifiers.is_empty() { + return self.inner.finish(chunk, services).await; + } + self.hold(chunk)?; + let masked = self.mask()?; + let input = masked.input(self.limit)?; + let output = self.inner.finish(&input, services).await?; + masked.restore(output, self.limit) + }) + } + + fn reset(&mut self) { + self.inner.reset(); + self.original.clear(); + self.ended = false; + } +} + +struct MaskedDocument { + // Retain one original allocation. Protected SVGs remain slices, never copies. + original: Vec, + ranges: Vec>, + prefix: String, +} + +impl MaskedDocument { + fn new(original: Vec, ranges: Vec>, limit: usize) -> io::Result { + if original.len() > limit { + return Err(refuse("source protection original exceeds body limit")); + } + let prefix = if ranges.is_empty() { + String::new() + } else { + format!("", self.prefix) + } + + fn input(&self, limit: usize) -> io::Result> { + if self.ranges.is_empty() { + return Ok(std::borrow::Cow::Borrowed(&self.original)); + } + let mut input = Vec::new(); + let mut cursor = 0; + for (index, range) in self.ranges.iter().enumerate() { + append(&mut input, &self.original[cursor..range.start], limit)?; + append(&mut input, self.marker(index).as_bytes(), limit)?; + cursor = range.end; + } + append(&mut input, &self.original[cursor..], limit)?; + Ok(std::borrow::Cow::Owned(input)) + } + + fn restore(&self, output: Vec, limit: usize) -> io::Result> { + if output.len() > limit { + return Err(refuse("source protection output exceeds body limit")); + } + if self.ranges.is_empty() { + return Ok(output); + } + let mut restored = Vec::new(); + let mut cursor = 0; + for (index, range) in self.ranges.iter().enumerate() { + let at = find(&output[cursor..], self.prefix.as_bytes()) + .map(|at| cursor + at) + .ok_or_else(|| refuse("source protection marker lost"))?; + let marker = self.marker(index); + if !output[at..].starts_with(marker.as_bytes()) { + return Err(refuse( + "source protection marker duplicated, reordered or altered", + )); + } + append(&mut restored, &output[cursor..at], limit)?; + append(&mut restored, &self.original[range.clone()], limit)?; + cursor = at + marker.len(); + } + if find(&output[cursor..], self.prefix.as_bytes()).is_some() { + return Err(refuse("source protection marker duplicated")); + } + append(&mut restored, &output[cursor..], limit)?; + Ok(restored) + } +} + +fn append(output: &mut Vec, bytes: &[u8], limit: usize) -> io::Result<()> { + if bytes.len() > limit.saturating_sub(output.len()) { + return Err(refuse("source protection exceeds body limit")); + } + // Reserve explicitly so Vec's implicit growth cannot overshoot the bound. + let needed = output.len() + bytes.len(); + if needed > output.capacity() { + let capacity = output.capacity().saturating_mul(2).max(needed).min(limit); + output.reserve_exact(capacity - output.len()); + } + output.extend_from_slice(bytes); + Ok(()) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn refuse(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::middleware::{ + HTML_MEDIA_TYPE, Middleware, MiddlewareAction, MiddlewareContext, MiddlewarePhase, + }; + use crate::platform::test_support::noop_services; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Policy(SourceProtection); + + impl Middleware for Policy { + fn middleware_id(&self) -> &'static str { + "test.protection" + } + fn phases(&self) -> &[MiddlewarePhase] { + &MiddlewarePhase::ALL + } + fn source_protection(&self) -> Option { + Some(self.0) + } + fn create(&self, _: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction::pass() + } + } + + struct Replace { + calls: Arc, + transform: fn(&[u8]) -> Vec, + } + + impl StreamProcessor for Replace { + fn process_chunk(&mut self, chunk: &[u8], _: bool) -> io::Result> { + self.calls.fetch_add(1, Ordering::Relaxed); + Ok((self.transform)(chunk)) + } + } + + fn processor( + classify: SourceProtection, + transform: fn(&[u8]) -> Vec, + limit: usize, + ) -> (SourceProtected, Arc) { + let chain = MiddlewareChain::new( + MiddlewarePhase::Serve, + HTML_MEDIA_TYPE, + vec![Arc::new(Policy(classify))], + ); + let calls = Arc::new(AtomicUsize::new(0)); + ( + SourceProtected::new( + Replace { + calls: Arc::clone(&calls), + transform, + }, + &[&chain], + limit, + ), + calls, + ) + } + + fn middle(_: &[u8]) -> io::Result>> { + Ok(std::iter::once(1..5).collect()) + } + fn unchanged(bytes: &[u8]) -> Vec { + bytes.to_vec() + } + + #[test] + fn holds_original_until_complete_and_restores_exact_bytes_after_async_finish() { + let (mut processor, calls) = processor( + middle, + |bytes| { + let mut output = b"outside".to_vec(); + output.extend_from_slice(&bytes[1..]); + output + }, + 1024, + ); + assert!( + processor + .process_chunk(b"aKEEP", false) + .expect("hold") + .is_empty() + ); + assert_eq!(calls.load(Ordering::Relaxed), 0, "no early transform"); + let result = + futures::executor::block_on(processor.finish(b"z", &noop_services())).expect("restore"); + assert_eq!(result, b"outsideKEEPz"); + assert_eq!(calls.load(Ordering::Relaxed), 1); + } + + #[test] + fn source_limit_is_checked_before_copying_or_running_transforms() { + let (mut processor, calls) = processor(middle, unchanged, 5); + assert!( + processor + .process_chunk(b"a", false) + .expect("hold") + .is_empty() + ); + let before = processor.original.capacity(); + assert!(processor.process_chunk(&[b'x'; 128], false).is_err()); + assert_eq!(processor.original, b"a"); + assert_eq!(processor.original.capacity(), before); + assert_eq!(calls.load(Ordering::Relaxed), 0); + } + + #[test] + fn refuses_invalid_ranges_without_exposing_output() { + for classify in [ + (|_: &[u8]| Ok(std::iter::once(4..7).collect())) as SourceProtection, + |_: &[u8]| Ok(std::iter::once(2..2).collect()), + |_: &[u8]| Ok(vec![1..4, 3..5]), + |_: &[u8]| Ok(vec![3..5, 0..1]), + |_: &[u8]| Err(refuse("classification failed")), + ] { + let (mut processor, calls) = processor(classify, unchanged, 1024); + assert!(processor.process_chunk(b"aKEEPz", true).is_err()); + assert_eq!(calls.load(Ordering::Relaxed), 0); + } + } + + #[test] + fn refuses_lost_duplicated_reordered_and_altered_markers() { + let masked = + MaskedDocument::new(b"aKEEPzOTHERq".to_vec(), vec![1..5, 6..11], 1024).expect("mask"); + let first = masked.marker(0); + let second = masked.marker(1); + for invalid in [ + format!("a{second}q"), + format!("a{first}{first}{second}q"), + format!("a{second}{first}q"), + format!("a{}x-->{second}q", masked.prefix), + format!("a{first}{second}{second}q"), + ] { + assert!(masked.restore(invalid.into_bytes(), 1024).is_err()); + } + assert_eq!( + masked + .restore(format!("edited{first}z{second}q").into_bytes(), 1024) + .expect("restore"), + b"editedKEEPzOTHERq" + ); + } + + #[test] + fn refuses_mask_and_restoration_expansion_past_limit() { + let short = MaskedDocument::new(b"aKEEPz".to_vec(), std::iter::once(1..5).collect(), 6) + .expect("original fits"); + assert!(short.input(6).is_err(), "opaque marker must fit too"); + let original = format!("a{}z", "K".repeat(128)).into_bytes(); + let limit = original.len(); + let masked = MaskedDocument::new( + original.clone(), + std::iter::once(1..limit - 1).collect(), + limit, + ) + .expect("fits"); + let input = masked.input(limit).expect("mask fits").into_owned(); + assert!(masked.restore(input.clone(), limit - 1).is_err()); + assert_eq!(masked.restore(input, limit).expect("exact limit"), original); + } + + #[test] + fn policy_is_default_off_and_selected_only_from_supplied_chains() { + let chain = MiddlewareChain::new(MiddlewarePhase::Serve, HTML_MEDIA_TYPE, Vec::new()); + let calls = Arc::new(AtomicUsize::new(0)); + let mut processor = SourceProtected::new( + Replace { + calls: Arc::clone(&calls), + transform: unchanged, + }, + &[&chain], + 1, + ); + assert_eq!( + processor.process_chunk(b"unheld", false).expect("stream"), + b"unheld" + ); + assert_eq!(calls.load(Ordering::Relaxed), 1); + } +} diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index 76e9103360..2e9b90a296 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -684,7 +684,9 @@ mod tests { vec![Arc::new(ScriptAddress(integration))], ); let config = HtmlProcessorConfig { + rehost: crate::rehost::PageRehost::default(), csp_nonce_observed: None, + response_headers: None, public_prefix: None, prepared: std::sync::Arc::new(crate::middleware::Prepared::default()), status: http::StatusCode::OK, diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index ea672660ff..01b0220613 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -22,6 +22,7 @@ use crate::error::TrustedServerError; use crate::evidence::OwnedRequestInfo; use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; +use crate::media_signing::MediaSigner; use crate::middleware::{Middleware, MiddlewareChain, MiddlewarePhase, PhaseEntries}; use crate::module_context::{ModuleCall, ModuleContext, ModuleRequest, ResolvedRequest}; use crate::origin_auth::OriginSigners; @@ -707,6 +708,11 @@ pub struct IntegrationRegistration { /// on every request, so declaring one runs it wherever the registration /// itself runs. pub identifier_module: Option>, + /// Media signer this registration supplies, see [`crate::media_signing`]. + /// + /// No section selects one. It runs wherever the registration itself + /// runs, and at most one running registration may supply one. + pub media_signer: Option>, } impl IntegrationRegistration { @@ -741,6 +747,7 @@ impl IntegrationRegistrationBuilder { source_module: None, analytics_module: None, identifier_module: None, + media_signer: None, }, } } @@ -872,6 +879,18 @@ impl IntegrationRegistrationBuilder { self } + /// Declare the media signer this registration supplies, which core asks + /// to sign the binary responses it claims, see [`crate::media_signing`]. + /// + /// It runs wherever the registration runs. The registry refuses to build + /// when two running registrations supply one, because signing a body + /// twice would sign the first signer's output as a source. + #[must_use] + pub fn with_media_signer(mut self, signer: Arc) -> Self { + self.registration.media_signer = Some(signer); + self + } + /// Declare the rules this registration adds to `/robots.txt`, under their /// `name`. /// @@ -1025,6 +1044,9 @@ struct IntegrationRegistryInner { // registration order, which core asks on every request. Shared, so a // request's services take a reference count rather than a copy. identifier_modules: Arc<[Arc]>, + // The media signer a running registration supplies, with the + // integration that supplied it. At most one. + media_signer: Option<(&'static str, Arc)>, } /// The sources a deployment's settings name, built once. @@ -1074,6 +1096,7 @@ impl Default for IntegrationRegistryInner { analytics_module: None, sources: None, identifier_modules: Arc::default(), + media_signer: None, } } } @@ -2304,6 +2327,19 @@ impl IntegrationRegistry { )?; identifier_modules.push(module); } + if let Some(signer) = registration.media_signer { + if let Some((supplier, _)) = &inner.media_signer { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "integrations `{supplier}` and `{}` both supply a media signer, and \ + at most one running integration may, because a body is signed \ + once. Select only one of their modules", + registration.integration_id + ), + })); + } + inner.media_signer = Some((registration.integration_id, signer)); + } if let Some((name, contributor)) = registration.robots_txt_contributor { claim_module_name( &mut claimed, @@ -2451,6 +2487,17 @@ impl IntegrationRegistry { Arc::clone(&self.inner.identifier_modules) } + /// The media signer the running registrations supply, if one does. + /// Adapters hand it to each request's services with + /// [`RuntimeServices::with_media_signer`]. + #[must_use] + pub fn media_signer(&self) -> Option> { + self.inner + .media_signer + .as_ref() + .map(|(_, signer)| Arc::clone(signer)) + } + /// The prefixes of the publisher `request` was resolved to under /// `settings`, from the selected prefix module. /// @@ -3059,6 +3106,7 @@ impl IntegrationRegistry { analytics_module: None, sources: None, identifier_modules: Arc::default(), + media_signer: None, }), plan: None, platform: Arc::default(), @@ -3147,6 +3195,7 @@ impl IntegrationRegistry { analytics_module: None, sources: None, identifier_modules: Arc::default(), + media_signer: None, }), plan: None, platform: Arc::default(), @@ -4070,6 +4119,35 @@ pub(crate) mod test_support { } } + /// Ends each stylesheet a `[[serve]]` entry names it for with a line, + /// standing in for a stylesheet signer. + pub(crate) const STYLESHEET: &str = "testing.middleware-fixture.stylesheet"; + /// The line [`STYLESHEET`] adds. + pub(crate) const STYLESHEET_LINE: &str = "/* served by the middleware fixture */\n"; + + struct Stylesheet; + + impl Middleware for Stylesheet { + fn middleware_id(&self) -> &'static str { + STYLESHEET + } + + fn handles_media_type(&self, media_type: &str) -> bool { + media_type == "text/css" + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Serve] + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction { + stream: Some(Box::new(AppendLine(STYLESHEET_LINE))), + ..MiddlewareAction::pass() + } + } + } + /// Says it moves the origin's address itself, on its own media type, /// and then leaves the body as it came with a line added, so a test /// sees what core left to it. @@ -4149,6 +4227,7 @@ pub(crate) mod test_support { .with_middleware(Arc::new(Locating)) .with_middleware(Arc::new(Labeled { text: labeled })) .with_middleware(Arc::new(Framed)) + .with_middleware(Arc::new(Stylesheet)) .build(), )) } @@ -4552,6 +4631,7 @@ mod tests { }; use super::*; use crate::constants::COOKIE_EC; + use crate::media_signing::test_support::{Behavior, StubSigner}; use crate::middleware::{HTML_MEDIA_TYPE, MiddlewareAction, MiddlewareContext, PhaseEntry}; use crate::permissions::{Permission, PermissionSet, PermissionState}; use crate::platform::test_support::noop_services; @@ -7687,6 +7767,101 @@ mod tests { ); } + /// The names the two media signer test modules run under. + const MEDIA_PROBE_MODULE: &str = "testing.media-probe"; + const MEDIA_SECOND_MODULE: &str = "testing.media-second"; + + fn media_probe_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("media-probe") + .with_media_signer(StubSigner::new(Behavior::Sign, 1024)) + .build(), + )) + } + + fn media_second_registration( + _settings: &Settings, + ) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("media-second") + .with_media_signer(StubSigner::new(Behavior::Sign, 1024)) + .build(), + )) + } + + fn media_probe_builder() -> crate::integrations::IntegrationBuilder { + crate::integrations::IntegrationBuilder::new( + "media-probe", + "seam-probe", + media_probe_registration, + validate_nothing, + ) + .with_module_name(MEDIA_PROBE_MODULE) + } + + /// No section selects a media signer. A running registration's is handed + /// out for each request's services. + #[test] + fn a_running_registration_hands_out_its_media_signer() { + let settings = settings_naming(MEDIA_PROBE_MODULE); + + let registry = IntegrationRegistry::with_registrations(&settings, &[media_probe_builder()]) + .expect("should build a registry with a media signer"); + + assert_eq!( + registry.media_signer().map(|signer| signer.signer_id()), + Some("testing.media"), + "should hand out the running registration's signer" + ); + } + + /// A registration a section does not select runs nothing, its media + /// signer included, so no response is claimed. + #[test] + fn an_unselected_registration_offers_no_media_signer() { + let settings = crate::test_support::tests::create_test_settings(); + + let registry = IntegrationRegistry::with_registrations(&settings, &[media_probe_builder()]) + .expect("should build a registry without the probe selected"); + + assert!( + registry.media_signer().is_none(), + "an unselected registration's signer should not be handed out" + ); + } + + /// Two running signers would sign one signer's output as the other's + /// source, so the pair is refused. + #[test] + fn two_running_registrations_supplying_a_media_signer_are_refused() { + let mut settings = settings_naming(MEDIA_PROBE_MODULE); + settings.select_module("testing", MEDIA_SECOND_MODULE); + let extra = [ + media_probe_builder(), + crate::integrations::IntegrationBuilder::new( + "media-second", + "seam-probe", + media_second_registration, + validate_nothing, + ) + .with_module_name(MEDIA_SECOND_MODULE), + ]; + + let error = IntegrationRegistry::with_registrations(&settings, &extra) + .err() + .expect("should refuse two media signers"); + + let message = error.to_string(); + assert!( + message.contains("both supply a media signer") + && message.contains("`media-probe`") + && message.contains("`media-second`"), + "error should name both integrations: {message}" + ); + } + fn html_entry(path: Option<&str>, names: &[&str]) -> PhaseEntry { PhaseEntry { media_type: HTML_MEDIA_TYPE.to_owned(), @@ -7712,51 +7887,6 @@ mod tests { let registry = IntegrationRegistry::new(&settings) .expect("should build a registry whose entries name registered middleware"); - assert_eq!( - registry.middleware_ids(), - [ - fixture::HEAD, - fixture::LINKS, - fixture::BROKEN, - fixture::READER, - fixture::BROKEN_READER, - fixture::TEXT, - fixture::PREPARING, - fixture::REFUSING, - fixture::TEXT_HEADER, - fixture::LOCATING, - fixture::LABELED, - fixture::FRAMED, - crate::service_version::MODULE, - ], - "should list the middleware the selected module supplies, and core's own after them" - ); - assert_eq!( - registry.middleware_in(MiddlewarePhase::Fetch), - [ - fixture::HEAD, - fixture::LINKS, - fixture::BROKEN, - fixture::TEXT, - fixture::PREPARING, - fixture::REFUSING, - fixture::LABELED, - fixture::FRAMED - ], - "should list the middleware that run in the fetch phase" - ); - assert_eq!( - registry.middleware_in(MiddlewarePhase::Serve), - [ - fixture::READER, - fixture::BROKEN_READER, - fixture::PREPARING, - fixture::TEXT_HEADER, - fixture::LOCATING, - crate::service_version::MODULE - ], - "should list the middleware that run in the serve phase, core's own last" - ); let chain_for = |media_type: &str, path: &str| { registry .middleware_chain(&settings.fetch, MiddlewarePhase::Fetch, media_type, path) @@ -7979,45 +8109,6 @@ mod tests { ); } - #[test] - fn an_entry_naming_a_middleware_nothing_supplies_is_refused_with_what_is_supplied() { - let settings = fixture_settings_with_entries(vec![ - html_entry(Some("/news/"), &[fixture::HEAD]), - html_entry(None, &[fixture::HEAD, "testing.nothing"]), - ]); - - let error = IntegrationRegistry::new(&settings) - .err() - .expect("should refuse an entry naming a middleware nothing supplies"); - - let message = error.to_string(); - assert!( - message.contains( - "[[fetch]] entry 2 names `testing.nothing`, which no module that runs supplies" - ) && message.contains(&format!( - "[{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}]", - fixture::HEAD, - fixture::LINKS, - fixture::BROKEN, - fixture::READER, - fixture::BROKEN_READER, - fixture::TEXT, - fixture::PREPARING, - fixture::REFUSING, - fixture::TEXT_HEADER, - fixture::LOCATING, - fixture::LABELED, - fixture::FRAMED, - crate::service_version::MODULE - )), - "should name the entry and list what could be named: {message}" - ); - assert!( - !message.contains("no section selects"), - "should not blame a selection when the name is no module's: {message}" - ); - } - #[test] fn an_entry_naming_the_middleware_of_a_module_no_section_selects_says_so() { let mut settings = crate::test_support::tests::create_test_settings(); @@ -8159,59 +8250,6 @@ mod tests { ); } - #[test] - fn a_middleware_no_entry_names_is_listed_for_the_startup_warning() { - let unnamed = |entries: Vec| -> Vec<&'static str> { - let settings = fixture_settings_with_entries(entries); - let registry = IntegrationRegistry::new(&settings).expect("should build a registry"); - unnamed_middleware(&settings, ®istry.inner) - .into_iter() - .map(|(integration, middleware)| { - assert_eq!(integration, fixture::ID, "should name the supplier"); - middleware.middleware_id() - }) - .collect() - }; - - assert_eq!( - unnamed(Vec::new()), - [ - fixture::HEAD, - fixture::LINKS, - fixture::BROKEN, - fixture::READER, - fixture::BROKEN_READER, - fixture::TEXT, - fixture::PREPARING, - fixture::REFUSING, - fixture::TEXT_HEADER, - fixture::LOCATING, - fixture::LABELED, - fixture::FRAMED, - ], - "should list every middleware when there are no entries" - ); - assert_eq!( - unnamed(vec![ - html_entry(Some("/news/"), &[fixture::LINKS]), - html_entry(None, &[fixture::HEAD]), - ]), - [ - fixture::BROKEN, - fixture::READER, - fixture::BROKEN_READER, - fixture::TEXT, - fixture::PREPARING, - fixture::REFUSING, - fixture::TEXT_HEADER, - fixture::LOCATING, - fixture::LABELED, - fixture::FRAMED - ], - "should leave out a middleware any entry names" - ); - } - #[test] fn an_entry_naming_a_middleware_in_a_phase_it_does_not_run_in_is_refused() { let mut settings = settings_naming(fixture::MODULE); @@ -8264,26 +8302,6 @@ mod tests { chain_for(MiddlewarePhase::Serve, "/sport/today").is_empty(), "should run no serve middleware on a path only the fetch entries cover" ); - let unnamed: Vec<&str> = unnamed_middleware(&settings, ®istry.inner) - .into_iter() - .map(|(_, middleware)| middleware.middleware_id()) - .collect(); - assert_eq!( - unnamed, - [ - fixture::LINKS, - fixture::BROKEN, - fixture::BROKEN_READER, - fixture::TEXT, - fixture::PREPARING, - fixture::REFUSING, - fixture::TEXT_HEADER, - fixture::LOCATING, - fixture::LABELED, - fixture::FRAMED - ], - "should count a middleware as named only by an entry of a phase it runs in" - ); } /// A middleware under a name of the test's choosing, running in the diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 0422b2882c..f33e9d7361 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -45,6 +45,7 @@ pub mod constants; pub mod cookies; pub mod creative; pub mod creative_opportunities; +pub(crate) mod css_relocation; pub mod ec; pub mod edge_cookie; pub mod error; @@ -56,6 +57,7 @@ pub mod html_processor; pub mod http_util; pub mod inspect; pub mod integrations; +pub mod media_signing; pub mod middleware; pub mod module_context; pub mod module_name; @@ -70,6 +72,7 @@ pub mod provider_table; pub mod proxy; pub mod publisher; pub mod redacted; +pub mod rehost; pub mod request_signing; pub(crate) mod response_header_rewrite; pub mod response_privacy; diff --git a/crates/trusted-server-core/src/media_signing.rs b/crates/trusted-server-core/src/media_signing.rs new file mode 100644 index 0000000000..82c7c7ff4f --- /dev/null +++ b/crates/trusted-server-core/src/media_signing.rs @@ -0,0 +1,2094 @@ +//! Signed media, which a module makes from the exact bytes of a binary +//! response on its way to a reader. +//! +//! A module supplies a [`MediaSigner`] through +//! [`IntegrationRegistrationBuilder::with_media_signer`](crate::integrations::IntegrationRegistrationBuilder::with_media_signer), +//! and it runs wherever the registration runs. Core asks it about two +//! paths: a binary response the publisher path passes through, and the +//! answer of an asset route. Which responses are signed is decided by the +//! origin's media type, so a path with no file extension is covered as an +//! image, audio or video file is. +//! +//! The origin is asked as the reader asked, so a response that is not +//! signed keeps the origin's own range and conditional handling. Where the +//! origin answers `200` with a type the signer handles, within its limit, +//! core captures the body before anything else reads it and hands those +//! exact bytes to the signer. A request that plausibly asks for media asks +//! the origin for an identity-coded body, the only coding signed. Where the +//! origin answers a `206` or a `HEAD` for such a type, core asks it once +//! more for the whole representation, unconditionally, and signs what that +//! answer carries, but only where the signed whole can be kept, so the +//! origin is not read whole for every range. Where the reader's range or +//! `If-Match` brings a `416` or `412` the longer signed representation may +//! answer, and where the reader's `If-Range` or `If-Match` names a signed +//! representation, the origin is asked for the whole representation too. +//! Core applies no transformation of its own, so the captured source is +//! also the final body. +//! +//! An asset route that uses the platform's image optimizer is the one +//! place a transformation happens, and the optimizer reads the origin +//! itself, so core never holds its input. Core asks the optimizer to keep +//! its input's C2PA manifest and add a manifest of its own recording what +//! it did, which binds the input it read by hash. Only a whole `200` to a +//! `GET` whose body carries a C2PA manifest store is handed to the signer, +//! marked as [`MediaJob::optimizer_output`], so the optimizer's own record +//! is the parent of what is signed and core adds nothing to the history. +//! An optimizer writes such a record only where its input carried a +//! manifest, so an unsigned original's optimized answer is never signed. +//! A route may instead send the optimizer to its own signed original on +//! the serving host, see `AssetImageOptimizerConfig::signed_source`, so +//! the optimizer's input is the exact origin bytes core captured and +//! signed. +//! Nothing of it is kept, since its bytes depend on the transformation and +//! the reader's `Accept`, and its signed answer says it varies by `Accept`. +//! +//! The signed bytes are a representation of their own. Core gives them a +//! strong `ETag` of their own digest, a `Repr-Digest` and a +//! `Content-Length`, and answers the reader's `If-Match`, `If-None-Match`, +//! `If-Range` and `Range` against them, with `304`, `412`, `206` and `416` +//! as each requires. The origin's validators never describe signed bytes, +//! so they are removed. Ranges are offered only on a signed representation +//! that was kept. +//! +//! A signed representation is kept in the `[cache.assets]` loading cache +//! for as long as the origin's own headers made it shareably fresh, under +//! the namespace the signer signed it under, and only for a request whose +//! answer the origin could not have made one reader's own. Before the +//! origin is asked, a request of that kind which plausibly asks for media +//! and asks for no revalidation is looked up under the signer's current +//! [`namespace`](MediaSigner::namespace), so a changed or revoked policy +//! never serves a kept copy. A kept copy carries its `Age`, and one whose +//! body no longer hashes to its tag is dropped. +//! +//! What is never signed: a response the origin marked as one reader's, a +//! request carrying `Authorization`, a body larger than the signer's limit, +//! a content-coded body, a body the origin forbade transforming, a +//! response whose own `Link` already names a C2PA manifest, read from the +//! origin's own headers before core writes a cache policy, and a captured +//! body that is an init or media segment of a fragmented ISO BMFF stream, +//! whose provenance binds the stream's parts together. Such a response +//! goes to the reader as the origin answered it, its `Link` untouched. +//! One core meant to +//! sign and could not goes out without the origin's validators and held +//! only briefly and privately, so the next request can be signed. +//! +//! A signer may also sign a source larger than its limit, unchanged, in +//! the large lane, see [`MediaSigner::sign_large`]. Core never holds such a +//! source whole: it pins one version by the origin's strong `ETag` and +//! length, hashes it in bounded chunks, hands the signer a +//! [`VersionedMediaSource`] whose every read is pinned with `If-Match`, and +//! answers the reader with the origin's own bytes and validators plus the +//! signer's headers and a `Repr-Digest`. Only metadata is kept, under the +//! signer's current namespace and the pinned tag. A source without a strong +//! `ETag` goes to the reader as the origin answered it. + +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use base64::Engine as _; +use bytes::{Bytes, BytesMut}; +use edgezero_core::body::Body as EdgeBody; +use futures::StreamExt as _; +use http::{HeaderMap, HeaderName, HeaderValue, Method, Response, StatusCode, header}; +use sha2::{Digest as _, Sha256}; +use web_time::{SystemTime, UNIX_EPOCH}; + +use crate::middleware::{ResponseHeader, media_type_of}; +use crate::platform::{CacheSpec, LoadError, LoadedValue, RuntimeServices, StoreKey}; +use crate::response_privacy::cache_control_forbids_shared_storage; +use crate::settings::{ASSETS_CACHE, Settings}; + +mod large; + +pub(crate) use large::{LargeAnswer, PinnedOrigin}; +pub use large::{ + LargeMediaJob, LargeMediaSigning, LargeSignedMedia, MediaSourceError, SourceVersion, + VersionedMediaSource, +}; + +/// A module that signs the exact bytes of a binary representation. +/// +/// Registered with +/// [`IntegrationRegistrationBuilder::with_media_signer`](crate::integrations::IntegrationRegistrationBuilder::with_media_signer). +/// At most one running registration may supply one. +pub trait MediaSigner: Send + Sync { + /// The module's name, for logs. + fn signer_id(&self) -> &'static str; + + /// Whether a `GET` or `HEAD` request for `path` may be signed, asked + /// before the origin is. Core changes nothing about the origin request, + /// so this only leaves out paths that never carry media, such as an API. + /// The media type of the origin's answer decides the rest, so a signer + /// answers `true` for a path with no file extension. + fn handles_path(&self, path: &str) -> bool; + + /// Whether this signer signs a body of `media_type`, a bare lower case + /// `type/subtype` read from the origin's `Content-Type`. + fn handles_media_type(&self, media_type: &str) -> bool; + + /// The largest body core captures for this signer. A larger one goes to + /// the reader as the origin answered it, never held whole. + fn max_source_bytes(&self) -> usize; + + /// The policy and credential context a kept signed representation is + /// bound to, from a policy check no older than the signer's own policy + /// lifetime. Core keeps a signed representation under it and serves a + /// kept one only while this answers the same value. An error keeps + /// nothing and serves nothing kept for the request. Never a secret. + fn namespace<'a>(&'a self, services: &'a RuntimeServices) -> MediaNamespacing<'a>; + + /// Signs one captured representation. + fn sign<'a>(&'a self, services: &'a RuntimeServices, job: &'a MediaJob<'a>) + -> MediaSigning<'a>; + + /// The largest source this signer signs unchanged in the large lane, + /// see [`sign_large`](Self::sign_large), or `0`, the default, where it + /// signs none. Core uses the lane only for a source larger than + /// [`max_source_bytes`](Self::max_source_bytes) and no larger than this. + fn max_large_source_bytes(&self) -> u64 { + 0 + } + + /// The one wall-clock budget for a large source: core's hashing of it + /// and the whole of [`sign_large`](Self::sign_large). Core drops the + /// work, stalled reads included, when it is spent. `Duration::ZERO`, + /// the default, keeps the large lane closed, and the lane is also + /// closed on a platform without a timer. + fn max_large_media_duration(&self) -> Duration { + Duration::ZERO + } + + /// Signs one large source that reaches the reader unchanged, from its + /// whole-source digest and pinned reads, returning only the headers its + /// answers carry. + /// + /// Core asks only for a shared `GET` whose origin answer is a `200` or + /// `206` of a type the signer handles, identity coded, not marked as + /// one reader's, not forbidden from transformation, naming no manifest + /// of its own, saying `Accept-Ranges: bytes`, with one strong `ETag` + /// and a known total length above + /// [`max_source_bytes`](Self::max_source_bytes) and within + /// [`max_large_source_bytes`](Self::max_large_source_bytes), on a + /// platform that streams response bodies and offers a timer, and only + /// where no metadata is kept for that version under the current + /// [`namespace`](Self::namespace). The default signs nothing. + fn sign_large<'a>( + &'a self, + _services: &'a RuntimeServices, + _job: &'a LargeMediaJob<'a>, + ) -> LargeMediaSigning<'a> { + Box::pin(async { + Err(MediaSigningFailure { + reason: "large_media_unsupported", + }) + }) + } +} + +/// What [`MediaSigner::sign`] returns. +pub type MediaSigning<'a> = + Pin> + 'a>>; + +/// What [`MediaSigner::namespace`] returns. +pub type MediaNamespacing<'a> = + Pin> + 'a>>; + +/// One captured representation a signer is asked to sign. +#[derive(Debug, Clone, Copy)] +pub struct MediaJob<'a> { + /// The address the reader asked for, `scheme://host/path`, without its + /// query. + pub reader_url: &'a str, + /// The origin's `Content-Type`, as it sent it. + pub content_type: &'a str, + /// The exact body core received, captured before anything else read + /// it. Core transforms nothing, so it is also the final body: the + /// operations core performed are none and the record is complete. + pub source: &'a Bytes, + /// SHA-256 of [`source`](Self::source), lower case hex. + pub source_sha256: &'a str, + /// The address core fetched the source from, exactly as fetched, or + /// `None` when that address carries user information, a query or a + /// fragment, or when the source is + /// [`optimizer_output`](Self::optimizer_output). It names a mutable + /// resource, not an immutable version. + pub source_reference: Option<&'a str>, + /// The lower case host of the address core fetched the source from, + /// with no scheme, port, path, query or user information, present even + /// where [`source_reference`](Self::source_reference) is not. Core + /// always sets it for a body it captured over HTTP. For + /// [`optimizer_output`](Self::optimizer_output) it is the origin host + /// the optimizer was asked to read. + pub source_origin_host: Option<&'a str>, + /// The source is the platform image optimizer's answer, not the + /// origin's bytes. Core hands such a source over only where it carries + /// a C2PA manifest store, which the optimizer writes to record what it + /// did to its own input. A signer must have the signature refused + /// unless that embedded manifest is present and intact, so the + /// optimizer's own record of its input and transformations is the + /// parent of what is signed, never replaced by a claim of core's. + pub optimizer_output: bool, +} + +/// The signed representation a signer returns. +#[derive(Debug, Clone)] +pub struct SignedMedia { + /// The final signed bytes, which core sends as they are. + pub bytes: Bytes, + /// The `Content-Type` of the signed bytes. + pub content_type: HeaderValue, + /// Headers the signer writes on every response of the signed + /// representation: `200`, `206`, `304`, `412`, `416` and `HEAD`. A `Link` + /// is added to the origin's own links, and any other replaces the + /// origin's header of that name. A name core owns, see + /// [`is_reserved_header`], fails the signing. + pub headers: Vec, + /// The policy and credential context these bytes were actually signed + /// under, as [`MediaSigner::namespace`] gives it for that context. Core + /// keeps the signed representation under this value, never under a + /// namespace asked for again, so bytes signed under one policy are never + /// served while another is current. + pub namespace: String, +} + +/// Why a signer did not sign, which core logs before it sends the origin's +/// bytes unsigned. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +#[display("{reason}")] +pub struct MediaSigningFailure { + /// A short code naming the reason. + pub reason: &'static str, +} + +impl core::error::Error for MediaSigningFailure {} + +/// Headers core writes on a signed representation, which a signer may not +/// set. +const RESERVED_HEADERS: [&str; 17] = [ + "content-length", + "content-type", + "content-encoding", + "content-range", + "accept-ranges", + "etag", + "last-modified", + "cache-control", + "expires", + "vary", + "set-cookie", + "transfer-encoding", + "repr-digest", + "content-digest", + "digest", + "age", + "date", +]; + +/// Origin headers that describe the origin's bytes or one transfer of them, +/// never signed bytes. +const ORIGIN_REPRESENTATION_HEADERS: [&str; 11] = [ + "content-length", + "content-encoding", + "content-range", + "accept-ranges", + "etag", + "last-modified", + "repr-digest", + "content-digest", + "digest", + "content-md5", + "transfer-encoding", +]; + +/// Origin headers a kept signed representation carries besides the ones +/// core and the signer write. Core's per-request headers are written again +/// on each reader's copy, so none of them is kept. +const KEPT_ORIGIN_HEADERS: [&str; 6] = [ + "cache-control", + "expires", + "content-disposition", + "content-language", + "link", + "x-content-type-options", +]; + +/// Request headers that make an origin answer with less than the whole +/// representation. +const PRECONDITION_HEADERS: [HeaderName; 6] = [ + header::RANGE, + header::IF_RANGE, + header::IF_NONE_MATCH, + header::IF_MODIFIED_SINCE, + header::IF_MATCH, + header::IF_UNMODIFIED_SINCE, +]; + +/// The first bytes of a kept signed representation, naming its layout. +const KEPT_MAGIC: &[u8; 4] = b"TSM2"; + +/// How many kept signed representations one process holds in memory. Each +/// may be as large as a signer's limit, so few are held. +const KEPT_MEMORY_ENTRIES: usize = 4; + +/// What starts the opaque part of a signed representation's entity tag, +/// so a reader's precondition naming signed bytes is told from one naming +/// the origin's. +const SIGNED_TAG_PREFIX: &str = "sig-"; + +/// The `Cache-Control` an answer core meant to sign and could not goes out +/// with, so no cache holds unsigned bytes in place of signed ones for long. +const UNSIGNED_FALLBACK_CACHE_CONTROL: &str = "private, max-age=60"; + +/// `Sec-Fetch-Dest` values a browser sends for media. +const MEDIA_DESTINATIONS: [&str; 7] = [ + "image", "audio", "video", "track", "font", "object", "embed", +]; + +/// File extensions of media, for a request that names neither a media +/// destination nor a media type it accepts. +const MEDIA_EXTENSIONS: [&str; 33] = [ + "jpg", "jpeg", "png", "gif", "webp", "avif", "heic", "heif", "tif", "tiff", "svg", "bmp", + "ico", "jxl", "mp4", "m4v", "mov", "m4a", "mp3", "wav", "aac", "flac", "ogg", "oga", "ogv", + "opus", "webm", "avi", "mkv", "woff", "woff2", "ttf", "otf", +]; + +/// What a lookup that finds nothing kept answers, never stored. +const NOT_KEPT: &str = "no signed representation kept"; + +/// Whether `name` is a header core owns on a signed representation. +#[must_use] +pub fn is_reserved_header(name: &HeaderName) -> bool { + RESERVED_HEADERS.contains(&name.as_str()) +} + +/// Removes every header that would make the origin answer with less than +/// the whole representation, for the second, whole fetch of a claim. +pub(crate) fn strip_representation_preconditions(headers: &mut HeaderMap) { + for name in &PRECONDITION_HEADERS { + headers.remove(name); + } +} + +/// What the reader's request asked of the representation. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ReaderPreconditions { + if_match: Option, + if_none_match: Option, + if_range: Option, + range: Option, + if_modified_since: Option, + if_unmodified_since: Option, +} + +impl ReaderPreconditions { + fn of(headers: &HeaderMap) -> Self { + Self { + if_match: joined(headers, &header::IF_MATCH), + if_none_match: joined(headers, &header::IF_NONE_MATCH), + if_range: joined(headers, &header::IF_RANGE), + range: joined(headers, &header::RANGE), + if_modified_since: joined(headers, &header::IF_MODIFIED_SINCE), + if_unmodified_since: joined(headers, &header::IF_UNMODIFIED_SINCE), + } + } + + /// Whether `If-Range` or `If-Match` names a signed representation's + /// entity tag, see [`SIGNED_TAG_PREFIX`]. + fn names_signed_bytes(&self) -> bool { + let if_range = self + .if_range + .as_deref() + .and_then(single_entity_tag) + .is_some_and(|tag| is_signed_tag(&tag)); + let if_match = self + .if_match + .as_deref() + .is_some_and(|value| match TagList::parse(value) { + TagList::Any => false, + TagList::Tags(tags) => tags.iter().any(is_signed_tag), + }); + if_range || if_match + } +} + +/// Whether `tag` is a strong entity tag core gave a signed representation. +fn is_signed_tag(tag: &EntityTag<'_>) -> bool { + !tag.weak + && tag + .opaque + .strip_prefix(SIGNED_TAG_PREFIX) + .is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +/// The strong entity tag of signed bytes whose SHA-256 is `digest`. +fn signed_entity_tag(digest: &[u8]) -> String { + format!("\"{SIGNED_TAG_PREFIX}{}\"", hex::encode(digest)) +} + +/// Whether a request plausibly asks for media: the destination a browser +/// names, else the first type it accepts, else the path's file extension. +fn request_looks_like_media(path: &str, headers: &HeaderMap) -> bool { + if let Some(destination) = headers + .get("sec-fetch-dest") + .and_then(|value| value.to_str().ok()) + { + let destination = destination.trim(); + return MEDIA_DESTINATIONS + .iter() + .any(|media| destination.eq_ignore_ascii_case(media)); + } + let accepted = header_text(headers, &header::ACCEPT) + .split(',') + .next() + .map(media_type_of) + .unwrap_or_default(); + if ["image/", "audio/", "video/", "font/"] + .iter() + .any(|prefix| accepted.starts_with(prefix)) + { + return true; + } + path.rsplit('/') + .next() + .and_then(|name| name.rsplit_once('.')) + .is_some_and(|(_, extension)| { + MEDIA_EXTENSIONS + .iter() + .any(|media| extension.eq_ignore_ascii_case(media)) + }) +} + +/// Seconds since the Unix epoch now, from the clock every runtime offers. +fn unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) +} + +/// An answer core meant to sign and could not, made safe to send unsigned: +/// without the origin's validators, which a later conditional request would +/// use to pin the unsigned bytes, and held only briefly and only by the +/// reader, so the next request can be signed. +fn unsigned_fallback(mut response: Response) -> Response { + crate::cache_policy::remove_owned_cache_headers(&mut response); + let headers = response.headers_mut(); + for name in ["etag", "last-modified", "expires"] { + headers.remove(name); + } + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(UNSIGNED_FALLBACK_CACHE_CONTROL), + ); + response +} + +/// Every line of header `name`, joined as one list. A value that is not +/// text stays present and matches nothing. +fn joined(headers: &HeaderMap, name: &HeaderName) -> Option { + let mut values = headers.get_all(name).iter().peekable(); + values.peek()?; + Some( + values + .map(|value| String::from_utf8_lossy(value.as_bytes()).into_owned()) + .collect::>() + .join(", "), + ) +} + +/// What names one signed representation before the origin is asked: the +/// reader's address, the origin core asks and the address it asks there. +#[derive(Debug, Clone, Copy)] +pub(crate) struct VariantIdentity<'a> { + /// `scheme://host/path` the reader asked for, without its query. + pub reader_url: &'a str, + /// What tells this origin apart from another at one address. + pub origin_identity: &'a str, + /// The address core fetches, exactly as fetched. + pub fetched_url: &'a str, + /// The reader's request carried no query, so + /// [`reader_url`](Self::reader_url) is the whole address it asked for. + pub reader_query_free: bool, +} + +/// Independent remaining browser and shared-cache lifetimes. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OriginFreshness { + pub browser: Duration, + pub shared: Duration, +} + +/// What the origin's own headers said about its answer, read before core +/// changed or removed any of them, so a cache policy core writes never +/// stands in for the origin's. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OriginFacts { + /// The origin marked the answer as one reader's, by a cookie, a + /// `Cache-Control` that forbids shared storage or a `Vary` on the + /// reader. + private: bool, + /// The origin forbade transforming the body. + no_transform: bool, + /// How long a signed representation of the answer may be kept. + keep_for: Option, + /// Freshness at receipt, before any response processing. + freshness: OriginFreshness, +} + +impl OriginFacts { + /// What `headers`, the origin's as it sent them, say. + pub(crate) fn of(headers: &HeaderMap) -> Self { + Self::of_at(headers, SystemTime::now()) + } + + /// Read against an explicit receipt time, also used by deterministic tests. + pub(crate) fn of_at(headers: &HeaderMap, received_at: SystemTime) -> Self { + let freshness = origin_freshness(headers, received_at); + Self { + private: headers.contains_key(header::SET_COOKIE) + || cache_control_forbids_shared_storage(headers) + || varies_by_reader(headers), + no_transform: cache_control_has(headers, "no-transform"), + keep_for: (!freshness.shared.is_zero()).then_some(freshness.shared), + freshness, + } + } + + /// The origin marked the answer as one reader's, by a cookie, a + /// `Cache-Control` that forbids shared storage or a `Vary` on the + /// reader. + pub(crate) fn is_private(&self) -> bool { + self.private + } + + /// The origin forbade transforming the body, by `no-transform`. + pub(crate) fn forbids_transform(&self) -> bool { + self.no_transform + } + + /// Freshness after processing, without borrowing the shared lifetime for + /// the browser or granting a new lifetime when origin dates are removed. + pub(crate) fn remaining_freshness(&self, elapsed: Duration) -> OriginFreshness { + OriginFreshness { + browser: self.freshness.browser.saturating_sub(elapsed), + shared: self.freshness.shared.saturating_sub(elapsed), + } + } +} + +/// Where the response handed to [`MediaClaim::deliver`] came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Answered { + /// The origin, to the request as core sent it, with what its own + /// headers said. + Origin(OriginFacts), + /// A kept signed representation [`MediaClaim::kept`] found. + Kept, +} + +/// What [`MediaClaim::deliver`] decided. +pub(crate) enum Delivery { + /// The response to send. + Send(Response), + /// The origin answered with a part, a `HEAD`, or a `412` or `416` a + /// reader's signed-length range or signed tag brings, for a + /// representation that may be signed. The caller asks the origin once + /// more for the whole representation, see + /// [`strip_representation_preconditions`], and hands the answer to + /// [`MediaClaim::deliver_whole`], with this response to send where that + /// fails. + FetchWhole(Response), + /// The origin answered with a source larger than the signer's bounded + /// limit that the large lane admits, pinned to its version. The caller + /// hands it to [`MediaClaim::deliver_large`] with how the origin is + /// asked again. + Large(LargeAnswer), +} + +/// A request the services' [`MediaSigner`] may sign the answer to. +pub(crate) struct MediaClaim { + signer: Arc, + reader_method: Method, + preconditions: ReaderPreconditions, + spec: CacheSpec, + /// The answer may be kept for, and a kept one served to, other + /// readers: the request carries nothing that could make the origin's + /// answer this reader's own. + shared: bool, + /// The request lets a kept signed representation answer it: it is + /// shared, does not ask for revalidation, and plausibly asks for media. + reads_kept: bool, + /// The request plausibly asks for media. + looks_like_media: bool, + /// The reader's `If-Range` or `If-Match` names a signed + /// representation, which the origin cannot judge. + names_signed_bytes: bool, + /// The answer is the platform image optimizer's, see + /// [`answered_by_optimizer`](Self::answered_by_optimizer). + optimizer_output: bool, + /// The claim answered the reader's preconditions against the + /// representation it delivered, see + /// [`answer_unsigned`](Self::answer_unsigned). + conditions_answered: AtomicBool, +} + +impl MediaClaim { + /// The claim the request's services' signer makes on a request, or + /// `None` where no signer is registered, the method is neither `GET` + /// nor `HEAD`, the request carries `Authorization`, or the signer leaves + /// `path` alone. + /// + /// `shared` says whether nothing the request carries beyond these + /// headers could make the origin's answer this reader's own, being a + /// cookie sent to the origin, a module's request state or a + /// personalized delivery. Only a shared request reads or writes a kept + /// signed representation. + pub(crate) fn of_request( + settings: &Settings, + services: &RuntimeServices, + method: &Method, + path: &str, + headers: &HeaderMap, + shared: bool, + ) -> Option { + let signer = services.media_signer()?; + let claimed = (*method == Method::GET || *method == Method::HEAD) + && !headers.contains_key(header::AUTHORIZATION) + && signer.handles_path(path); + if !claimed { + return None; + } + let preconditions = ReaderPreconditions::of(headers); + let looks_like_media = request_looks_like_media(path, headers); + let names_signed_bytes = preconditions.names_signed_bytes(); + Some(Self { + signer: Arc::clone(signer), + reader_method: method.clone(), + preconditions, + spec: CacheSpec { + name: ASSETS_CACHE, + lifetimes: settings + .cache + .assets + .lifetimes(crate::publisher::ASSET_DEFAULT_LIFETIMES), + memory_entries: KEPT_MEMORY_ENTRIES, + }, + shared, + reads_kept: shared + && looks_like_media + && !crate::publisher::request_asks_for_revalidation(headers), + looks_like_media, + names_signed_bytes, + optimizer_output: false, + conditions_answered: AtomicBool::new(false), + }) + } + + /// The same claim on a request the platform's image optimizer answers. + /// + /// The optimizer reads the origin itself, so core never holds its + /// input. Its answer is signed only where it carries a C2PA manifest + /// store, the optimizer's own record of the input it read and what it + /// did, and the signer must refuse unless that manifest is present and + /// intact, see [`MediaJob::optimizer_output`]. Its answer depends on + /// the transformation and the reader's `Accept`, neither of which names + /// a kept representation, so nothing is read from or written to the + /// kept representations, and only a whole `200` to a `GET` is signed: + /// a part or `HEAD` is never asked for again past the optimizer. + pub(crate) fn answered_by_optimizer(mut self) -> Self { + self.shared = false; + self.reads_kept = false; + self.optimizer_output = true; + self + } + + /// Whether the request plausibly asks for media, by the destination + /// the browser names, the type it accepts first, or the file extension. + /// The origin is then asked for an identity-coded body, the only coding + /// signed. + pub(crate) fn looks_like_media(&self) -> bool { + self.looks_like_media + } + + /// Whether the reader's `If-Range` or `If-Match` names a signed + /// representation. The origin cannot judge such a precondition, so the + /// caller asks it for the whole representation, see + /// [`strip_representation_preconditions`], and core answers the + /// reader's range and preconditions against the signed bytes. + pub(crate) fn names_signed_bytes(&self) -> bool { + self.names_signed_bytes + } + + /// Whether the answer is the platform image optimizer's, see + /// [`answered_by_optimizer`](Self::answered_by_optimizer). + pub(crate) fn signs_optimizer_output(&self) -> bool { + self.optimizer_output + } + + /// The signed representation kept for `identity` under the signer's + /// current namespace, as an origin answer to hand to + /// [`deliver`](Self::deliver) with [`Answered::Kept`], or `None`. Only + /// a shared request that plausibly asks for media and asks for no + /// revalidation is looked up, so the signer's namespace is not resolved + /// for any other. + pub(crate) async fn kept( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + ) -> Option> { + if !self.reads_kept || !services.loading_cache_for(ASSETS_CACHE).keeps_values() { + return None; + } + let namespace = match self.signer.namespace(services).await { + Ok(namespace) => namespace, + Err(failure) => { + log::debug!( + "media signing: nothing kept looked up signer={} reason={failure}", + self.signer.signer_id() + ); + return None; + } + }; + let key = self.store_key(services, &namespace, identity); + let bytes = services + .load_cached( + &self.spec, + &key, + Box::new(|| Box::pin(async { Err(LoadError::new(NOT_KEPT)) })), + ) + .await + .ok()?; + let kept = decode_kept(&bytes, unix_seconds()); + if kept.is_none() { + log::warn!( + "media signing: kept representation unreadable or altered signer={}", + self.signer.signer_id() + ); + services + .loading_cache_for(ASSETS_CACHE) + .remove(&services.clone().without_connection(), &self.spec, &key) + .await; + } + kept + } + + /// Answers the reader from `response`: a kept signed representation, or + /// the origin's answer, signed where the signer signs it. + pub(crate) async fn deliver( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + answered: Answered, + response: Response, + ) -> Delivery { + let facts = match answered { + Answered::Kept => return Delivery::Send(self.respond_kept(response)), + Answered::Origin(facts) => facts, + }; + if self.optimizer_output { + return Delivery::Send( + self.deliver_optimized(services, identity, &facts, response) + .await, + ); + } + let signer_id = self.signer.signer_id(); + let status = response.status(); + let limit = to_u64(self.signer.max_source_bytes()); + match status { + StatusCode::OK if self.reader_method != Method::HEAD => { + if let Some(reason) = self.unsignable_reason(response.headers(), &facts) { + log::debug!( + "media signing: outcome=unsigned signer={signer_id} reason={reason}" + ); + return Delivery::Send(response); + } + let total = declared_length(response.headers()); + if total.is_some_and(|total| total > limit) { + return self.large_or_unsigned(services, response, total, &facts); + } + let (parts, body) = response.into_parts(); + let source = match self.capture_within_limit(&parts.headers, body).await { + Ok(source) => source, + Err(body) => return Delivery::Send(Response::from_parts(parts, body)), + }; + if is_fragmented_stream_part(&source) { + log::info!( + "media signing: outcome=unsigned signer={signer_id} reason=fragmented_stream_part" + ); + return Delivery::Send(Response::from_parts(parts, EdgeBody::Once(source))); + } + match self.sign(services, identity, &parts.headers, &source).await { + Ok(signed) => Delivery::Send( + self.respond_signed(services, identity, parts, signed, facts.keep_for) + .await, + ), + Err(()) => Delivery::Send(unsigned_fallback(Response::from_parts( + parts, + EdgeBody::Once(source), + ))), + } + } + StatusCode::OK | StatusCode::PARTIAL_CONTENT => { + if let Some(reason) = self.unsignable_reason(response.headers(), &facts) { + log::debug!( + "media signing: outcome=unsigned signer={signer_id} reason={reason}" + ); + return Delivery::Send(response); + } + let total = if status == StatusCode::PARTIAL_CONTENT { + content_range_total(response.headers()) + } else { + declared_length(response.headers()) + }; + let too_large = if status == StatusCode::PARTIAL_CONTENT { + total.is_none_or(|total| total > limit) + } else { + total.is_some_and(|total| total > limit) + }; + if too_large { + return self.large_or_unsigned(services, response, total, &facts); + } + // A part or a `HEAD` is signed only where the signed whole + // can be kept, so the reader's next range is answered from + // it and the origin is not read whole for every range. + if !self.retains(services, &facts) { + log::info!( + "media signing: outcome=unsigned signer={signer_id} reason=part_not_kept" + ); + return Delivery::Send(response); + } + Delivery::FetchWhole(response) + } + StatusCode::PRECONDITION_FAILED | StatusCode::RANGE_NOT_SATISFIABLE + if self.preconditions.range.is_some() || self.preconditions.if_match.is_some() => + { + // The reader's range or `If-Match` may describe the signed + // representation, which is longer than the origin's and has + // a tag of its own, so the origin's refusal is not the + // answer until the signed bytes are asked. + let too_large = + content_range_total(response.headers()).is_some_and(|total| total > limit); + if facts.private || too_large { + return Delivery::Send(response); + } + Delivery::FetchWhole(response) + } + StatusCode::NOT_MODIFIED => Delivery::Send( + self.decorate_not_modified(services, identity, &facts, response) + .await, + ), + _ => Delivery::Send(response), + } + } + + /// Answers the reader from the platform image optimizer's answer: a + /// whole `200` to a `GET` that carries a C2PA manifest store is signed, + /// and every other answer goes as the optimizer made it. + async fn deliver_optimized( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + facts: &OriginFacts, + response: Response, + ) -> Response { + let signer_id = self.signer.signer_id(); + if response.status() != StatusCode::OK || self.reader_method != Method::GET { + return response; + } + if let Some(reason) = self.unsignable_reason(response.headers(), facts) { + log::debug!("media signing: outcome=unsigned signer={signer_id} reason={reason}"); + return response; + } + let (parts, body) = response.into_parts(); + let source = match self.capture_within_limit(&parts.headers, body).await { + Ok(source) => source, + Err(body) => return Response::from_parts(parts, body), + }; + // The optimizer writes a manifest only where its input carried one. + // Without it nothing records what the optimizer read or did, so the + // answer is not signed. + if !carries_c2pa_manifest_store(&source) { + log::info!( + "media signing: outcome=unsigned signer={signer_id} reason=image_optimizer_output_without_c2pa" + ); + return Response::from_parts(parts, EdgeBody::Once(source)); + } + // The optimizer may choose its output format from the reader's + // `Accept`, and it removes the origin's `Vary`, so a cache past core + // is told the signed bytes depend on it. + let mut parts = parts; + let varies_by_accept = parts + .headers + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|name| name.trim() == "*" || name.trim().eq_ignore_ascii_case("accept")); + if !varies_by_accept { + parts + .headers + .append(header::VARY, HeaderValue::from_static("Accept")); + } + match self.sign(services, identity, &parts.headers, &source).await { + Ok(signed) => { + self.respond_signed(services, identity, parts, signed, None) + .await + } + Err(()) => unsigned_fallback(Response::from_parts(parts, EdgeBody::Once(source))), + } + } + + /// Answers the reader from the origin's answer to the whole, + /// unconditional request [`Delivery::FetchWhole`] asked for, or sends + /// `fallback` where it cannot be signed. + /// + /// `whole` is the origin's answer as it came, whose own headers decide + /// what is signed and how long it is kept. The signed response is + /// written on `fallback`'s headers, which carry what core wrote for + /// this reader. + pub(crate) async fn deliver_whole( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + whole: Response, + fallback: Response, + ) -> Response { + let signer_id = self.signer.signer_id(); + // The answer sent where the whole cannot be signed already declares + // a manifest of the origin's own, which core never competes with. + if carries_manifest_link(fallback.headers()) { + log::info!( + "media signing: outcome=unsigned signer={signer_id} reason=origin_manifest_link" + ); + return fallback; + } + let facts = OriginFacts::of(whole.headers()); + if whole.status() != StatusCode::OK { + log::info!( + "media signing: outcome=unsigned signer={signer_id} reason=whole_fetch_status_{}", + whole.status().as_u16() + ); + return unsigned_fallback(fallback); + } + if let Some(reason) = self.unsignable_reason(whole.headers(), &facts) { + log::info!("media signing: outcome=unsigned signer={signer_id} reason={reason}"); + return fallback; + } + let (whole_parts, whole_body) = whole.into_parts(); + let Ok(source) = self + .capture_within_limit(&whole_parts.headers, whole_body) + .await + else { + return fallback; + }; + // A part of a fragmented stream goes as the origin answered it. + if is_fragmented_stream_part(&source) { + log::info!( + "media signing: outcome=unsigned signer={signer_id} reason=fragmented_stream_part" + ); + return fallback; + } + match self + .sign(services, identity, &whole_parts.headers, &source) + .await + { + Ok(signed) => { + let (parts, _) = fallback.into_parts(); + self.respond_signed(services, identity, parts, signed, facts.keep_for) + .await + } + Err(()) => unsigned_fallback(fallback), + } + } + + /// Whether a signed whole made for this request can be kept: the request + /// is shared, the origin gave its answer a shared freshness, and the + /// deployment's asset cache keeps values. + fn retains(&self, services: &RuntimeServices, facts: &OriginFacts) -> bool { + self.shared + && facts.keep_for.is_some() + && services.loading_cache_for(ASSETS_CACHE).keeps_values() + } + + /// The whole body of a signable `200`, or the body as it stands where + /// it is larger than the signer's limit or could not be read, so the + /// reader still receives every byte the origin sent. + async fn capture_within_limit( + &self, + headers: &HeaderMap, + body: EdgeBody, + ) -> Result { + let signer_id = self.signer.signer_id(); + let limit = self.signer.max_source_bytes(); + if declared_length(headers).is_some_and(|length| length > to_u64(limit)) { + log::info!("media signing: outcome=unsigned signer={signer_id} reason=exceeds_limit"); + return Err(body); + } + match capture(body, limit).await { + Captured::Whole(source) => Ok(source), + Captured::Partial(body) => { + log::info!( + "media signing: outcome=unsigned signer={signer_id} reason=exceeds_limit_or_unreadable" + ); + Err(body) + } + } + } + + /// Why a response is not handed to the signer, or `None` when it is. + /// `facts` are the origin's own, read before core wrote its cache + /// policy on `headers`. + fn unsignable_reason(&self, headers: &HeaderMap, facts: &OriginFacts) -> Option<&'static str> { + if facts.private || headers.contains_key(header::SET_COOKIE) { + return Some("origin_private"); + } + // Provenance the origin already links to describes these bytes, so + // they are neither signed again nor given a competing manifest. + if carries_manifest_link(headers) { + return Some("origin_manifest_link"); + } + if facts.no_transform { + return Some("no_transform"); + } + let encoding = header_text(headers, &header::CONTENT_ENCODING).trim(); + if !encoding.is_empty() && !encoding.eq_ignore_ascii_case("identity") { + return Some("content_encoded"); + } + let media_type = media_type_of(header_text(headers, &header::CONTENT_TYPE)); + if media_type.is_empty() || !self.signer.handles_media_type(&media_type) { + return Some("media_type"); + } + None + } + + /// Asks the signer for the signed representation of `source`, logging + /// the outcome with no address, since a reader's or origin's address + /// may carry a credential. + async fn sign( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + headers: &HeaderMap, + source: &Bytes, + ) -> Result { + let signer_id = self.signer.signer_id(); + let source_sha256 = hex::encode(Sha256::digest(source)); + let origin_host = url::Url::parse(identity.fetched_url) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)); + let job = MediaJob { + reader_url: identity.reader_url, + content_type: header_text(headers, &header::CONTENT_TYPE), + source, + source_sha256: &source_sha256, + // No address serves the optimizer's answer. + source_reference: if self.optimizer_output { + None + } else { + source_reference(identity.fetched_url) + }, + source_origin_host: origin_host.as_deref(), + optimizer_output: self.optimizer_output, + }; + let checked = match self.signer.sign(services, &job).await { + Ok(signed) if signed.bytes.is_empty() => Err(MediaSigningFailure { + reason: "empty_signed_body", + }), + Ok(signed) + if signed + .headers + .iter() + .any(|(name, _)| is_reserved_header(name)) => + { + Err(MediaSigningFailure { + reason: "reserved_header", + }) + } + Ok(signed) if signed.namespace.is_empty() => Err(MediaSigningFailure { + reason: "no_namespace", + }), + other => other, + }; + match checked { + Ok(signed) => { + log::info!( + "media signing: outcome=signed signer={signer_id} source_bytes={} signed_bytes={}", + source.len(), + signed.bytes.len() + ); + Ok(signed) + } + Err(failure) => { + log::warn!("media signing: outcome=unsigned signer={signer_id} reason={failure}"); + Err(()) + } + } + } + + /// The signed representation, kept for `keep_for` where this request is + /// shared, and answered against the reader's request. Ranges are offered + /// only on a representation that was kept, so a reader's later range is + /// answered from it and never from a part of the origin's own bytes. + async fn respond_signed( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + mut parts: http::response::Parts, + signed: SignedMedia, + keep_for: Option, + ) -> Response { + let origin_age = digits(header_text(&parts.headers, &header::AGE).trim()).unwrap_or(0); + for name in ORIGIN_REPRESENTATION_HEADERS { + parts.headers.remove(name); + } + let digest = Sha256::digest(&signed.bytes); + let etag = signed_entity_tag(digest.as_slice()); + let repr_digest = format!( + "sha-256=:{}:", + base64::engine::general_purpose::STANDARD.encode(digest.as_slice()) + ); + let mut representation = HeaderMap::new(); + representation.insert(header::CONTENT_TYPE, signed.content_type); + representation.insert( + header::ETAG, + HeaderValue::from_str(&etag).expect("should build a hex entity tag"), + ); + representation.insert( + HeaderName::from_static("repr-digest"), + HeaderValue::from_str(&repr_digest).expect("should build a base64 digest"), + ); + for name in KEPT_ORIGIN_HEADERS { + for value in parts.headers.get_all(name) { + representation.append(HeaderName::from_static(name), value.clone()); + } + } + for (name, value) in signed.headers { + if name == header::LINK { + representation.append(name, value); + } else { + representation.insert(name, value); + } + } + let kept = match keep_for.filter(|_| self.shared) { + Some(lifetime) => { + let value = encode_kept(&KeptRecord { + kept_at: unix_seconds(), + age: origin_age, + headers: &representation, + body: &signed.bytes, + }); + self.keep(services, identity, &signed.namespace, value, lifetime) + .await + } + None => false, + }; + for name in representation.keys() { + parts.headers.remove(name); + } + for (name, value) in &representation { + parts.headers.append(name.clone(), value.clone()); + } + self.answer(parts, signed.bytes, kept) + } + + /// A kept signed representation, answered against the reader's + /// request. + fn respond_kept(&self, response: Response) -> Response { + let (parts, body) = response.into_parts(); + let bytes = body.into_bytes().unwrap_or_default(); + self.answer(parts, bytes, true) + } + + /// Answers the reader's preconditions and range against the signed + /// `bytes`, whose `ETag` is in `parts`. + fn answer( + &self, + parts: http::response::Parts, + bytes: Bytes, + ranges_offered: bool, + ) -> Response { + let length = to_u64(bytes.len()); + let etag = parts + .headers + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let outcome = evaluate( + &self.reader_method, + &self.preconditions, + etag.as_deref(), + None, + length, + ); + self.conditions_answered.store(true, Ordering::Relaxed); + self.apply(parts, bytes, outcome, ranges_offered) + } + + /// `response`, a whole `200` this claim delivered, answered against the + /// reader's preconditions where the claim has not already answered them, + /// which it has for every signed representation it delivered. For a + /// request whose preconditions were not sent to the origin, so the + /// origin's whole answer is checked against them here, by the + /// representation's own `ETag` and `Last-Modified`. A range is not + /// applied, so a passing answer goes whole, as RFC 9110 allows. + pub(crate) fn answer_unsigned(&self, response: Response) -> Response { + if self.conditions_answered.load(Ordering::Relaxed) { + return response; + } + answer_whole_with(&self.reader_method, &self.preconditions, response) + } +} + +/// `response`, a whole `200`, answered against the preconditions of a +/// reader's request carrying `reader_headers`, by the representation's own +/// `ETag` and `Last-Modified`, see [`MediaClaim::answer_unsigned`]: `412` +/// or `304` with no body, or the response as it is. A range is not applied. +pub(crate) fn answer_whole( + method: &Method, + reader_headers: &HeaderMap, + response: Response, +) -> Response { + answer_whole_with(method, &ReaderPreconditions::of(reader_headers), response) +} + +fn answer_whole_with( + method: &Method, + preconditions: &ReaderPreconditions, + response: Response, +) -> Response { + if response.status() != StatusCode::OK { + return response; + } + let headers = response.headers(); + let etag = headers + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let last_modified = headers + .get(header::LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .and_then(|value| httpdate::parse_http_date(value.trim()).ok()); + let whole = ReaderPreconditions { + range: None, + if_range: None, + ..preconditions.clone() + }; + let outcome = evaluate(method, &whole, etag.as_deref(), last_modified, 0); + let (mut parts, body) = response.into_parts(); + match outcome { + Outcome::NotModified => { + parts.status = StatusCode::NOT_MODIFIED; + for name in ["content-length", "content-range", "transfer-encoding"] { + parts.headers.remove(name); + } + Response::from_parts(parts, EdgeBody::empty()) + } + Outcome::PreconditionFailed => { + parts.status = StatusCode::PRECONDITION_FAILED; + for name in [ + "content-type", + "content-range", + "transfer-encoding", + "content-encoding", + ] { + parts.headers.remove(name); + } + parts + .headers + .insert(header::CONTENT_LENGTH, HeaderValue::from(0_u64)); + Response::from_parts(parts, EdgeBody::empty()) + } + _ => Response::from_parts(parts, body), + } +} + +/// Removes the headers that describe the origin's bytes or one transfer of +/// them, for a representation core made of them. +pub(crate) fn remove_origin_representation_headers(headers: &mut HeaderMap) { + for name in ORIGIN_REPRESENTATION_HEADERS { + headers.remove(name); + } +} + +impl MediaClaim { + /// Keeps a signed representation for `identity` under `namespace`, the + /// one it was signed under, so a later request is answered before the + /// origin is asked. Whether it was kept. + async fn keep( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + namespace: &str, + value: Bytes, + lifetime: Duration, + ) -> bool { + if !services.loading_cache_for(ASSETS_CACHE).keeps_values() { + return false; + } + let key = self.store_key(services, namespace, identity); + let stored = services + .load_cached( + &self.spec, + &key, + Box::new(move || { + Box::pin(async move { Ok(LoadedValue::new(value).with_lifetime(lifetime)) }) + }), + ) + .await; + match stored { + Ok(_) => true, + Err(error) => { + log::debug!( + "media signing: representation not kept signer={} error={error}", + self.signer.signer_id() + ); + false + } + } + } + + /// The key a signed representation for `identity` is kept under, bound + /// to `namespace`. + fn store_key( + &self, + services: &RuntimeServices, + namespace: &str, + identity: &VariantIdentity<'_>, + ) -> StoreKey { + let mut digest = Sha256::new(); + for part in [ + self.signer.signer_id(), + namespace, + identity.reader_url, + identity.origin_identity, + identity.fetched_url, + ] { + digest.update(part.as_bytes()); + digest.update([0]); + } + StoreKey::new( + services.server_key_prefix(), + ASSETS_CACHE, + format!("media-{}", hex::encode(digest.finalize().as_slice())), + ) + } + + /// Writes `outcome` on the response: its status, its framing headers and + /// the part of `bytes` it carries. + fn apply( + &self, + mut parts: http::response::Parts, + bytes: Bytes, + outcome: Outcome, + ranges_offered: bool, + ) -> Response { + let length = to_u64(bytes.len()); + let headers = &mut parts.headers; + for name in [ + "content-range", + "content-length", + "transfer-encoding", + "content-encoding", + ] { + headers.remove(name); + } + headers.insert( + header::ACCEPT_RANGES, + HeaderValue::from_static(if ranges_offered { "bytes" } else { "none" }), + ); + let carries_body = self.reader_method != Method::HEAD; + let body = match outcome { + Outcome::Full => { + parts.status = StatusCode::OK; + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(length)); + if carries_body { + EdgeBody::Once(bytes) + } else { + EdgeBody::empty() + } + } + Outcome::NotModified => { + parts.status = StatusCode::NOT_MODIFIED; + EdgeBody::empty() + } + Outcome::PreconditionFailed => { + parts.status = StatusCode::PRECONDITION_FAILED; + headers.remove(header::CONTENT_TYPE); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(0_u64)); + EdgeBody::empty() + } + Outcome::Partial { first, last } => { + parts.status = StatusCode::PARTIAL_CONTENT; + headers.insert( + header::CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes {first}-{last}/{length}")) + .expect("should build a content range"), + ); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(last - first + 1)); + let from = usize::try_from(first) + .unwrap_or(bytes.len()) + .min(bytes.len()); + let to = usize::try_from(last) + .map_or(bytes.len(), |last| last.saturating_add(1)) + .min(bytes.len()); + EdgeBody::Once(bytes.slice(from..to.max(from))) + } + Outcome::Unsatisfiable => { + parts.status = StatusCode::RANGE_NOT_SATISFIABLE; + headers.insert( + header::CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes */{length}")) + .expect("should build a content range"), + ); + headers.remove(header::CONTENT_TYPE); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(0_u64)); + EdgeBody::empty() + } + }; + Response::from_parts(parts, body) + } +} + +/// Origin freshness at receipt. Revalidation-only answers and reader-varying +/// responses authorize no reusable copy. Shared freshness falls back to +/// `max-age` only when `s-maxage` is absent, never when it is malformed. +fn origin_freshness(headers: &HeaderMap, received_at: SystemTime) -> OriginFreshness { + if ["no-store", "no-cache", "private"] + .iter() + .any(|directive| cache_control_has(headers, directive)) + || headers + .get_all(header::VARY) + .iter() + .flat_map(|value| value.to_str().unwrap_or("*").split(',')) + .map(str::trim) + .any(|name| !name.is_empty() && !name.eq_ignore_ascii_case("accept-encoding")) + { + return OriginFreshness::default(); + } + let age = match headers.get(header::AGE) { + Some(value) => match value.to_str().ok().and_then(|value| digits(value.trim())) { + Some(seconds) => Duration::from_secs(seconds), + None => return OriginFreshness::default(), + }, + None => Duration::ZERO, + }; + let apparent_age = match headers.get(header::DATE) { + Some(value) => match value + .to_str() + .ok() + .and_then(|value| httpdate::parse_http_date(value.trim()).ok()) + { + Some(date) => received_at + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .saturating_sub( + date.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(), + ), + None => return OriginFreshness::default(), + }, + None => Duration::ZERO, + }; + let current_age = age.max(apparent_age); + let browser = cache_control_seconds(headers, "max-age"); + let shared = match cache_control_seconds(headers, "s-maxage") { + Ok(None) => browser, + value => value, + }; + let remaining = |seconds: Result, ()>| { + Duration::from_secs(seconds.ok().flatten().unwrap_or(0)).saturating_sub(current_age) + }; + OriginFreshness { + browser: remaining(browser), + shared: remaining(shared), + } +} + +/// An absent directive permits fallback; an invalid or repeated one does not. +fn cache_control_seconds(headers: &HeaderMap, directive: &str) -> Result, ()> { + let mut seconds = None; + for line in headers.get_all(header::CACHE_CONTROL) { + for part in line.to_str().map_err(|_| ())?.split(',') { + let (name, value) = part.split_once('=').unwrap_or((part, "")); + if !name.trim().eq_ignore_ascii_case(directive) { + continue; + } + if seconds.is_some() { + return Err(()); + } + let value = value.trim(); + let value = if let Some(quoted) = value.strip_prefix('"') { + quoted.strip_suffix('"').ok_or(())? + } else { + value + }; + seconds = Some(digits(value).ok_or(())?); + } + } + Ok(seconds) +} + +/// What a kept signed representation holds besides its key. +struct KeptRecord<'a> { + /// When it was kept, in seconds since the Unix epoch. + kept_at: u64, + /// The `Age` the origin's answer carried when it was kept. + age: u64, + headers: &'a HeaderMap, + body: &'a Bytes, +} + +/// A kept signed representation, as written: [`KEPT_MAGIC`], when it was +/// kept and the age it had then, the header count, each header's name and +/// value with their lengths, then the body. +fn encode_kept(record: &KeptRecord<'_>) -> Bytes { + let mut out = BytesMut::with_capacity(record.body.len() + 512); + out.extend_from_slice(KEPT_MAGIC); + out.extend_from_slice(&record.kept_at.to_be_bytes()); + out.extend_from_slice(&record.age.to_be_bytes()); + let count = u32::try_from(record.headers.len()).unwrap_or(u32::MAX); + out.extend_from_slice(&count.to_be_bytes()); + for (name, value) in record + .headers + .iter() + .take(usize::try_from(count).unwrap_or(usize::MAX)) + { + let name = name.as_str().as_bytes(); + let value = value.as_bytes(); + out.extend_from_slice(&u32::try_from(name.len()).unwrap_or(u32::MAX).to_be_bytes()); + out.extend_from_slice(name); + out.extend_from_slice(&u32::try_from(value.len()).unwrap_or(u32::MAX).to_be_bytes()); + out.extend_from_slice(value); + } + out.extend_from_slice(record.body); + out.freeze() +} + +/// A kept signed representation as a `200` answer at `now`, with the `Age` +/// it has reached, or `None` where the bytes are not one [`encode_kept`] +/// wrote or the body is not the one its entity tag names. +fn decode_kept(bytes: &Bytes, now: u64) -> Option> { + let mut reader = KeptReader { + rest: bytes.strip_prefix(KEPT_MAGIC.as_slice())?, + }; + let kept_at = reader.number()?; + let age = reader.number()?; + let count = reader.length()?; + let mut headers = HeaderMap::new(); + for _ in 0..count { + let name_length = reader.length()?; + let name = HeaderName::from_bytes(reader.take(name_length)?).ok()?; + let value_length = reader.length()?; + let value = HeaderValue::from_bytes(reader.take(value_length)?).ok()?; + headers.append(name, value); + } + let body = bytes.slice(bytes.len() - reader.rest.len()..); + let expected = signed_entity_tag(Sha256::digest(&body).as_slice()); + if header_text(&headers, &header::ETAG) != expected { + return None; + } + headers.insert( + header::AGE, + HeaderValue::from(age.saturating_add(now.saturating_sub(kept_at))), + ); + let mut response = Response::new(EdgeBody::Once(body)); + *response.headers_mut() = headers; + Some(response) +} + +/// Reads a kept representation's fields in order. +struct KeptReader<'a> { + rest: &'a [u8], +} + +impl<'a> KeptReader<'a> { + fn take(&mut self, length: usize) -> Option<&'a [u8]> { + let taken = self.rest.get(..length)?; + self.rest = self.rest.get(length..)?; + Some(taken) + } + + fn length(&mut self) -> Option { + let bytes: [u8; 4] = self.take(4)?.try_into().ok()?; + usize::try_from(u32::from_be_bytes(bytes)).ok() + } + + fn number(&mut self) -> Option { + let bytes: [u8; 8] = self.take(8)?.try_into().ok()?; + Some(u64::from_be_bytes(bytes)) + } +} + +/// The fetched address a signer may name as the source, being the address +/// exactly as fetched when it carries no user information, query or +/// fragment, which could hold a credential or a reader's data. +fn source_reference(fetched_url: &str) -> Option<&str> { + let parsed = url::Url::parse(fetched_url).ok()?; + let plain = parsed.username().is_empty() + && parsed.password().is_none() + && parsed.query().is_none() + && parsed.fragment().is_none(); + plain.then_some(fetched_url) +} + +/// Whether `Vary` names `*`, `Cookie` or `Authorization`, so the origin's +/// answer may differ between readers. +fn varies_by_reader(headers: &HeaderMap) -> bool { + headers + .get_all(header::VARY) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .any(|name| { + name == "*" + || name.eq_ignore_ascii_case("cookie") + || name.eq_ignore_ascii_case("authorization") + }) +} + +/// Whether the origin's own `Link` already names a C2PA manifest, so the +/// representation carries provenance core must keep as it came rather +/// than sign again or compete with. +fn carries_manifest_link(headers: &HeaderMap) -> bool { + headers + .get_all(header::LINK) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|parameter| { + let (name, relations) = parameter.trim().split_once('=')?; + name.trim().eq_ignore_ascii_case("rel").then_some(relations) + }) + .flat_map(|relations| { + relations.split(|character: char| { + character.is_ascii_whitespace() || character == '"' || character == ',' + }) + }) + .any(|relation| relation.eq_ignore_ascii_case("c2pa-manifest")) +} + +/// The JUMBF description box of a C2PA manifest store: the `jumd` box type +/// followed by the C2PA manifest store UUID, +/// `63327061-0011-0010-8000-00AA00389B71`. Every embedding of a store, a +/// JPEG `APP11` segment, a PNG `caBX` chunk, a WebP `C2PA` chunk, an ISO +/// BMFF `uuid` box or a JPEG XL `jumb` box, carries these bytes +/// contiguously at the head of the store. +const C2PA_STORE_DESCRIPTION: [u8; 20] = [ + b'j', b'u', b'm', b'd', 0x63, 0x32, 0x70, 0x61, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, + 0x00, 0x38, 0x9B, 0x71, +]; + +/// Whether `bytes` carry a C2PA manifest store. This is presence only: +/// whether the store is well placed and intact is the signer's to check, +/// see [`MediaJob::optimizer_output`]. +fn carries_c2pa_manifest_store(bytes: &[u8]) -> bool { + bytes + .windows(C2PA_STORE_DESCRIPTION.len()) + .any(|window| window == C2PA_STORE_DESCRIPTION) +} + +/// The most boxes [`bmff_boxes`] reads at one level before it gives up. +const MAX_BMFF_BOXES: usize = 4096; + +/// The type and body range of each ISO BMFF box laid end to end in +/// `bytes`, or `None` where they are not well formed, or are more than +/// [`MAX_BMFF_BOXES`]. A size of `1` reads a 64-bit size after the type, +/// and a size of `0` runs the box to the end of `bytes`. +fn bmff_boxes(bytes: &[u8]) -> Option)>> { + let mut boxes = Vec::new(); + let mut offset = 0_usize; + while offset < bytes.len() { + if boxes.len() == MAX_BMFF_BOXES { + return None; + } + let header = bytes.get(offset..offset.checked_add(8)?)?; + let size32 = u32::from_be_bytes(header.get(0..4)?.try_into().ok()?); + let kind: [u8; 4] = header.get(4..8)?.try_into().ok()?; + let (header_len, size) = match size32 { + 0 => (8, bytes.len() - offset), + 1 => { + let large = bytes.get(offset + 8..offset.checked_add(16)?)?; + let size = u64::from_be_bytes(large.try_into().ok()?); + (16, usize::try_from(size).ok()?) + } + size => (8, usize::try_from(size).ok()?), + }; + if size < header_len { + return None; + } + let end = offset.checked_add(size)?; + if end > bytes.len() { + return None; + } + boxes.push((kind, offset + header_len..end)); + offset = end; + } + Some(boxes) +} + +/// Whether `bytes` are a part of a fragmented ISO BMFF stream, which is +/// never signed alone: its provenance, such as C2PA verifiable segment +/// info, binds the stream's init segment and media segments together, so +/// a manifest added to one part would compete with the stream's. +/// +/// A media segment starts with `styp`, or has a `moof` before its first +/// `mdat`, and has no `moov`. An init segment starts with `ftyp`, has a +/// `moov` that holds an `mvex`, and has no `mdat`. A progressive file, +/// whose `moov` holds no `mvex` or which carries an `mdat` beside its +/// `moov`, is not a part, and neither is anything not well formed. +fn is_fragmented_stream_part(bytes: &[u8]) -> bool { + let Some(boxes) = bmff_boxes(bytes) else { + return false; + }; + let Some((first, _)) = boxes.first() else { + return false; + }; + let moov = boxes.iter().find(|(kind, _)| kind == b"moov"); + let first_mdat = boxes.iter().position(|(kind, _)| kind == b"mdat"); + let moof_before_mdat = boxes + .iter() + .take(first_mdat.unwrap_or(boxes.len())) + .any(|(kind, _)| kind == b"moof"); + let media_segment = (first == b"styp" || moof_before_mdat) && moov.is_none(); + let init_segment = first == b"ftyp" + && first_mdat.is_none() + && moov.is_some_and(|(_, body)| { + bytes + .get(body.clone()) + .and_then(bmff_boxes) + .is_some_and(|children| children.iter().any(|(kind, _)| kind == b"mvex")) + }); + media_segment || init_segment +} + +/// Whether a `Cache-Control` line names `directive`. +fn cache_control_has(headers: &HeaderMap, directive: &str) -> bool { + headers + .get_all(header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(|part| part.split('=').next().unwrap_or_default().trim()) + .any(|name| name.eq_ignore_ascii_case(directive)) +} + +fn header_text<'a>(headers: &'a HeaderMap, name: &HeaderName) -> &'a str { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() +} + +/// The origin's `Content-Length`, where it sent one that parses. +fn declared_length(headers: &HeaderMap) -> Option { + digits(header_text(headers, &header::CONTENT_LENGTH).trim()) +} + +/// The complete length a `206`'s `Content-Range: bytes a-b/total` names. +fn content_range_total(headers: &HeaderMap) -> Option { + let value = header_text(headers, &header::CONTENT_RANGE).trim(); + let (unit, range) = value.split_once(' ')?; + if !unit.eq_ignore_ascii_case("bytes") { + return None; + } + digits(range.rsplit_once('/')?.1.trim()) +} + +fn to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +/// A decimal number of ASCII digits alone, which `str::parse` would also +/// take with a leading `+`. +fn digits(value: &str) -> Option { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + value.parse().ok() +} + +/// A body read up to a limit. +enum Captured { + /// The whole body, within the limit. + Whole(Bytes), + /// A body over the limit, or one whose stream failed, rebuilt from what + /// was read and what was not, so the reader receives what it would have. + Partial(EdgeBody), +} + +/// Reads `body` whole when it is no larger than `limit`. +async fn capture(body: EdgeBody, limit: usize) -> Captured { + match body { + EdgeBody::Once(bytes) if bytes.len() <= limit => Captured::Whole(bytes), + once @ EdgeBody::Once(_) => Captured::Partial(once), + EdgeBody::Stream(mut stream) => { + let mut chunks = Vec::new(); + let mut total = 0_usize; + while let Some(item) = stream.next().await { + match item { + Ok(chunk) => { + total = total.saturating_add(chunk.len()); + chunks.push(chunk); + if total > limit { + let read = futures::stream::iter(chunks.into_iter().map(Ok)); + return Captured::Partial(EdgeBody::Stream( + read.chain(stream).boxed_local(), + )); + } + } + Err(error) => { + let read = chunks + .into_iter() + .map(Ok) + .chain(std::iter::once(Err(error))); + return Captured::Partial(EdgeBody::Stream( + futures::stream::iter(read).boxed_local(), + )); + } + } + } + let mut whole = BytesMut::with_capacity(total); + for chunk in chunks { + whole.extend_from_slice(&chunk); + } + Captured::Whole(whole.freeze()) + } + } +} + +/// How a request is answered from a signed representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Outcome { + /// `200` with the whole representation. + Full, + /// `304`. + NotModified, + /// `412`. + PreconditionFailed, + /// `206` with bytes `first` to `last`, inclusive. + Partial { first: u64, last: u64 }, + /// `416`. + Unsatisfiable, +} + +/// Evaluates the reader's preconditions and range against a representation +/// in the order RFC 9110 section 13.2.2 gives. `etag` and `last_modified` +/// are the representation's own validators. A signed representation has no +/// modification date, so it passes `None` and `If-Modified-Since` and +/// `If-Unmodified-Since` are ignored; an `If-Range` date never holds. A +/// `Range` is honored on a `GET` alone, for one range. Several ranges are +/// answered with the whole representation, which the RFC allows. +fn evaluate( + method: &Method, + preconditions: &ReaderPreconditions, + etag: Option<&str>, + last_modified: Option, + length: u64, +) -> Outcome { + let current = etag.and_then(single_entity_tag); + let date_of = + |value: Option<&str>| value.and_then(|value| httpdate::parse_http_date(value.trim()).ok()); + + if let Some(value) = preconditions.if_match.as_deref() { + let matched = match TagList::parse(value) { + TagList::Any => true, + TagList::Tags(tags) => current + .as_ref() + .is_some_and(|current| tags.iter().any(|tag| tag.strong_eq(current))), + }; + if !matched { + return Outcome::PreconditionFailed; + } + } else if let (Some(since), Some(modified)) = ( + date_of(preconditions.if_unmodified_since.as_deref()), + last_modified, + ) && modified > since + { + return Outcome::PreconditionFailed; + } + + if let Some(value) = preconditions.if_none_match.as_deref() { + let matched = match TagList::parse(value) { + TagList::Any => true, + TagList::Tags(tags) => current + .as_ref() + .is_some_and(|current| tags.iter().any(|tag| tag.opaque == current.opaque)), + }; + if matched { + return if *method == Method::GET || *method == Method::HEAD { + Outcome::NotModified + } else { + Outcome::PreconditionFailed + }; + } + } else if (*method == Method::GET || *method == Method::HEAD) + && let (Some(since), Some(modified)) = ( + date_of(preconditions.if_modified_since.as_deref()), + last_modified, + ) + && modified <= since + { + return Outcome::NotModified; + } + + if *method != Method::GET { + return Outcome::Full; + } + let Some(range) = preconditions.range.as_deref() else { + return Outcome::Full; + }; + if let Some(if_range) = preconditions.if_range.as_deref() { + let holds = single_entity_tag(if_range) + .zip(current.as_ref()) + .is_some_and(|(tag, current)| tag.strong_eq(current)); + if !holds { + return Outcome::Full; + } + } + match parse_single_range(range, length) { + RangeRequest::Ignore => Outcome::Full, + RangeRequest::Satisfiable { first, last } => Outcome::Partial { first, last }, + RangeRequest::Unsatisfiable => Outcome::Unsatisfiable, + } +} + +/// One entity tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EntityTag<'a> { + weak: bool, + opaque: &'a str, +} + +impl EntityTag<'_> { + fn strong_eq(&self, other: &EntityTag<'_>) -> bool { + !self.weak && !other.weak && self.opaque == other.opaque + } +} + +/// The entity tag at the start of `input` and what follows it. +fn split_entity_tag(input: &str) -> Option<(EntityTag<'_>, &str)> { + let (weak, quoted) = match input.strip_prefix("W/") { + Some(rest) => (true, rest), + None => (false, input), + }; + let body = quoted.strip_prefix('"')?; + let end = body.find('"')?; + Some(( + EntityTag { + weak, + opaque: &body[..end], + }, + &body[end + 1..], + )) +} + +/// A header holding exactly one entity tag, such as `ETag`. +fn single_entity_tag(value: &str) -> Option> { + let (tag, rest) = split_entity_tag(value.trim())?; + rest.trim().is_empty().then_some(tag) +} + +/// An `If-Match` or `If-None-Match` value. +enum TagList<'a> { + Any, + Tags(Vec>), +} + +impl<'a> TagList<'a> { + /// The tags of a list, as far as it parses. A tag may hold a comma, so + /// the list is read tag by tag. + fn parse(value: &'a str) -> Self { + if value.trim() == "*" { + return Self::Any; + } + let mut tags = Vec::new(); + let mut rest = value; + loop { + rest = rest.trim_start_matches([',', ' ', '\t']); + if rest.is_empty() { + break; + } + let Some((tag, after)) = split_entity_tag(rest) else { + break; + }; + tags.push(tag); + rest = after; + } + Self::Tags(tags) + } +} + +/// What a `Range` header asks of a representation of known length. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RangeRequest { + /// Not one byte range this answers, so the whole representation goes. + Ignore, + Satisfiable { + first: u64, + last: u64, + }, + Unsatisfiable, +} + +fn parse_single_range(value: &str, length: u64) -> RangeRequest { + let Some((unit, set)) = value.trim().split_once('=') else { + return RangeRequest::Ignore; + }; + if !unit.trim().eq_ignore_ascii_case("bytes") { + return RangeRequest::Ignore; + } + let mut specs = set + .split(',') + .map(str::trim) + .filter(|spec| !spec.is_empty()); + let (Some(spec), None) = (specs.next(), specs.next()) else { + return RangeRequest::Ignore; + }; + let Some((first, last)) = spec.split_once('-') else { + return RangeRequest::Ignore; + }; + let (first, last) = (first.trim(), last.trim()); + if first.is_empty() { + let Some(suffix) = digits(last) else { + return RangeRequest::Ignore; + }; + if suffix == 0 || length == 0 { + return RangeRequest::Unsatisfiable; + } + return RangeRequest::Satisfiable { + first: length.saturating_sub(suffix), + last: length - 1, + }; + } + let Some(first) = digits(first) else { + return RangeRequest::Ignore; + }; + let last = if last.is_empty() { + None + } else { + match digits(last) { + Some(last) if last >= first => Some(last), + _ => return RangeRequest::Ignore, + } + }; + if first >= length { + return RangeRequest::Unsatisfiable; + } + RangeRequest::Satisfiable { + first, + last: last.map_or(length - 1, |last| last.min(length - 1)), + } +} + +#[cfg(test)] +pub(crate) mod test_support; + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-core/src/media_signing/large.rs b/crates/trusted-server-core/src/media_signing/large.rs new file mode 100644 index 0000000000..07e2ae355e --- /dev/null +++ b/crates/trusted-server-core/src/media_signing/large.rs @@ -0,0 +1,1582 @@ +//! The large-media lane: a representation larger than a signer's bounded +//! limit, signed without core ever holding it whole. +//! +//! The lane signs an unchanged source only. The bytes the reader receives +//! are the origin's own, so the origin's representation keeps its own +//! validators, length and range handling, and the signer only adds headers, +//! such as a manifest `Link`. Core adds a `Repr-Digest` of the whole source. +//! +//! Core admits an origin answer to the lane only where it can pin one +//! version of the source and keep the outcome: a shared request, an +//! identity-coded answer the origin keeps shareable for a while and lets +//! be transformed, a cache that keeps values, a single strong `ETag`, a +//! known total length above the signer's bounded limit and within its +//! large limit, a platform that streams response bodies and offers a +//! timer, and no C2PA manifest `Link` of the origin's own. Before anything +//! is hashed, core also requires a public address to name the source by: +//! the fetched address where it is plain `https` and core does not sign +//! the request for it, or else the reader's query-free `https` address. +//! An answer that fails any of these goes to the reader as the origin sent +//! it, validators and all; an answer with no strong `ETag` is never given +//! an invented version. Only a reader's `GET` of an answer whose origin +//! says `Accept-Ranges: bytes` starts the hashing of a version; a `HEAD`, +//! or a `GET` without that, is answered from a kept outcome or goes as it +//! came. +//! +//! Every later read of a version being signed, by core or the signer, asks +//! the origin through the route's own backend and origin signing for +//! exactly one byte range with `If-Match` on the pinned tag, and refuses an +//! answer whose status, `Content-Range`, `Content-Length`, coding or `ETag` +//! does not describe exactly that range of exactly that version. A changed +//! version therefore fails the read, never mixes into a digest or a signed +//! delivery. A version the lane declined has no signature to protect, so +//! where the origin's first answer can no longer be sent its reader gets +//! one fresh origin answer to the request as core first sent it, held by +//! no cache. +//! +//! What core keeps for the lane is the outcome only, never the bytes: for +//! a signed version the whole-source digest, the length and the signer's +//! headers, for a declined one (refused, failed or out of time) that it was +//! declined, briefly. Both are kept under the publisher's prefix, the +//! signer's current [`namespace`](super::MediaSigner::namespace), the +//! reader's address, the origin and the pinned tag, for no longer than the +//! origin's shared freshness. A changed or revoked policy names another +//! namespace, so it never finds an outcome kept under the old one. The +//! hashing and signing run as one load of the loading cache, which runs a +//! key's load once for every caller asking for it at the same time. + +use std::fmt; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; +use bytes::{Bytes, BytesMut}; +use edgezero_core::body::Body as EdgeBody; +use futures::StreamExt as _; +use futures::future::Either; +use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, header}; +use sha2::{Digest as _, Sha256}; +use web_time::Instant; + +use super::{ + Delivery, KeptReader, MediaClaim, MediaSigner, MediaSigningFailure, OriginFacts, Outcome, + VariantIdentity, carries_manifest_link, digits, evaluate, header_text, is_reserved_header, + single_entity_tag, source_reference, strip_representation_preconditions, to_u64, +}; +use crate::middleware::ResponseHeader; +use crate::origin_auth::OriginSigning; +use crate::platform::{ + LoadError, LoadedValue, Loader, PlatformHttpRequest, RuntimeServices, StoreKey, +}; +use crate::settings::ASSETS_CACHE; + +/// What [`MediaSigner::sign_large`](super::MediaSigner::sign_large) returns. +pub type LargeMediaSigning<'a> = + Pin> + 'a>>; + +/// One large, unchanged representation a signer is asked to sign. +#[derive(Debug, Clone, Copy)] +pub struct LargeMediaJob<'a> { + /// The address the reader asked for, `scheme://host/path`, without its + /// query. + pub reader_url: &'a str, + /// The origin's `Content-Type`, as it sent it. + pub content_type: &'a str, + /// The pinned version of the source, read at offsets. + pub source: &'a VersionedMediaSource<'a>, + /// SHA-256 of every byte of the pinned version, lower case hex, which + /// core computed in bounded chunks and checked against its length. The + /// bytes the reader receives are these bytes. + pub source_sha256: &'a str, + /// The address core fetched the source from, exactly as fetched, where + /// it is `https` and carries no user information, query or fragment. + pub source_reference: Option<&'a str>, + /// The reader's address, where it is `https` and the reader's request + /// carried no query. It names the source only because this lane + /// delivers the source unchanged. + pub reader_reference: Option<&'a str>, + /// The lower case host of the address core fetched the source from, + /// with no scheme, port, path, query or user information. + pub source_origin_host: &'a str, + /// What is left of the signer's + /// [`max_large_media_duration`](super::MediaSigner::max_large_media_duration) + /// after core's hashing. Core drops the signing when it is spent. + pub remaining: Duration, +} + +/// What a signer returns for a large representation: headers only, since +/// the bytes are the origin's. +#[derive(Debug, Clone)] +pub struct LargeSignedMedia { + /// Headers written on every answer of the signed version: `200`, `206`, + /// `HEAD` and `304`. A `Link` is added to the origin's own links, and + /// any other replaces the origin's header of that name. A name core + /// owns, see [`is_reserved_header`], fails the signing. + pub headers: Vec, + /// The policy and credential context the source was signed under, as + /// [`MediaSigner::namespace`](super::MediaSigner::namespace) gives it + /// while that context is current. Core keeps the metadata under it. + pub namespace: String, +} + +/// One version of a source: its strong entity tag and its length. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceVersion { + /// The strong tag, quoted, as `"opaque"`. + etag: String, + length: u64, +} + +impl SourceVersion { + /// The version a single strong entity tag `etag` and a length name, or + /// `None` where `etag` is weak, empty or not one tag. + #[must_use] + pub fn new(etag: &str, length: u64) -> Option { + let tag = single_entity_tag(etag)?; + (!tag.weak && !tag.opaque.is_empty()).then(|| Self { + etag: format!("\"{}\"", tag.opaque), + length, + }) + } + + /// The version an origin answer's headers name, where they carry + /// exactly one strong `ETag`. + fn of(headers: &HeaderMap, length: u64) -> Option { + let mut tags = headers.get_all(header::ETAG).iter(); + let (Some(tag), None) = (tags.next(), tags.next()) else { + return None; + }; + Self::new(tag.to_str().ok()?, length) + } + + /// The strong entity tag, quoted. + #[must_use] + pub fn etag(&self) -> &str { + &self.etag + } + + /// The length in bytes. + #[must_use] + pub fn length(&self) -> u64 { + self.length + } + + /// Whether an answer's headers carry this version's tag, strongly. + fn matches(&self, headers: &HeaderMap) -> bool { + let mut tags = headers.get_all(header::ETAG).iter(); + let (Some(tag), None) = (tags.next(), tags.next()) else { + return false; + }; + tag.to_str() + .ok() + .and_then(single_entity_tag) + .is_some_and(|tag| { + !tag.weak && self.etag.get(1..self.etag.len() - 1) == Some(tag.opaque) + }) + } +} + +/// Why a read of a [`VersionedMediaSource`] failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub enum MediaSourceError { + /// The origin no longer serves the pinned version: it answered `412` to + /// `If-Match`, or with a missing, weak or different `ETag`. Nothing + /// read from this source may be published. + #[display("the origin no longer serves the pinned version")] + VersionChanged, + /// The read reaches past the pinned version's length. + #[display("the read reaches past the pinned version")] + OutOfBounds, + /// The origin answered a pinned read with something other than exactly + /// the bytes asked for. + #[display("the origin answered a pinned read wrongly: {reason}")] + InvalidAnswer { + /// A short code naming what was wrong. + reason: &'static str, + }, + /// The read could not be sent, or its body failed or ended early. + #[display("a pinned read could not be completed")] + Transport, +} + +impl core::error::Error for MediaSourceError {} + +impl MediaSourceError { + /// A short code naming the failure, for logs and refusals. + #[must_use] + pub fn code(&self) -> &'static str { + match self { + Self::VersionChanged => "source_version_changed", + Self::OutOfBounds => "source_read_out_of_bounds", + Self::InvalidAnswer { reason } => reason, + Self::Transport => "source_transport", + } + } +} + +/// How core asks the origin for one more byte range of a source: the +/// address, the request's headers as they stood before it was signed, the +/// backend and the origin's signing. +#[derive(Clone, Copy)] +pub(crate) struct PinnedOrigin<'a> { + /// The address, exactly as fetched and signed. + pub(crate) url: &'a url::Url, + /// The origin request's headers before signing. + pub(crate) headers: &'a HeaderMap, + /// The backend the origin request went to. + pub(crate) backend_name: &'a str, + /// The origin's signing, applied fresh to every read. + pub(crate) auth: Option<&'a OriginSigning>, + /// Headers a signed read must not carry, removed before core writes its + /// own conditions and signs. + pub(crate) unsigned_headers: &'a [HeaderName], + /// Publisher URL mapping for a fresh unsigned response; asset proxies keep + /// the origin's navigation targets as on their ordinary response path. + pub(crate) response_header_rewrite: + Option>, +} + +/// One pinned version of a source, read at offsets from the origin. +/// +/// Every read asks the origin for exactly one byte range with `If-Match` +/// on the pinned tag, through the route's own backend and origin signing, +/// and fails unless the answer is exactly that range of that version. +pub struct VersionedMediaSource<'a> { + services: &'a RuntimeServices, + origin: PinnedOrigin<'a>, + version: SourceVersion, +} + +impl fmt::Debug for VersionedMediaSource<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VersionedMediaSource") + .field("version", &self.version) + .finish_non_exhaustive() + } +} + +impl<'a> VersionedMediaSource<'a> { + /// A source read from `url` through `backend_name` with `headers`, an + /// origin that signs no request, pinned to `version`. + #[must_use] + pub fn new( + services: &'a RuntimeServices, + url: &'a url::Url, + headers: &'a HeaderMap, + backend_name: &'a str, + version: SourceVersion, + ) -> Self { + Self { + services, + origin: PinnedOrigin { + url, + headers, + backend_name, + auth: None, + unsigned_headers: &[], + response_header_rewrite: None, + }, + version, + } + } + + /// The version every read is pinned to. + #[must_use] + pub fn version(&self) -> &SourceVersion { + &self.version + } + + /// The pinned version's length in bytes. + #[must_use] + pub fn size(&self) -> u64 { + self.version.length + } + + /// Fills `destination` with the bytes at `offset` of the pinned version. + /// + /// # Errors + /// + /// [`MediaSourceError::OutOfBounds`] where the read reaches past the + /// version, [`MediaSourceError::VersionChanged`] where the origin no + /// longer serves it, and the other variants where the origin's answer + /// is not exactly the range asked for or does not arrive whole. + /// + /// # Performance + /// + /// One origin request per call, streamed into `destination`. + pub async fn read_exact_at( + &self, + offset: u64, + destination: &mut [u8], + ) -> Result<(), MediaSourceError> { + let length = to_u64(destination.len()); + let mut filled = 0_usize; + let mut visit = |chunk: &[u8]| { + let end = filled.saturating_add(chunk.len()); + if let Some(slot) = destination.get_mut(filled..end) { + slot.copy_from_slice(chunk); + } + filled = end; + }; + self.stream_range(offset, length, &mut visit).await + } + + /// Hands `visit` the bytes from `offset` for `length` bytes of the + /// pinned version, in order, in the chunks the origin sends. + /// + /// # Errors + /// + /// As [`read_exact_at`](Self::read_exact_at). `visit` may have seen + /// some of the bytes before a read fails. + /// + /// # Performance + /// + /// One origin request; nothing is held beyond one chunk. + pub async fn for_each_chunk( + &self, + offset: u64, + length: u64, + mut visit: F, + ) -> Result<(), MediaSourceError> { + self.stream_range(offset, length, &mut visit).await + } + + /// Whether the origin still serves the pinned version, by a pinned + /// read of its first byte. A signer asks this last before it publishes. + /// + /// # Errors + /// + /// As [`read_exact_at`](Self::read_exact_at). + pub async fn confirm_current(&self) -> Result<(), MediaSourceError> { + let mut first = [0_u8; 1]; + self.read_exact_at(0, &mut first).await + } + + /// The same source read through `origin`, which may sign its reads. + pub(crate) fn pinned( + services: &'a RuntimeServices, + origin: PinnedOrigin<'a>, + version: SourceVersion, + ) -> Self { + Self { + services, + origin, + version, + } + } + + /// Streams `length` bytes from `offset` to `visit`. + async fn stream_range( + &self, + offset: u64, + length: u64, + visit: &mut F, + ) -> Result<(), MediaSourceError> { + if length == 0 { + return Ok(()); + } + let last = offset + .checked_add(length - 1) + .filter(|last| *last < self.version.length) + .ok_or(MediaSourceError::OutOfBounds)?; + let body = self.send_pinned(offset, last).await?; + drain(body, length, visit).await + } + + /// Asks the origin for bytes `first` to `last` of the pinned version + /// and returns the body of an answer that is exactly that. + async fn send_pinned(&self, first: u64, last: u64) -> Result { + let request = self.pinned_request(first, last)?; + // The platform's error may name the address, which can carry a + // credential, so it is not logged. + let answer = self + .services + .http_client() + .send(request) + .await + .map_err(|_| MediaSourceError::Transport)?; + let (parts, body) = answer.response.into_parts(); + check_pinned(&self.version, parts.status, &parts.headers, first, last)?; + Ok(body) + } + + /// The origin request for bytes `first` to `last`: the request as it + /// stood before signing, with every condition the reader sent removed, + /// and only core's own `If-Match`, `Range` and identity coding written + /// before it is signed afresh. + fn pinned_request( + &self, + first: u64, + last: u64, + ) -> Result { + let mut headers = self.origin.base_headers(); + strip_representation_preconditions(&mut headers); + headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + headers.insert( + header::IF_MATCH, + HeaderValue::from_str(&self.version.etag).map_err(|_| { + MediaSourceError::InvalidAnswer { + reason: "etag_not_a_header", + } + })?, + ); + headers.insert( + header::RANGE, + HeaderValue::from_str(&format!("bytes={first}-{last}")) + .expect("should build a byte range"), + ); + let request = self + .origin + .signed_request(&Method::GET, headers) + .map_err(|()| MediaSourceError::Transport)?; + Ok( + if self.services.http_client().supports_streaming_responses() { + request.with_stream_response() + } else { + request.with_max_response_body_bytes( + usize::try_from(last - first + 1).unwrap_or(usize::MAX), + ) + }, + ) + } +} + +impl PinnedOrigin<'_> { + /// The origin request's headers as they stood before signing, less + /// the headers a signed request must not carry where core signs it. + fn base_headers(&self) -> HeaderMap { + let mut headers = self.headers.clone(); + if self.auth.is_some() { + for name in self.unsigned_headers { + headers.remove(name); + } + } + headers + } + + /// A `method` request to the origin carrying `headers`, signed afresh + /// where the origin signs requests. Nothing is written after the + /// signature. + fn signed_request( + &self, + method: &Method, + mut headers: HeaderMap, + ) -> Result { + if let Some(auth) = self.auth { + auth.remove_namespace_headers(&mut headers); + auth.sign(method, self.url, &mut headers).map_err(|_| ())?; + } + let mut request = Request::builder() + .method(method.clone()) + .uri(self.url.as_str()) + .body(EdgeBody::empty()) + .map_err(|_| ())?; + *request.headers_mut() = headers; + Ok(PlatformHttpRequest::new(request, self.backend_name)) + } +} + +/// Checks that an answer to a pinned read of bytes `first` to `last` is +/// exactly that range of `version`. +fn check_pinned( + version: &SourceVersion, + status: StatusCode, + headers: &HeaderMap, + first: u64, + last: u64, +) -> Result<(), MediaSourceError> { + if status == StatusCode::PRECONDITION_FAILED { + return Err(MediaSourceError::VersionChanged); + } + if status != StatusCode::OK && status != StatusCode::PARTIAL_CONTENT { + return Err(MediaSourceError::InvalidAnswer { + reason: "pinned_read_status", + }); + } + if !version.matches(headers) { + return Err(MediaSourceError::VersionChanged); + } + let encoding = header_text(headers, &header::CONTENT_ENCODING).trim(); + if !encoding.is_empty() && !encoding.eq_ignore_ascii_case("identity") { + return Err(MediaSourceError::InvalidAnswer { + reason: "pinned_read_content_encoded", + }); + } + let whole = first == 0 && last.checked_add(1) == Some(version.length); + if status == StatusCode::PARTIAL_CONTENT { + if content_range(headers) != Some((first, last, version.length)) { + return Err(MediaSourceError::InvalidAnswer { + reason: "pinned_read_content_range", + }); + } + } else if !whole { + return Err(MediaSourceError::InvalidAnswer { + reason: "pinned_read_status", + }); + } + let declared = header_text(headers, &header::CONTENT_LENGTH).trim(); + if !declared.is_empty() && digits(declared) != Some(last - first + 1) { + return Err(MediaSourceError::InvalidAnswer { + reason: "pinned_read_content_length", + }); + } + Ok(()) +} + +/// The first byte, last byte and complete length a `206`'s +/// `Content-Range: bytes first-last/length` names. +fn content_range(headers: &HeaderMap) -> Option<(u64, u64, u64)> { + let value = header_text(headers, &header::CONTENT_RANGE).trim(); + let (unit, range) = value.split_once(' ')?; + if !unit.eq_ignore_ascii_case("bytes") { + return None; + } + let (span, total) = range.trim().split_once('/')?; + let (first, last) = span.split_once('-')?; + Some(( + digits(first.trim())?, + digits(last.trim())?, + digits(total.trim())?, + )) +} + +/// Hands `visit` every chunk of `body`, which must hold exactly `expected` +/// bytes. A chunk that would pass `expected` is refused before `visit` +/// sees it. A stream that fails or ends early is a +/// [`MediaSourceError::Transport`]. +async fn drain( + body: EdgeBody, + expected: u64, + visit: &mut F, +) -> Result<(), MediaSourceError> { + match body { + EdgeBody::Once(bytes) => { + if to_u64(bytes.len()) != expected { + return Err(MediaSourceError::InvalidAnswer { + reason: "pinned_read_body_length", + }); + } + visit(&bytes); + Ok(()) + } + EdgeBody::Stream(mut stream) => { + let mut seen = 0_u64; + while let Some(item) = stream.next().await { + let Ok(chunk) = item else { + return Err(MediaSourceError::Transport); + }; + let next = seen.saturating_add(to_u64(chunk.len())); + if next > expected { + return Err(MediaSourceError::InvalidAnswer { + reason: "pinned_read_overlong_body", + }); + } + visit(&chunk); + seen = next; + } + if seen == expected { + Ok(()) + } else { + Err(MediaSourceError::Transport) + } + } + } +} + +/// SHA-256 of every byte of `source`'s pinned version, read once by one +/// pinned read in bounded chunks. A read that fails fails the digest; it is +/// never restarted or resumed. +async fn whole_digest(source: &VersionedMediaSource<'_>) -> Result<[u8; 32], MediaSourceError> { + let mut hasher = Sha256::new(); + let mut visit = |chunk: &[u8]| hasher.update(chunk); + source.stream_range(0, source.size(), &mut visit).await?; + let mut digest = [0_u8; 32]; + digest.copy_from_slice(&hasher.finalize()); + Ok(digest) +} + +/// `body` as sent to the reader, refused where it holds more or fewer than +/// `expected` bytes, so a reader never receives bytes of another length +/// under headers that describe this one. +fn exact_body(body: EdgeBody, expected: u64) -> Result { + match body { + EdgeBody::Once(bytes) if to_u64(bytes.len()) == expected => Ok(EdgeBody::Once(bytes)), + EdgeBody::Once(_) => Err(MediaSourceError::InvalidAnswer { + reason: "pinned_read_body_length", + }), + EdgeBody::Stream(stream) => Ok(EdgeBody::from_stream(futures::stream::unfold( + (stream, 0_u64, false), + move |(mut stream, sent, finished)| async move { + if finished { + return None; + } + match stream.next().await { + Some(Ok(chunk)) => { + let sent = sent.saturating_add(to_u64(chunk.len())); + if sent > expected { + let error = MediaSourceError::InvalidAnswer { + reason: "pinned_read_overlong_body", + }; + Some((Err(error), (stream, sent, true))) + } else { + Some((Ok(chunk), (stream, sent, false))) + } + } + Some(Err(_)) => Some((Err(MediaSourceError::Transport), (stream, sent, true))), + None if sent == expected => None, + None => Some((Err(MediaSourceError::Transport), (stream, sent, true))), + } + }, + ))), + } +} + +/// The first bytes of kept metadata for a signed version, naming its +/// layout. +const SIGNED_MAGIC: &[u8; 4] = b"TSL1"; + +/// The whole of the kept record for a version the lane declined: refused +/// by the signer, failed to read or hash, or out of time. +const DECLINED_MAGIC: &[u8; 4] = b"TSL0"; + +/// The longest metadata for a signed version is kept, whatever the origin +/// allows. +const SIGNED_MAX_LIFETIME: Duration = Duration::from_secs(3600); + +/// The longest a declined version is kept, so a source, policy or signer +/// that changes is tried again soon, while a burst of requests for one +/// version never hashes it once per request. +const DECLINED_MAX_LIFETIME: Duration = Duration::from_secs(60); + +/// How long a lookup may take before the origin's own answer, held unread +/// meanwhile, is no longer trusted to stream, and the bytes are read again +/// pinned. A lookup that waits on another caller's load can take as long as +/// that load. +const ORIGIN_BODY_MAX_WAIT: Duration = Duration::from_secs(1); + +/// The most signer headers kept metadata may hold. +const LARGE_MAX_HEADERS: usize = 64; + +/// What core keeps for a signed large version: never its bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +struct LargeRecord { + length: u64, + sha256: [u8; 32], + headers: Vec, +} + +/// What core keeps for one large version under one namespace. +#[derive(Debug, Clone, PartialEq, Eq)] +enum KeptOutcome { + /// The version was signed; its answers carry these headers. + Signed(LargeRecord), + /// The version was not signed; its answers go as the origin made them. + Declined, +} + +/// Metadata for a signed version as written: [`SIGNED_MAGIC`], the length, +/// the digest, the header count, then each header's name and value with +/// their lengths. +fn encode_large(record: &LargeRecord) -> Bytes { + let mut out = BytesMut::with_capacity(128); + out.extend_from_slice(SIGNED_MAGIC); + out.extend_from_slice(&record.length.to_be_bytes()); + out.extend_from_slice(&record.sha256); + let count = record.headers.len().min(LARGE_MAX_HEADERS); + out.extend_from_slice(&u32::try_from(count).unwrap_or(u32::MAX).to_be_bytes()); + for (name, value) in record.headers.iter().take(count) { + for field in [name.as_str().as_bytes(), value.as_bytes()] { + out.extend_from_slice(&u32::try_from(field.len()).unwrap_or(u32::MAX).to_be_bytes()); + out.extend_from_slice(field); + } + } + out.freeze() +} + +/// A kept outcome as [`encode_large`] or [`DECLINED_MAGIC`] wrote it, or +/// `None` for any other bytes. +fn decode_kept_outcome(bytes: &[u8]) -> Option { + if bytes == DECLINED_MAGIC.as_slice() { + return Some(KeptOutcome::Declined); + } + let mut reader = KeptReader { + rest: bytes.strip_prefix(SIGNED_MAGIC.as_slice())?, + }; + let length = reader.number()?; + let sha256: [u8; 32] = reader.take(32)?.try_into().ok()?; + let count = reader.length()?; + if count > LARGE_MAX_HEADERS { + return None; + } + let mut headers = Vec::with_capacity(count); + for _ in 0..count { + let name_length = reader.length()?; + let name = HeaderName::from_bytes(reader.take(name_length)?).ok()?; + let value_length = reader.length()?; + let value = HeaderValue::from_bytes(reader.take(value_length)?).ok()?; + if is_reserved_header(&name) { + return None; + } + headers.push((name, value)); + } + reader + .rest + .is_empty() + .then_some(KeptOutcome::Signed(LargeRecord { + length, + sha256, + headers, + })) +} + +/// Writes a signed version's headers on an answer of it: the signer's, +/// with a `Link` added to the origin's, and the whole source's +/// `Repr-Digest`. +fn decorate(headers: &mut HeaderMap, record: &LargeRecord) { + for (name, value) in &record.headers { + if name == header::LINK { + headers.append(name.clone(), value.clone()); + } else { + headers.insert(name.clone(), value.clone()); + } + } + let digest = format!( + "sha-256=:{}:", + base64::engine::general_purpose::STANDARD.encode(record.sha256) + ); + headers.insert( + HeaderName::from_static("repr-digest"), + HeaderValue::from_str(&digest).expect("should build a base64 digest"), + ); +} + +/// The answer where a signed or meant-to-be-signed version can no longer +/// be read: nothing of another version is sent in its place. +fn refusal() -> Response { + Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header(header::CACHE_CONTROL, "no-store") + .header(header::RETRY_AFTER, "1") + .header(header::CONTENT_LENGTH, 0) + .body(EdgeBody::empty()) + .expect("should build a refusal") +} + +/// The headers a fresh origin answer for an unsigned version brings: its +/// status framing, type, coding, validators and redirect/authentication +/// semantics. Every other header stays as core prepared it for the reader. +const FRESH_REPRESENTATION_HEADERS: [&str; 16] = [ + "content-type", + "content-location", + "content-length", + "content-encoding", + "content-range", + "accept-ranges", + "etag", + "last-modified", + "repr-digest", + "content-digest", + "digest", + "content-md5", + "transfer-encoding", + "location", + "www-authenticate", + "retry-after", +]; + +/// Whether the origin says it serves byte ranges of this representation, +/// which every pinned read relies on. +fn advertises_byte_ranges(headers: &HeaderMap) -> bool { + headers + .get_all(header::ACCEPT_RANGES) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|unit| unit.trim().eq_ignore_ascii_case("bytes")) +} + +/// The answer where the origin could not be asked again for an unsigned +/// version. +fn origin_unreachable() -> Response { + Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header(header::CACHE_CONTROL, "no-store") + .header(header::CONTENT_LENGTH, 0) + .body(EdgeBody::empty()) + .expect("should build a gateway failure") +} + +/// An origin answer admitted to the large lane, with the version it pins. +pub(crate) struct LargeAnswer { + response: Response, + version: SourceVersion, + /// How long the origin let its answer be kept, shared, which bounds + /// how long its outcome is kept. + keep_for: Duration, +} + +impl LargeAnswer { + /// The origin's answer as it came, for a caller that cannot say how to + /// ask the origin again, sent unsigned. + pub(crate) fn into_origin_response(self) -> Response { + self.response + } +} + +/// The one wall-clock budget for a large source's namespace resolution, +/// cache waits, hashing and signing. +#[derive(Debug, Clone, Copy)] +struct LargeBudget { + started: Instant, + total: Duration, +} + +impl LargeBudget { + /// What is left of the budget now. + fn remaining(self) -> Duration { + self.total.saturating_sub(self.started.elapsed()) + } +} + +/// The public addresses a large source may be named by, worked out before +/// anything is hashed. +#[derive(Debug, Clone, PartialEq, Eq)] +struct LargeReferences { + /// The fetched address, where it is `https`, carries no user + /// information, query or fragment, and core does not sign the request + /// for it: an address core signs is a private one and is never named. + source: Option, + /// The reader's address, where it is `https` and the reader's request + /// carried no query. + reader: Option, +} + +impl LargeReferences { + fn of(identity: &VariantIdentity<'_>, origin_signed: bool) -> Self { + let source = if origin_signed { + None + } else { + source_reference(identity.fetched_url) + .filter(|reference| reference.starts_with("https://")) + .map(str::to_owned) + }; + let reader = (identity.reader_query_free && identity.reader_url.starts_with("https://")) + .then(|| identity.reader_url.to_owned()); + Self { source, reader } + } + + fn any(&self) -> bool { + self.source.is_some() || self.reader.is_some() + } +} + +/// Everything the hashing and signing of one large version owns, because +/// the loading cache keeps the load with its key rather than borrowing the +/// request, and runs it once for every caller asking for that key at the +/// same time. +struct LargeLoad { + signer: Arc, + /// The request's services without its connection, since the outcome + /// serves every caller waiting on the load. + services: RuntimeServices, + url: url::Url, + headers: HeaderMap, + backend_name: String, + auth: Option, + unsigned_headers: Vec, + version: SourceVersion, + reader_url: String, + content_type: String, + references: LargeReferences, + /// The namespace the outcome is kept under, which the signer must sign + /// under. + namespace: String, + budget: LargeBudget, + keep_for: Duration, +} + +impl LargeLoad { + fn into_loader(self) -> Loader { + Box::new(move || Box::pin(async move { Ok(self.run().await) })) + } + + /// Hashes and signs within the budget, giving the outcome to keep. A + /// declined outcome is kept briefly too, so the version is not hashed + /// again for every request while it stands. + async fn run(self) -> LoadedValue { + let signer_id = self.signer.signer_id(); + let remaining = self.budget.remaining(); + let outcome = match self.services.timer() { + Some(_) if remaining.is_zero() => { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=timeout" + ); + None + } + Some(timer) => { + match futures::future::select(Box::pin(self.sign()), timer.sleep(remaining)).await { + Either::Left((outcome, _)) => outcome, + Either::Right(((), _)) => { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=timeout" + ); + None + } + } + } + None => { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=no_timer" + ); + None + } + }; + match outcome { + Some(record) => LoadedValue::new(encode_large(&record)) + .with_lifetime(self.keep_for.min(SIGNED_MAX_LIFETIME)), + None => LoadedValue::new(Bytes::from_static(DECLINED_MAGIC)) + .with_lifetime(self.keep_for.min(DECLINED_MAX_LIFETIME)), + } + } + + /// Hashes the pinned version once and asks the signer for it. + async fn sign(&self) -> Option { + let signer_id = self.signer.signer_id(); + let declined = |reason: &str| { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason={reason}" + ); + }; + let origin = PinnedOrigin { + url: &self.url, + headers: &self.headers, + backend_name: &self.backend_name, + auth: self.auth.as_ref(), + unsigned_headers: &self.unsigned_headers, + response_header_rewrite: None, + }; + let source = VersionedMediaSource::pinned(&self.services, origin, self.version.clone()); + let digest = whole_digest(&source) + .await + .map_err(|error| declined(error.code())) + .ok()?; + let Some(origin_host) = self.url.host_str().map(str::to_ascii_lowercase) else { + declined("no_origin_host"); + return None; + }; + let remaining = self.budget.remaining(); + if remaining.is_zero() { + declined("timeout"); + return None; + } + let source_sha256 = hex::encode(digest); + let job = LargeMediaJob { + reader_url: &self.reader_url, + content_type: &self.content_type, + source: &source, + source_sha256: &source_sha256, + source_reference: self.references.source.as_deref(), + reader_reference: self.references.reader.as_deref(), + source_origin_host: &origin_host, + remaining, + }; + let signed = match self.signer.sign_large(&self.services, &job).await { + Ok(signed) + if signed + .headers + .iter() + .any(|(name, _)| is_reserved_header(name)) => + { + declined("reserved_header"); + return None; + } + Ok(signed) if signed.headers.len() > LARGE_MAX_HEADERS => { + declined("too_many_headers"); + return None; + } + Ok(signed) if signed.namespace != self.namespace => { + declined("namespace_changed"); + return None; + } + Ok(signed) => signed, + Err(failure) => { + declined(failure.reason); + return None; + } + }; + log::info!( + "media signing: outcome=signed lane=large signer={signer_id} source_bytes={}", + source.size() + ); + Some(LargeRecord { + length: source.size(), + sha256: digest, + headers: signed.headers, + }) + } +} + +impl MediaClaim { + /// Whether this claim may use the large lane at all: the signer signs + /// large sources and the request is shared. + fn large_lane_open(&self) -> bool { + self.shared && self.signer.max_large_source_bytes() > 0 + } + + /// The version a signable origin answer of `total` bytes, larger than + /// the bounded limit, pins in the large lane, or why it cannot. The + /// answer's coding, privacy, transform rule, type and any manifest + /// `Link` of the origin's own were already checked, see + /// `unsignable_reason`. Every check here is cheap and runs before + /// anything is hashed. + fn large_version( + &self, + services: &RuntimeServices, + headers: &HeaderMap, + total: Option, + facts: &OriginFacts, + ) -> Result { + let ceiling = self.signer.max_large_source_bytes(); + if ceiling == 0 { + return Err("exceeds_limit"); + } + if !self.shared { + return Err("large_request_not_shared"); + } + // An outcome that cannot be kept would be worked out again for + // every request, each range of one playback included. + if facts.keep_for.is_none() || !services.loading_cache_for(ASSETS_CACHE).keeps_values() { + return Err("large_outcome_not_kept"); + } + let Some(length) = total else { + return Err("unknown_length"); + }; + if length > ceiling { + return Err("exceeds_large_limit"); + } + if !services.http_client().supports_streaming_responses() { + return Err("no_streaming_transport"); + } + if self.signer.max_large_media_duration().is_zero() { + return Err("no_large_media_budget"); + } + if services.timer().is_none() { + return Err("no_timer"); + } + SourceVersion::of(headers, length).ok_or("no_strong_etag") + } + + /// What a signable origin answer of `total` bytes, larger than the + /// bounded limit, becomes: a [`Delivery::Large`] where the large lane + /// admits it, else the origin's answer as it came. + pub(super) fn large_or_unsigned( + &self, + services: &RuntimeServices, + response: Response, + total: Option, + facts: &OriginFacts, + ) -> Delivery { + match self.large_version(services, response.headers(), total, facts) { + Ok(version) => Delivery::Large(LargeAnswer { + response, + version, + keep_for: facts.keep_for.unwrap_or_default(), + }), + Err(reason) => { + log::info!( + "media signing: outcome=unsigned signer={} reason={reason}", + self.signer.signer_id() + ); + Delivery::Send(response) + } + } + } + + /// Answers the reader from an origin answer admitted to the large lane. + /// + /// A source no public address can name, or whose signer's namespace + /// cannot be resolved, goes as the origin answered it, before anything + /// is hashed. An outcome kept for the pinned version under the signer's + /// current namespace answers next: a signed one decorates the origin's + /// own answer, a declined one leaves it as it came. A `HEAD`, or an + /// answer whose origin does not say it serves byte ranges, never starts + /// a load and goes as it came. Otherwise the version is hashed and + /// signed once, through the loading cache, so callers asking at the + /// same time share that one load. A signed version is answered by + /// pinned reads, refused where the origin no longer serves it; a + /// declined one by one fresh origin answer to the request as core first + /// sent it, since no signature binds its bytes. `origin` is how the + /// origin is asked again. + pub(crate) async fn deliver_large( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + origin: PinnedOrigin<'_>, + answer: LargeAnswer, + ) -> Response { + let signer_id = self.signer.signer_id(); + let budget = LargeBudget { + started: Instant::now(), + total: self.signer.max_large_media_duration(), + }; + let LargeAnswer { + response, + version, + keep_for, + } = answer; + let references = LargeReferences::of(identity, origin.auth.is_some()); + if !references.any() { + log::info!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=no_public_reference" + ); + return response; + } + let Some(timer) = services.timer() else { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=no_timer" + ); + return response; + }; + let remaining = budget.remaining(); + if remaining.is_zero() { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=timeout" + ); + return response; + } + let namespace = match futures::future::select( + self.signer.namespace(services), + timer.sleep(remaining), + ) + .await + { + Either::Left((Ok(namespace), _)) => namespace, + Either::Left((Err(failure), _)) => { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason={failure}" + ); + return response; + } + Either::Right(((), _)) => { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=timeout" + ); + return response; + } + }; + let key = self.large_store_key(services, &namespace, identity, version.etag()); + let looked_up = Instant::now(); + let kept = match futures::future::select( + Box::pin(self.large_kept(services, &key, Some(version.length()))), + timer.sleep(budget.remaining()), + ) + .await + { + Either::Left((kept, _)) => kept, + Either::Right(((), _)) => { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=timeout" + ); + if looked_up.elapsed() <= ORIGIN_BODY_MAX_WAIT { + return response; + } + let (parts, _) = response.into_parts(); + return self.deliver_fresh(services, origin, parts).await; + } + }; + if let Some(kept) = kept { + if looked_up.elapsed() <= ORIGIN_BODY_MAX_WAIT { + log::debug!("media signing: lane=large signer={signer_id} from=kept_outcome"); + return match kept { + KeptOutcome::Signed(record) => { + let (mut parts, body) = response.into_parts(); + decorate(&mut parts.headers, &record); + Response::from_parts(parts, body) + } + KeptOutcome::Declined => response, + }; + } + // The origin's answer waited unread for the lookup, so it is no + // longer trusted to stream. + let (parts, _) = response.into_parts(); + return match kept { + KeptOutcome::Signed(record) => { + let source = VersionedMediaSource::pinned(services, origin, version); + self.deliver_pinned(&source, parts, &record).await + } + KeptOutcome::Declined => self.deliver_fresh(services, origin, parts).await, + }; + } + // Only a reader's own `GET` of a version the origin serves in byte + // ranges starts the work of hashing it. + if self.reader_method == Method::HEAD { + log::debug!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=head_starts_no_load" + ); + return response; + } + if !advertises_byte_ranges(response.headers()) { + log::info!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=origin_ranges_not_advertised" + ); + return response; + } + if budget.remaining().is_zero() { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=timeout" + ); + if looked_up.elapsed() <= ORIGIN_BODY_MAX_WAIT { + return response; + } + let (parts, _) = response.into_parts(); + return self.deliver_fresh(services, origin, parts).await; + } + // The origin's own body is not read: the load reads the version + // pinned, so it serves every caller waiting on it. + let (parts, _) = response.into_parts(); + let load = LargeLoad { + signer: Arc::clone(&self.signer), + services: services.clone().without_connection(), + url: origin.url.clone(), + headers: origin.headers.clone(), + backend_name: origin.backend_name.to_owned(), + auth: origin.auth.cloned(), + unsigned_headers: origin.unsigned_headers.to_vec(), + version: version.clone(), + reader_url: identity.reader_url.to_owned(), + content_type: header_text(&parts.headers, &header::CONTENT_TYPE).to_owned(), + references, + namespace, + budget, + keep_for, + }; + let loaded = match futures::future::select( + Box::pin(services.load_cached(&self.spec, &key, load.into_loader())), + timer.sleep(budget.remaining()), + ) + .await + { + Either::Left((loaded, _)) => loaded.ok().and_then(|bytes| decode_kept_outcome(&bytes)), + Either::Right(((), _)) => { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=timeout" + ); + None + } + }; + match loaded { + Some(KeptOutcome::Signed(record)) if record.length == version.length() => { + let source = VersionedMediaSource::pinned(services, origin, version); + self.deliver_pinned(&source, parts, &record).await + } + _ => self.deliver_fresh(services, origin, parts).await, + } + } + + /// Answers the reader of a version the lane does not sign with one + /// fresh origin answer to the request as core first sent it, the + /// reader's own range and conditions included, its origin signing + /// renewed. Nothing is retried. + /// + /// The status, body and every header describing the representation + /// are the fresh answer's, so a changed version goes with its own + /// validators. The rest are `parts`, the headers core and its caller + /// prepared for this reader, to which the fresh answer's `Link` and + /// `Content-Security-Policy` values are added, never replacing the + /// caller's: provenance the origin now declares is kept, and several + /// policies only narrow what a reader allows. The caller's cache policy + /// describes the first answer, not this one, so the answer is held by + /// no cache at all. A cookie or any other header the fresh answer + /// sets is never passed on, since the caller's own handling never saw + /// it. + async fn deliver_fresh( + &self, + services: &RuntimeServices, + origin: PinnedOrigin<'_>, + mut parts: http::response::Parts, + ) -> Response { + let signer_id = self.signer.signer_id(); + let Ok(mut request) = origin.signed_request(&self.reader_method, origin.base_headers()) + else { + log::warn!( + "media signing: outcome=unanswered lane=large signer={signer_id} reason=origin_request_unsigned" + ); + return origin_unreachable(); + }; + if services.http_client().supports_streaming_responses() { + request = request.with_stream_response(); + } + // The platform's error may name the address, which can carry a + // credential, so it is not logged. + let Ok(fresh) = services.http_client().send(request).await else { + log::warn!( + "media signing: outcome=unanswered lane=large signer={signer_id} reason=origin_unreachable" + ); + return origin_unreachable(); + }; + let (mut fresh, body) = fresh.response.into_parts(); + // Fresh cookies are not delivered. The existing redirect-loop guard + // must not assume they will change the reader's next request. + fresh.headers.remove(header::SET_COOKIE); + if let Some(rewrite) = origin.response_header_rewrite { + crate::response_header_rewrite::rewrite_origin_urls_in_headers( + &mut fresh.headers, + &rewrite, + ); + } + parts.status = fresh.status; + for name in FRESH_REPRESENTATION_HEADERS { + parts.headers.remove(name); + for value in fresh.headers.get_all(name) { + parts.headers.append(name, value.clone()); + } + } + for name in [header::LINK, header::CONTENT_SECURITY_POLICY] { + for value in fresh.headers.get_all(&name) { + if !parts + .headers + .get_all(&name) + .iter() + .any(|kept| kept == value) + { + parts.headers.append(name.clone(), value.clone()); + } + } + } + let mut response = Response::from_parts(parts, body); + crate::cache_policy::remove_owned_cache_headers(&mut response); + let headers = response.headers_mut(); + crate::cache_policy::apply_no_store_private_to_headers(headers); + for name in [header::EXPIRES, header::AGE] { + headers.remove(name); + } + response + } + + /// Decorates the origin's `304` for a large version kept as signed + /// under the signer's current namespace, so a reader revalidating it is + /// told of its manifest. Any other `304` goes as it came. + pub(super) async fn decorate_not_modified( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + facts: &OriginFacts, + response: Response, + ) -> Response { + // A `304` whose own `Link` already names a manifest keeps the + // origin's provenance as it came; no kept outcome is added to it. + if !self.large_lane_open() + || !self.looks_like_media + || facts.private + || carries_manifest_link(response.headers()) + { + return response; + } + let Some(version) = SourceVersion::of(response.headers(), 0) else { + return response; + }; + let Some(timer) = services.timer() else { + return response; + }; + let budget = LargeBudget { + started: Instant::now(), + total: self.signer.max_large_media_duration(), + }; + let remaining = budget.remaining(); + if remaining.is_zero() { + return response; + } + let namespace = + match futures::future::select(self.signer.namespace(services), timer.sleep(remaining)) + .await + { + Either::Left((Ok(namespace), _)) => namespace, + _ => return response, + }; + let key = self.large_store_key(services, &namespace, identity, version.etag()); + let remaining = budget.remaining(); + if remaining.is_zero() { + return response; + } + let record = match futures::future::select( + Box::pin(self.large_kept(services, &key, None)), + timer.sleep(remaining), + ) + .await + { + Either::Left((Some(KeptOutcome::Signed(record)), _)) => record, + _ => return response, + }; + let (mut parts, body) = response.into_parts(); + decorate(&mut parts.headers, &record); + Response::from_parts(parts, body) + } + + /// Answers the reader's preconditions and range against the pinned, + /// signed version, reading the bytes it needs by pinned reads, on + /// `parts`, the origin's headers as core prepared them, and adds the + /// signed version's headers from `record`. A version the origin no + /// longer serves is refused, never replaced, since the headers bind + /// these exact bytes. + async fn deliver_pinned( + &self, + source: &VersionedMediaSource<'_>, + mut parts: http::response::Parts, + record: &LargeRecord, + ) -> Response { + let length = source.size(); + let outcome = evaluate( + &self.reader_method, + &self.preconditions, + Some(source.version().etag()), + None, + length, + ); + let carries_body = self.reader_method != Method::HEAD; + let range = match outcome { + Outcome::Full if carries_body => length.checked_sub(1).map(|last| (0, last)), + Outcome::Partial { first, last } => Some((first, last)), + _ => None, + }; + let body = match range { + Some((first, last)) => { + let read = source.send_pinned(first, last).await; + match read.and_then(|body| exact_body(body, last - first + 1)) { + Ok(body) => body, + Err(error) => { + log::warn!( + "media signing: outcome=refused lane=large signer={} reason={}", + self.signer.signer_id(), + error.code() + ); + return refusal(); + } + } + } + None => EdgeBody::empty(), + }; + let headers = &mut parts.headers; + for name in [ + "content-range", + "content-length", + "transfer-encoding", + "content-encoding", + ] { + headers.remove(name); + } + match outcome { + Outcome::Full => { + parts.status = StatusCode::OK; + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(length)); + } + Outcome::Partial { first, last } => { + parts.status = StatusCode::PARTIAL_CONTENT; + headers.insert( + header::CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes {first}-{last}/{length}")) + .expect("should build a content range"), + ); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(last - first + 1)); + } + Outcome::NotModified => { + parts.status = StatusCode::NOT_MODIFIED; + } + Outcome::PreconditionFailed => { + parts.status = StatusCode::PRECONDITION_FAILED; + headers.remove(header::CONTENT_TYPE); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(0_u64)); + } + Outcome::Unsatisfiable => { + parts.status = StatusCode::RANGE_NOT_SATISFIABLE; + headers.insert( + header::CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes */{length}")) + .expect("should build a content range"), + ); + headers.remove(header::CONTENT_TYPE); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(0_u64)); + } + } + if matches!( + outcome, + Outcome::Full | Outcome::Partial { .. } | Outcome::NotModified + ) { + decorate(headers, record); + } + Response::from_parts(parts, body) + } + + /// The outcome kept under `key`, or `None`. A signed outcome whose + /// length is not `length`, where an answer states one, or a record that + /// does not read back, is dropped. The lookup itself runs no load, but + /// waits on one another caller is running for `key`. + async fn large_kept( + &self, + services: &RuntimeServices, + key: &StoreKey, + length: Option, + ) -> Option { + if !services.loading_cache_for(ASSETS_CACHE).keeps_values() { + return None; + } + let bytes = services + .load_cached( + &self.spec, + key, + Box::new(|| Box::pin(async { Err(LoadError::new(super::NOT_KEPT)) })), + ) + .await + .ok()?; + let kept = decode_kept_outcome(&bytes).filter(|kept| match kept { + KeptOutcome::Signed(record) => length.is_none_or(|length| record.length == length), + KeptOutcome::Declined => true, + }); + if kept.is_none() { + log::warn!( + "media signing: lane=large kept outcome unreadable or for another length signer={}", + self.signer.signer_id() + ); + services + .loading_cache_for(ASSETS_CACHE) + .remove(&services.clone().without_connection(), &self.spec, key) + .await; + } + kept + } + + /// The key the outcome for `identity`'s version `etag` is kept under, + /// bound to the publisher's prefix, the signer and its `namespace`. + fn large_store_key( + &self, + services: &RuntimeServices, + namespace: &str, + identity: &VariantIdentity<'_>, + etag: &str, + ) -> StoreKey { + let mut digest = Sha256::new(); + for part in [ + self.signer.signer_id(), + namespace, + identity.reader_url, + identity.origin_identity, + identity.fetched_url, + etag, + ] { + digest.update(part.as_bytes()); + digest.update([0]); + } + StoreKey::new( + services.server_key_prefix(), + ASSETS_CACHE, + format!("media-large-{}", hex::encode(digest.finalize().as_slice())), + ) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-core/src/media_signing/large/tests.rs b/crates/trusted-server-core/src/media_signing/large/tests.rs new file mode 100644 index 0000000000..d6e72274f7 --- /dev/null +++ b/crates/trusted-server-core/src/media_signing/large/tests.rs @@ -0,0 +1,2020 @@ +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; + +use base64::Engine as _; +use error_stack::Report; +use futures::FutureExt as _; +use futures::StreamExt as _; +use futures::channel::oneshot; +use futures::executor::block_on; +use sha2::Digest as _; + +use super::*; +use crate::error::TrustedServerError; +use crate::media_signing::test_support::MANIFEST_LINK; +use crate::media_signing::{Answered, MediaJob, MediaNamespacing, MediaSigning}; +use crate::origin_auth::OriginSigner; +use crate::platform::test_support::{ + ElapsedTimer, MemoryLoadingCache, StoppedTimer, build_services_with_http_client, +}; +use crate::platform::{ + CacheSpec, PlatformError, PlatformHttpClient, PlatformLoadingCache, PlatformPendingRequest, + PlatformResponse, PlatformSelectResult, PlatformTimer, +}; +use crate::test_support::tests::create_test_settings; + +const PATH: &str = "/media/42"; +const FETCHED_URL: &str = "https://origin.example.com/media/42"; +const QUERY_FETCHED_URL: &str = "https://origin.example.com/media/42?v=2"; +const READER_URL: &str = "https://www.example.com/media/42"; +const ORIGIN_IDENTITY: &str = "https://origin.example.com\0origin.example.com"; +const ETAG_V1: &str = "\"v1\""; +const ETAG_V2: &str = "\"v2\""; +/// The size of each chunk the stub origin streams, so every body arrives +/// in several pieces. +const CHUNK: usize = 5; +/// The `Authorization` the test origin signer writes. +const ORIGIN_SIGNATURE: &str = "TestOrigin signed"; + +fn source() -> Vec { + (0_u8..64).collect() +} + +fn other_source() -> Vec { + (100_u8..164).collect() +} + +fn identity() -> VariantIdentity<'static> { + VariantIdentity { + reader_url: READER_URL, + origin_identity: ORIGIN_IDENTITY, + fetched_url: FETCHED_URL, + reader_query_free: true, + } +} + +/// An origin answer for `bytes` at version `etag`: the bytes `range` +/// names as a `206`, or all of them as a `200`, streamed in [`CHUNK`]s and +/// cut with a stream error after `cut_at` bytes where given. +fn serve( + bytes: &[u8], + etag: &str, + range: Option<(u64, u64)>, + cut_at: Option, +) -> Response { + let total = bytes.len(); + let (status, slice) = match range { + Some((first, last)) => { + let first = usize::try_from(first).expect("should fit a test offset"); + let last = usize::try_from(last).expect("should fit a test offset"); + (StatusCode::PARTIAL_CONTENT, bytes[first..=last].to_vec()) + } + None => (StatusCode::OK, bytes.to_vec()), + }; + let mut builder = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "image/png") + .header(header::ETAG, etag) + .header(header::CACHE_CONTROL, "public, max-age=600") + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CONTENT_LENGTH, slice.len()); + if let Some((first, last)) = range { + builder = builder.header( + header::CONTENT_RANGE, + format!("bytes {first}-{last}/{total}"), + ); + } + let kept = cut_at.map_or(slice.len(), |cut| cut.min(slice.len())); + let mut items: Vec> = slice[..kept] + .chunks(CHUNK) + .map(|chunk| Ok(Bytes::copy_from_slice(chunk))) + .collect(); + if cut_at.is_some() { + items.push(Err(std::io::Error::other("origin connection cut"))); + } + builder + .body(EdgeBody::from_stream(futures::stream::iter(items))) + .expect("should build an origin answer") +} + +/// The origin's whole `200` for the reader's first request. +fn whole() -> Response { + serve(&source(), ETAG_V1, None, None) +} + +/// The origin's answer to a `HEAD`: the whole's headers, no body. +fn head_answer(bytes: &[u8], etag: &str) -> Response { + let (parts, _) = serve(bytes, etag, None, None).into_parts(); + Response::from_parts(parts, EdgeBody::empty()) +} + +fn not_modified(etag: &str) -> Response { + Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, etag) + .header(header::CACHE_CONTROL, "public, max-age=600") + .body(EdgeBody::empty()) + .expect("should build a not modified answer") +} + +fn assert_unchanged_not_modified(delivery: Delivery, headers: &HeaderMap) { + let Delivery::Send(response) = delivery else { + panic!("should send the original not modified response"); + }; + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + assert_eq!(response.headers(), headers, "should preserve every header"); + let (_, body) = collect(response); + assert_eq!(body, Some(Vec::new()), "should preserve the empty body"); +} + +/// A `bytes=first-last` request range. +fn requested_range(headers: &HeaderMap) -> Option<(u64, u64)> { + let value = headers.get(header::RANGE)?.to_str().ok()?; + let (first, last) = value.strip_prefix("bytes=")?.split_once('-')?; + Some((first.parse().ok()?, last.parse().ok()?)) +} + +struct VersionChange { + after: usize, + bytes: Vec, + etag: String, + headers: Vec<(HeaderName, HeaderValue)>, +} + +#[derive(Default)] +struct OriginState { + bytes: Vec, + etag: String, + /// From the request after this many on, the origin serves the other + /// version, answering it with these headers too. + flip: Option, + /// Headers every answer carries besides [`serve`]'s own, replacing + /// them. + answer_headers: Vec<(HeaderName, HeaderValue)>, + /// Override the status after this many requests. + status_after: Option<(usize, StatusCode)>, + /// The next answer's stream is cut after this many bytes. + cut_next: Option, + sends: usize, + wrong_content_range: bool, + requests: Vec, +} + +/// An origin that honors `Range` and `If-Match` and can change version. +struct RangeOrigin { + state: Mutex, +} + +impl RangeOrigin { + fn new(bytes: &[u8], etag: &str) -> Arc { + Arc::new(Self { + state: Mutex::new(OriginState { + bytes: bytes.to_vec(), + etag: etag.to_owned(), + ..OriginState::default() + }), + }) + } + + /// Serves `bytes` at `etag` from the request after the `after`th on. + fn flip_after(&self, after: usize, bytes: &[u8], etag: &str) { + self.flip_after_with(after, bytes, etag, Vec::new()); + } + + /// As [`Self::flip_after`], the new version's answers carrying + /// `headers` too. + fn flip_after_with( + &self, + after: usize, + bytes: &[u8], + etag: &str, + headers: Vec<(HeaderName, HeaderValue)>, + ) { + self.state.lock().expect("should lock origin").flip = Some(VersionChange { + after, + bytes: bytes.to_vec(), + etag: etag.to_owned(), + headers, + }); + } + + fn cut_next_at(&self, at: usize) { + self.state.lock().expect("should lock origin").cut_next = Some(at); + } + + fn answer_wrong_content_range(&self) { + self.state + .lock() + .expect("should lock origin") + .wrong_content_range = true; + } + + fn requests(&self) -> Vec { + self.state + .lock() + .expect("should lock origin") + .requests + .clone() + } + + /// The ranges every request asked for, in order. + fn ranges(&self) -> Vec> { + self.requests().iter().map(requested_range).collect() + } + + /// How many requests read the whole version, which only hashing does + /// besides a whole delivery. + fn whole_reads(&self) -> usize { + self.ranges() + .iter() + .filter(|range| **range == Some((0, 63))) + .count() + } +} + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for RangeOrigin { + fn supports_streaming_responses(&self) -> bool { + true + } + + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let mut state = self.state.lock().expect("should lock origin"); + if state + .flip + .as_ref() + .is_some_and(|flip| state.sends >= flip.after) + { + let flip = state.flip.take().expect("should have a due version change"); + state.bytes = flip.bytes; + state.etag = flip.etag; + state.answer_headers = flip.headers; + } + state.sends += 1; + let headers = request.request.headers().clone(); + state.requests.push(headers.clone()); + if let Some(if_match) = headers.get(header::IF_MATCH) + && if_match.as_bytes() != state.etag.as_bytes() + { + let refused = Response::builder() + .status(StatusCode::PRECONDITION_FAILED) + .header(header::ETAG, state.etag.as_str()) + .body(EdgeBody::empty()) + .expect("should build a refusal"); + return Ok(PlatformResponse::new(refused)); + } + let range = requested_range(&headers); + let cut = state.cut_next.take(); + let mut response = serve(&state.bytes, &state.etag, range, cut); + if let Some((after, status)) = state.status_after + && state.sends > after + { + *response.status_mut() = status; + } + for (name, value) in &state.answer_headers { + response.headers_mut().insert(name.clone(), value.clone()); + } + if state.wrong_content_range && range.is_some() { + response.headers_mut().insert( + header::CONTENT_RANGE, + HeaderValue::from_static("bytes 0-0/1"), + ); + } + Ok(PlatformResponse::new(response)) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient)) + } +} + +/// An origin signer that writes [`ORIGIN_SIGNATURE`] and owns every +/// `x-test-origin-*` header. +struct TestOriginSigner; + +impl OriginSigner for TestOriginSigner { + fn sign( + &self, + _method: &Method, + _url: &url::Url, + headers: &mut HeaderMap, + _now: SystemTime, + ) -> Result<(), Report> { + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static(ORIGIN_SIGNATURE), + ); + Ok(()) + } + + fn remove_namespace_headers(&self, headers: &mut HeaderMap) { + headers.remove("x-test-origin-key"); + } +} + +/// One large job the stub signer was handed. +#[derive(Debug, Clone)] +struct LargeJobRecord { + source_sha256: String, + size: u64, + remaining: Duration, + source_reference: Option, + reader_reference: Option, + origin_host: String, +} + +/// How the stub signer answers a large job. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LargeBehavior { + /// Signs, adding [`MANIFEST_LINK`]. + Sign, + /// Signs after confirming the pinned version is still current. + ConfirmThenSign, + /// Refuses, as a source with provenance of its own is refused. + Refuse, + /// Never answers. + Hang, +} + +/// A signer whose bounded limit is 16 bytes and whose large lane takes up +/// to 1 KiB. +struct LargeSigner { + behavior: LargeBehavior, + namespace: Mutex>, + namespace_gate: Mutex>>, + large_duration: Duration, + attempts: Mutex, + jobs: Mutex>, + failures: Mutex>, +} + +impl LargeSigner { + fn new(behavior: LargeBehavior) -> Arc { + Arc::new(Self { + behavior, + namespace: Mutex::new(Ok("policy-1".to_owned())), + namespace_gate: Mutex::new(None), + large_duration: Duration::from_secs(60), + attempts: Mutex::new(0), + jobs: Mutex::new(Vec::new()), + failures: Mutex::new(Vec::new()), + }) + } + + fn set_namespace(&self, namespace: Result) { + *self.namespace.lock().expect("should lock namespace") = namespace; + } + + /// How many times core called `sign_large`. + fn attempts(&self) -> usize { + *self.attempts.lock().expect("should lock attempts") + } + + fn jobs(&self) -> Vec { + self.jobs.lock().expect("should lock jobs").clone() + } + + fn failures(&self) -> Vec<&'static str> { + self.failures.lock().expect("should lock failures").clone() + } +} + +impl MediaSigner for LargeSigner { + fn signer_id(&self) -> &'static str { + "testing.large-media" + } + + fn handles_path(&self, path: &str) -> bool { + !path.starts_with("/api/") + } + + fn handles_media_type(&self, media_type: &str) -> bool { + media_type.starts_with("image/") + } + + fn max_source_bytes(&self) -> usize { + 16 + } + + fn namespace<'a>(&'a self, _services: &'a RuntimeServices) -> MediaNamespacing<'a> { + let namespace = self + .namespace + .lock() + .expect("should lock namespace") + .clone(); + let gate = self + .namespace_gate + .lock() + .expect("should lock namespace gate") + .take(); + Box::pin(async move { + if let Some(gate) = gate { + gate.await.expect("should release namespace"); + } + namespace + }) + } + + fn sign<'a>( + &'a self, + _services: &'a RuntimeServices, + _job: &'a MediaJob<'a>, + ) -> MediaSigning<'a> { + Box::pin(async { + Err(MediaSigningFailure { + reason: "bounded_not_expected", + }) + }) + } + + fn max_large_source_bytes(&self) -> u64 { + 1024 + } + + fn max_large_media_duration(&self) -> Duration { + self.large_duration + } + + fn sign_large<'a>( + &'a self, + _services: &'a RuntimeServices, + job: &'a LargeMediaJob<'a>, + ) -> LargeMediaSigning<'a> { + Box::pin(async move { + *self.attempts.lock().expect("should lock attempts") += 1; + match self.behavior { + LargeBehavior::Refuse => { + return Err(MediaSigningFailure { + reason: "source_has_embedded_provenance", + }); + } + LargeBehavior::Hang => futures::future::pending::<()>().await, + LargeBehavior::ConfirmThenSign => { + if let Err(error) = job.source.confirm_current().await { + self.failures + .lock() + .expect("should lock failures") + .push(error.code()); + return Err(MediaSigningFailure { + reason: error.code(), + }); + } + } + LargeBehavior::Sign => {} + } + self.jobs + .lock() + .expect("should lock jobs") + .push(LargeJobRecord { + source_sha256: job.source_sha256.to_owned(), + size: job.source.size(), + remaining: job.remaining, + source_reference: job.source_reference.map(str::to_owned), + reader_reference: job.reader_reference.map(str::to_owned), + origin_host: job.source_origin_host.to_owned(), + }); + let namespace = self + .namespace + .lock() + .expect("should lock namespace") + .clone() + .unwrap_or_default(); + Ok(LargeSignedMedia { + headers: vec![(header::LINK, HeaderValue::from_static(MANIFEST_LINK))], + namespace, + }) + }) + } +} + +#[derive(Default)] +struct PendingLargeCache { + calls: Mutex, +} + +#[async_trait::async_trait(?Send)] +impl PlatformLoadingCache for PendingLargeCache { + async fn get_or_load( + &self, + _services: &RuntimeServices, + _spec: &CacheSpec, + _key: &StoreKey, + _load: Loader, + ) -> Result { + *self.calls.lock().expect("should lock cache calls") += 1; + futures::future::pending().await + } + + async fn remove(&self, _services: &RuntimeServices, _spec: &CacheSpec, _key: &StoreKey) { + panic!("should never remove a pending cache outcome"); + } +} + +fn services_with_timer( + signer: &Arc, + origin: &Arc, + timer: Arc, +) -> RuntimeServices { + build_services_with_http_client(Arc::clone(origin) as Arc) + .with_media_signer(Some(Arc::clone(signer) as Arc)) + .with_loading_cache(Arc::new(MemoryLoadingCache::default())) + .with_timer(timer) +} + +fn services_for(signer: &Arc, origin: &Arc) -> RuntimeServices { + services_with_timer(signer, origin, Arc::new(StoppedTimer)) +} + +fn claim_for( + services: &RuntimeServices, + method: &Method, + pairs: &[(HeaderName, &str)], +) -> MediaClaim { + let mut headers = HeaderMap::new(); + headers.insert("sec-fetch-dest", HeaderValue::from_static("image")); + for (name, value) in pairs { + headers.insert( + name.clone(), + HeaderValue::from_str(value).expect("should build a header"), + ); + } + MediaClaim::of_request( + &create_test_settings(), + services, + method, + PATH, + &headers, + true, + ) + .expect("should claim a media path") +} + +/// How core asks the origin again in a test: the address, the request's +/// headers before signing, and the origin's signing where it has one. +struct TestOrigin<'a> { + identity: VariantIdentity<'a>, + headers: HeaderMap, + auth: Option, + unsigned_headers: Vec, + response_header_rewrite: Option>, +} + +impl TestOrigin<'static> { + /// An origin request that forwarded the reader's `pairs`, as a route + /// forwards a reader's range and conditions. + fn forwarding(pairs: &[(HeaderName, &str)]) -> Self { + let mut headers = HeaderMap::new(); + for (name, value) in pairs { + headers.insert( + name.clone(), + HeaderValue::from_str(value).expect("should build a header"), + ); + } + Self { + headers, + ..Self::plain() + } + } + + fn plain() -> Self { + Self { + identity: identity(), + headers: HeaderMap::new(), + auth: None, + unsigned_headers: Vec::new(), + response_header_rewrite: None, + } + } +} + +/// Delivers the origin's `response` through `origin`, by the large lane +/// where core admits it. Whether it was admitted, and the answer. +fn run_through( + claim: &MediaClaim, + services: &RuntimeServices, + origin: &TestOrigin<'_>, + response: Response, +) -> (bool, Response) { + block_on(async { + let facts = OriginFacts::of(response.headers()); + match claim + .deliver( + services, + &origin.identity, + Answered::Origin(facts), + response, + ) + .await + { + Delivery::Large(answer) => { + let url = url::Url::parse(origin.identity.fetched_url) + .expect("should parse the fetched address"); + let pinned = PinnedOrigin { + url: &url, + headers: &origin.headers, + backend_name: "origin", + auth: origin.auth.as_ref(), + unsigned_headers: &origin.unsigned_headers, + response_header_rewrite: origin.response_header_rewrite, + }; + ( + true, + claim + .deliver_large(services, &origin.identity, pinned, answer) + .await, + ) + } + Delivery::Send(response) => (false, response), + Delivery::FetchWhole(_) => { + panic!("should never ask for a bounded whole of a large source") + } + } + }) +} + +fn run( + claim: &MediaClaim, + services: &RuntimeServices, + response: Response, +) -> (bool, Response) { + run_through(claim, services, &TestOrigin::plain(), response) +} + +/// The body's bytes, or `None` where its stream failed. +fn collect(response: Response) -> (Response, Option>) { + let (parts, body) = response.into_parts(); + let bytes = block_on(async { + match body { + EdgeBody::Once(bytes) => Some(bytes.to_vec()), + EdgeBody::Stream(mut stream) => { + let mut collected = Vec::new(); + while let Some(chunk) = stream.next().await { + collected.extend_from_slice(&chunk.ok()?); + } + Some(collected) + } + } + }); + (Response::from_parts(parts, EdgeBody::empty()), bytes) +} + +fn header_of<'a>(response: &'a Response, name: &str) -> Option<&'a str> { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) +} + +fn whole_digest_header(bytes: &[u8]) -> String { + format!( + "sha-256=:{}:", + base64::engine::general_purpose::STANDARD.encode(Sha256::digest(bytes)) + ) +} + +#[test] +fn hashes_a_large_source_once_and_delivers_the_exact_pinned_bytes() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::ConfirmThenSign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + + let (large, response) = run(&claim, &services, whole()); + let (response, body) = collect(response); + + assert!( + large, + "should admit a source past the bounded limit with a strong tag" + ); + let jobs = signer.jobs(); + assert_eq!(jobs.len(), 1, "should sign once"); + assert_eq!( + jobs[0].source_sha256, + hex::encode(Sha256::digest(source())), + "should hand the signer the digest of every source byte" + ); + assert_eq!(jobs[0].size, 64, "should pin the declared length"); + assert!( + jobs[0].remaining > Duration::ZERO && jobs[0].remaining <= Duration::from_secs(60), + "should tell the signer what is left of its budget" + ); + assert_eq!(jobs[0].source_reference.as_deref(), Some(FETCHED_URL)); + assert_eq!(jobs[0].reader_reference.as_deref(), Some(READER_URL)); + assert_eq!(jobs[0].origin_host, "origin.example.com"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + body, + Some(source()), + "should deliver the origin's exact bytes" + ); + assert_eq!( + header_of(&response, "etag"), + Some(ETAG_V1), + "should keep the origin's validator" + ); + assert_eq!(header_of(&response, "content-length"), Some("64")); + assert_eq!( + header_of(&response, "link"), + Some(MANIFEST_LINK), + "should carry the manifest link" + ); + assert_eq!( + header_of(&response, "repr-digest"), + Some(whole_digest_header(&source()).as_str()), + "should describe the whole representation" + ); + let requests = origin.requests(); + for request in &requests { + assert_eq!( + request + .get(header::IF_MATCH) + .and_then(|value| value.to_str().ok()), + Some(ETAG_V1), + "should pin every origin read to the version" + ); + assert!( + request.get(header::RANGE).is_some(), + "should ask for exact ranges" + ); + } + assert_eq!( + origin.ranges(), + vec![Some((0, 63)), Some((0, 0)), Some((0, 63))], + "should hash once, confirm, then deliver every byte of the pinned version" + ); +} + +#[test] +fn answers_a_reader_range_with_the_exact_original_bytes() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::ConfirmThenSign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[(header::RANGE, "bytes=10-19")]); + + let (large, response) = run( + &claim, + &services, + serve(&source(), ETAG_V1, Some((10, 19)), None), + ); + let (response, body) = collect(response); + + assert!( + large, + "should admit a part whose total is past the bounded limit" + ); + assert_eq!( + signer.jobs()[0].source_sha256, + hex::encode(Sha256::digest(source())), + "should hash the whole version, not the part" + ); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + header_of(&response, "content-range"), + Some("bytes 10-19/64") + ); + assert_eq!(header_of(&response, "content-length"), Some("10")); + assert_eq!( + body, + Some(source()[10..20].to_vec()), + "should send the original bytes of the range" + ); + assert_eq!(header_of(&response, "link"), Some(MANIFEST_LINK)); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V1)); + assert_eq!( + origin.ranges(), + vec![Some((0, 63)), Some((0, 0)), Some((10, 19))], + "should hash the whole pinned version once, then read the reader's range pinned" + ); + + let (_, response) = run( + &claim, + &services, + serve(&source(), ETAG_V1, Some((10, 19)), None), + ); + let (response, body) = collect(response); + assert_eq!( + origin.whole_reads(), + 1, + "a second range should never hash again" + ); + assert_eq!(body, Some(source()[10..20].to_vec())); + assert_eq!(header_of(&response, "link"), Some(MANIFEST_LINK)); +} + +#[test] +fn a_cold_head_starts_no_load_and_a_warm_one_is_decorated() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let head = claim_for(&services, &Method::HEAD, &[]); + + let (large, response) = run(&head, &services, head_answer(&source(), ETAG_V1)); + let (response, body) = collect(response); + assert!(large, "should admit a HEAD of a large source"); + assert!( + origin.requests().is_empty(), + "a cold HEAD should never read the origin again" + ); + assert_eq!( + signer.attempts(), + 0, + "a cold HEAD should never call the signer" + ); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(header_of(&response, "content-length"), Some("64")); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V1)); + assert_eq!(header_of(&response, "link"), None); + assert_eq!(body, Some(Vec::new()), "a HEAD carries no body"); + + let get = claim_for(&services, &Method::GET, &[]); + let _ = collect(run(&get, &services, whole()).1); + let reads = origin.requests().len(); + let (_, response) = run(&head, &services, head_answer(&source(), ETAG_V1)); + let (response, body) = collect(response); + assert_eq!( + header_of(&response, "link"), + Some(MANIFEST_LINK), + "a warm HEAD carries the manifest" + ); + assert_eq!( + header_of(&response, "repr-digest"), + Some(whole_digest_header(&source()).as_str()) + ); + assert_eq!(header_of(&response, "content-length"), Some("64")); + assert_eq!(body, Some(Vec::new())); + assert_eq!( + origin.requests().len(), + reads, + "a warm HEAD should read nothing" + ); + assert_eq!(signer.attempts(), 1); +} + +#[test] +fn only_an_origin_that_says_it_serves_byte_ranges_starts_a_load() { + for accept_ranges in [None, Some("none")] { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let mut answer = whole(); + answer.headers_mut().remove(header::ACCEPT_RANGES); + if let Some(value) = accept_ranges { + answer + .headers_mut() + .insert(header::ACCEPT_RANGES, HeaderValue::from_static(value)); + } + + let (_, response) = run(&claim, &services, answer); + let (response, body) = collect(response); + + assert!( + origin.requests().is_empty(), + "{accept_ranges:?}: should never hash" + ); + assert_eq!(signer.attempts(), 0, "{accept_ranges:?}"); + assert_eq!( + body, + Some(source()), + "{accept_ranges:?}: should send the origin's answer" + ); + assert_eq!( + header_of(&response, "etag"), + Some(ETAG_V1), + "{accept_ranges:?}" + ); + assert_eq!(header_of(&response, "link"), None, "{accept_ranges:?}"); + } + + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let _ = collect(run(&claim, &services, whole()).1); + let mut answer = whole(); + answer.headers_mut().remove(header::ACCEPT_RANGES); + let (_, response) = run(&claim, &services, answer); + let (response, body) = collect(response); + assert_eq!(body, Some(source())); + assert_eq!( + header_of(&response, "link"), + Some(MANIFEST_LINK), + "a kept signed outcome still decorates the version" + ); + assert_eq!(signer.attempts(), 1); +} + +#[test] +fn a_kept_outcome_answers_without_hashing_and_never_outlives_the_policy() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let _ = collect(run(&claim, &services, whole()).1); + let reads = origin.requests().len(); + + let (_, response) = run(&claim, &services, whole()); + let (response, body) = collect(response); + assert_eq!( + signer.attempts(), + 1, + "should reuse the outcome kept for the version" + ); + assert_eq!( + origin.requests().len(), + reads, + "should send the origin's own answer" + ); + assert_eq!(body, Some(source())); + assert_eq!(header_of(&response, "link"), Some(MANIFEST_LINK)); + + let (_, revalidated) = run(&claim, &services, not_modified(ETAG_V1)); + assert_eq!(revalidated.status(), StatusCode::NOT_MODIFIED); + assert_eq!( + header_of(&revalidated, "link"), + Some(MANIFEST_LINK), + "should tell a revalidating reader of the manifest" + ); + + signer.set_namespace(Ok("policy-2".to_owned())); + let _ = collect(run(&claim, &services, whole()).1); + assert_eq!( + signer.attempts(), + 2, + "a changed policy should never find the old outcome" + ); + + signer.set_namespace(Err(MediaSigningFailure { reason: "revoked" })); + let reads = origin.requests().len(); + let (_, response) = run(&claim, &services, whole()); + let (response, body) = collect(response); + assert_eq!(signer.attempts(), 2, "a revoked policy should sign nothing"); + assert_eq!(origin.requests().len(), reads, "should hash nothing"); + assert_eq!( + header_of(&response, "link"), + None, + "should carry no manifest" + ); + assert_eq!( + header_of(&response, "etag"), + Some(ETAG_V1), + "should keep the origin's validator on its unchanged bytes" + ); + assert_eq!(body, Some(source())); +} + +#[test] +fn a_refused_source_is_hashed_once_and_then_sent_as_it_came() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Refuse); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[(header::RANGE, "bytes=0-9")]); + let forwarding = TestOrigin::forwarding(&[(header::RANGE, "bytes=0-9")]); + + let (_, response) = run_through( + &claim, + &services, + &forwarding, + serve(&source(), ETAG_V1, Some((0, 9)), None), + ); + let (response, body) = collect(response); + assert_eq!(signer.attempts(), 1); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(header_of(&response, "content-range"), Some("bytes 0-9/64")); + assert_eq!(body, Some(source()[0..10].to_vec())); + assert_eq!(header_of(&response, "link"), None); + assert_eq!( + header_of(&response, "etag"), + Some(ETAG_V1), + "should keep the origin's validator" + ); + assert_eq!( + header_of(&response, "cache-control"), + Some(crate::cache_policy::NO_STORE_PRIVATE_CACHE_CONTROL), + "a fresh fallback is held by no cache, even for the same version" + ); + let fresh = origin + .requests() + .last() + .cloned() + .expect("should ask the origin afresh"); + assert_eq!( + requested_range(&fresh), + Some((0, 9)), + "should ask afresh exactly as the reader asked" + ); + assert!( + fresh.get(header::IF_MATCH).is_none(), + "an unsigned answer is not pinned" + ); + let reads = origin.requests().len(); + + for _ in 0..3 { + let (_, response) = run_through( + &claim, + &services, + &forwarding, + serve(&source(), ETAG_V1, Some((0, 9)), None), + ); + let (response, body) = collect(response); + assert_eq!(body, Some(source()[0..10].to_vec())); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V1)); + assert_eq!(header_of(&response, "link"), None); + } + assert_eq!( + signer.attempts(), + 1, + "a kept refusal should never call the signer again" + ); + assert_eq!( + origin.requests().len(), + reads, + "a kept refusal should never read the origin again" + ); + assert_eq!(origin.whole_reads(), 1, "should hash once in all"); +} + +#[test] +fn a_declined_signed_origin_is_asked_afresh_with_a_fresh_signature() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Refuse); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[(header::RANGE, "bytes=0-9")]); + let mut headers = HeaderMap::new(); + headers.insert(header::RANGE, HeaderValue::from_static("bytes=0-9")); + headers.insert( + header::REFERER, + HeaderValue::from_static("https://www.example.com/page"), + ); + headers.insert("x-test-origin-key", HeaderValue::from_static("from-reader")); + let signed = TestOrigin { + headers, + auth: Some(OriginSigning::new(Arc::new(TestOriginSigner), None)), + unsigned_headers: vec![header::REFERER], + ..TestOrigin::plain() + }; + + let (_, response) = run_through( + &claim, + &services, + &signed, + serve(&source(), ETAG_V1, Some((0, 9)), None), + ); + let (response, body) = collect(response); + + assert_eq!(body, Some(source()[0..10].to_vec())); + assert_eq!(header_of(&response, "link"), None); + let fresh = origin + .requests() + .last() + .cloned() + .expect("should ask the origin afresh"); + assert_eq!( + fresh + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(ORIGIN_SIGNATURE), + "should sign the fresh request afresh" + ); + assert_eq!(requested_range(&fresh), Some((0, 9))); + assert!(fresh.get(header::REFERER).is_none()); + assert!(fresh.get("x-test-origin-key").is_none()); + assert!(fresh.get(header::IF_MATCH).is_none()); +} + +#[test] +fn a_not_modified_pending_namespace_obeys_the_large_budget() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let mut signer = LargeSigner::new(LargeBehavior::Sign); + let (namespace_ready, namespace_gate) = oneshot::channel(); + let configured = Arc::get_mut(&mut signer).expect("should exclusively own the signer"); + configured.large_duration = Duration::from_secs(5); + *configured + .namespace_gate + .lock() + .expect("should lock namespace gate") = Some(namespace_gate); + let timer = Arc::new(ElapsedTimer::default()); + let cache = Arc::new(PendingLargeCache::default()); + let services = services_with_timer( + &signer, + &origin, + Arc::clone(&timer) as Arc, + ) + .with_loading_cache(Arc::clone(&cache) as Arc); + let claim = claim_for(&services, &Method::GET, &[]); + let response = not_modified(ETAG_V1); + let headers = response.headers().clone(); + let facts = OriginFacts::of(response.headers()); + + let delivery = claim + .deliver(&services, &identity(), Answered::Origin(facts), response) + .now_or_never() + .expect("should stop waiting for the namespace at the large deadline"); + + assert_unchanged_not_modified(delivery, &headers); + assert_eq!(*cache.calls.lock().expect("should lock cache calls"), 0); + assert!(origin.requests().is_empty()); + assert_eq!(signer.attempts(), 0); + let requested = timer.requested.lock().expect("should lock timer requests"); + assert_eq!(requested.len(), 1); + assert!(requested[0] > Duration::ZERO && requested[0] <= Duration::from_secs(5)); + drop(namespace_ready); +} + +#[test] +fn a_not_modified_pending_cache_obeys_the_large_budget() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let mut signer = LargeSigner::new(LargeBehavior::Sign); + Arc::get_mut(&mut signer) + .expect("should exclusively own the signer") + .large_duration = Duration::from_secs(5); + let timer = Arc::new(ElapsedTimer::default()); + let cache = Arc::new(PendingLargeCache::default()); + let services = services_with_timer( + &signer, + &origin, + Arc::clone(&timer) as Arc, + ) + .with_loading_cache(Arc::clone(&cache) as Arc); + let claim = claim_for(&services, &Method::GET, &[]); + let response = not_modified(ETAG_V1); + let headers = response.headers().clone(); + let facts = OriginFacts::of(response.headers()); + + let delivery = claim + .deliver(&services, &identity(), Answered::Origin(facts), response) + .now_or_never() + .expect("should stop waiting for the cached outcome at the large deadline"); + + assert_unchanged_not_modified(delivery, &headers); + assert_eq!(*cache.calls.lock().expect("should lock cache calls"), 1); + assert!(origin.requests().is_empty()); + assert_eq!(signer.attempts(), 0); + let requested = timer.requested.lock().expect("should lock timer requests"); + assert_eq!(requested.len(), 1); + assert!(requested[0] > Duration::ZERO && requested[0] <= Duration::from_secs(5)); +} + +#[test] +fn a_not_modified_without_a_timer_or_positive_large_budget_starts_no_io() { + for (has_timer, budget) in [(false, Duration::from_secs(5)), (true, Duration::ZERO)] { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let mut signer = LargeSigner::new(LargeBehavior::Sign); + let (namespace_ready, namespace_gate) = oneshot::channel(); + let configured = Arc::get_mut(&mut signer).expect("should exclusively own the signer"); + configured.large_duration = budget; + *configured + .namespace_gate + .lock() + .expect("should lock namespace gate") = Some(namespace_gate); + let cache = Arc::new(PendingLargeCache::default()); + let services = + build_services_with_http_client(Arc::clone(&origin) as Arc) + .with_media_signer(Some(Arc::clone(&signer) as Arc)) + .with_loading_cache(Arc::clone(&cache) as Arc); + let services = if has_timer { + services.with_timer(Arc::new(ElapsedTimer::default())) + } else { + services + }; + let claim = claim_for(&services, &Method::GET, &[]); + let response = not_modified(ETAG_V1); + let headers = response.headers().clone(); + let facts = OriginFacts::of(response.headers()); + + let delivery = claim + .deliver(&services, &identity(), Answered::Origin(facts), response) + .now_or_never() + .expect("should refuse before polling namespace without a timer and positive budget"); + + assert_unchanged_not_modified(delivery, &headers); + assert!( + signer + .namespace_gate + .lock() + .expect("should lock namespace gate") + .is_some(), + "should never call namespace resolution" + ); + assert_eq!(*cache.calls.lock().expect("should lock cache calls"), 0); + assert!(origin.requests().is_empty()); + assert_eq!(signer.attempts(), 0); + drop(namespace_ready); + } +} + +#[test] +fn a_namespace_pending_past_the_large_budget_starts_no_hash_or_signing() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let mut signer = LargeSigner::new(LargeBehavior::Sign); + let (namespace_ready, namespace_gate) = oneshot::channel(); + let configured = Arc::get_mut(&mut signer).expect("should exclusively own the signer"); + configured.large_duration = Duration::from_secs(5); + *configured + .namespace_gate + .lock() + .expect("should lock namespace gate") = Some(namespace_gate); + let timer = Arc::new(ElapsedTimer::default()); + let services = services_with_timer( + &signer, + &origin, + Arc::clone(&timer) as Arc, + ); + let claim = claim_for(&services, &Method::GET, &[]); + let response = whole(); + let facts = OriginFacts::of(response.headers()); + let Delivery::Large(answer) = + block_on(claim.deliver(&services, &identity(), Answered::Origin(facts), response)) + else { + panic!("should admit the source to the large lane"); + }; + let url = url::Url::parse(FETCHED_URL).expect("should parse the fetched address"); + let headers = HeaderMap::new(); + let pinned = PinnedOrigin { + url: &url, + headers: &headers, + backend_name: "origin", + auth: None, + unsigned_headers: &[], + response_header_rewrite: None, + }; + + // Keep the policy future unresolved while the deterministic large timer + // expires. A missing namespace race must fail here, never hang the test. + let response = claim + .deliver_large(&services, &identity(), pinned, answer) + .now_or_never() + .expect("should return unsigned when the large budget expires during namespace resolution"); + let (response, body) = collect(response); + + assert_eq!(body, Some(source()), "should preserve the original body"); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V1)); + assert_eq!(header_of(&response, "link"), None); + assert_eq!(header_of(&response, "repr-digest"), None); + assert!(origin.requests().is_empty(), "should never start hashing"); + assert_eq!(signer.attempts(), 0, "should never start signing"); + assert!(signer.jobs().is_empty()); + let requested = timer.requested.lock().expect("should lock timer requests"); + assert_eq!(requested.len(), 1, "should race the namespace once"); + assert!( + requested[0] > Duration::ZERO && requested[0] <= Duration::from_secs(5), + "should use the large budget rather than the policy's own hold limit" + ); + drop(namespace_ready); +} + +#[test] +fn an_exhausted_large_budget_starts_no_hash_or_signing() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_with_timer(&signer, &origin, Arc::new(ElapsedTimer::default())); + let load = LargeLoad { + signer: Arc::clone(&signer) as Arc, + services, + url: url::Url::parse(FETCHED_URL).expect("should parse the fetched address"), + headers: HeaderMap::new(), + backend_name: "origin".to_owned(), + auth: None, + unsigned_headers: Vec::new(), + version: SourceVersion::new(ETAG_V1, to_u64(source().len())) + .expect("should pin the test version"), + reader_url: READER_URL.to_owned(), + content_type: "image/png".to_owned(), + references: LargeReferences::of(&identity(), false), + namespace: "policy-1".to_owned(), + budget: LargeBudget { + started: Instant::now(), + total: Duration::ZERO, + }, + keep_for: Duration::from_secs(600), + }; + + let loaded = block_on(load.run()); + + assert!( + matches!( + decode_kept_outcome(&loaded.bytes), + Some(KeptOutcome::Declined) + ), + "should keep an unsigned outcome" + ); + assert!(origin.requests().is_empty(), "should never start hashing"); + assert_eq!(signer.attempts(), 0, "should never start signing"); +} + +#[test] +fn a_timed_out_source_is_hashed_once_and_then_sent_as_it_came() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Hang); + let services = services_with_timer(&signer, &origin, Arc::new(ElapsedTimer::default())); + let claim = claim_for(&services, &Method::GET, &[]); + + let (large, response) = run(&claim, &services, whole()); + let (response, body) = collect(response); + assert!(large); + assert!( + signer.jobs().is_empty(), + "a signer out of time signs nothing" + ); + assert_eq!(body, Some(source())); + assert_eq!(header_of(&response, "link"), None); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V1)); + let reads = origin.requests().len(); + + let (_, response) = run(&claim, &services, whole()); + let (_, body) = collect(response); + assert_eq!(body, Some(source())); + assert_eq!( + signer.attempts(), + 1, + "a kept timeout should never call the signer again" + ); + assert_eq!( + origin.requests().len(), + reads, + "a kept timeout should never hash again" + ); +} + +#[test] +fn a_version_change_before_publication_signs_nothing_and_sends_the_current_version() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + // The hash reads the first version; the signer's confirmation and the + // fresh answer meet the second. + origin.flip_after(1, &other_source(), ETAG_V2); + let signer = LargeSigner::new(LargeBehavior::ConfirmThenSign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + + let (large, response) = run(&claim, &services, whole()); + let (response, body) = collect(response); + + assert!(large); + assert!(signer.jobs().is_empty(), "should publish nothing"); + assert_eq!(signer.failures(), vec!["source_version_changed"]); + assert_eq!( + response.status(), + StatusCode::OK, + "an unsigned answer is the origin's, not a refusal" + ); + assert_eq!( + body, + Some(other_source()), + "should send the version the origin now serves" + ); + assert_eq!( + header_of(&response, "etag"), + Some(ETAG_V2), + "should describe the bytes sent by their own validator" + ); + assert_eq!(header_of(&response, "link"), None); + assert_eq!(header_of(&response, "repr-digest"), None); +} + +#[test] +fn a_fresh_fallback_is_never_cached_and_keeps_both_security_policies() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let caller_policy = "img-src 'self'"; + let fresh_policy = "default-src 'none'"; + // The same version answers afresh with a shorter lifetime and a + // security policy of its own. + origin.flip_after_with( + 1, + &source(), + ETAG_V1, + vec![ + ( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=5"), + ), + ( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(fresh_policy), + ), + ], + ); + let signer = LargeSigner::new(LargeBehavior::Refuse); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let mut answer = whole(); + answer.headers_mut().insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(caller_policy), + ); + + let (_, response) = run(&claim, &services, answer); + let (response, body) = collect(response); + + assert_eq!(body, Some(source())); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V1)); + assert_eq!( + header_of(&response, "cache-control"), + Some(crate::cache_policy::NO_STORE_PRIVATE_CACHE_CONTROL), + "should keep neither the first answer's lifetime nor the fresh one's" + ); + let policies: Vec<_> = response + .headers() + .get_all(header::CONTENT_SECURITY_POLICY) + .iter() + .collect(); + assert_eq!( + policies, + vec![caller_policy, fresh_policy], + "should add the fresh policy to the caller's, never replace it" + ); +} + +#[test] +fn a_refused_source_that_changed_keeps_the_new_versions_provenance_and_privacy() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let origin_link = "; rel=\"c2pa-manifest\""; + // The hash reads the first version; the fresh answer meets a second + // that names a manifest of its own and may be held by no shared cache. + origin.flip_after_with( + 1, + &other_source(), + ETAG_V2, + vec![ + ( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ), + (header::LINK, HeaderValue::from_static(origin_link)), + ( + header::SET_COOKIE, + HeaderValue::from_static("session=reader-1"), + ), + ], + ); + let signer = LargeSigner::new(LargeBehavior::Refuse); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let mut answer = whole(); + answer.headers_mut().insert( + header::EXPIRES, + HeaderValue::from_static("Wed, 21 Oct 2026 07:28:00 GMT"), + ); + answer + .headers_mut() + .insert(header::AGE, HeaderValue::from_static("30")); + + let (_, response) = run(&claim, &services, answer); + let (response, body) = collect(response); + + assert_eq!(signer.attempts(), 1); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + body, + Some(other_source()), + "should send the version the origin now serves" + ); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V2)); + let links: Vec<_> = response.headers().get_all(header::LINK).iter().collect(); + assert_eq!( + links, + vec![origin_link], + "should keep the new version's manifest link" + ); + assert_eq!( + header_of(&response, "cache-control"), + Some(crate::cache_policy::NO_STORE_PRIVATE_CACHE_CONTROL), + "should never send the first version's shared cache policy with other bytes" + ); + assert_eq!(header_of(&response, "expires"), None); + assert_eq!(header_of(&response, "age"), None); + assert_eq!( + header_of(&response, "set-cookie"), + None, + "should pass on no cookie the caller's handling never saw" + ); + assert_eq!(header_of(&response, "repr-digest"), None); +} + +#[test] +fn a_fresh_fallback_keeps_redirect_challenge_and_retry_response_semantics() { + for (status, name, value) in [ + ( + StatusCode::TEMPORARY_REDIRECT, + header::LOCATION, + "/media/moved", + ), + ( + StatusCode::UNAUTHORIZED, + header::WWW_AUTHENTICATE, + "Bearer realm=\"media\"", + ), + (StatusCode::SERVICE_UNAVAILABLE, header::RETRY_AFTER, "60"), + ] { + let origin = RangeOrigin::new(&source(), ETAG_V1); + origin.flip_after_with( + 1, + &source(), + ETAG_V1, + vec![(name.clone(), HeaderValue::from_static(value))], + ); + origin + .state + .lock() + .expect("should lock origin") + .status_after = Some((1, status)); + let signer = LargeSigner::new(LargeBehavior::Refuse); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let (_, response) = run(&claim, &services, whole()); + let (response, _) = collect(response); + + assert_eq!(response.status(), status); + assert_eq!( + response.headers().get(&name), + Some(&HeaderValue::from_static(value)) + ); + assert_eq!( + header_of(&response, "cache-control"), + Some(crate::cache_policy::NO_STORE_PRIVATE_CACHE_CONTROL), + ); + assert_eq!(signer.attempts(), 1); + assert_eq!( + origin.requests().len(), + 2, + "should not follow or retry the fresh response" + ); + } +} + +#[test] +fn a_fresh_publisher_redirect_uses_the_existing_origin_url_mapping_without_discarded_cookies() { + for (location, expected) in [ + ( + "https://origin.example.com/media/moved", + "https://www.example.com/media/moved", + ), + (FETCHED_URL, FETCHED_URL), + ] { + let origin = RangeOrigin::new(&source(), ETAG_V1); + origin.flip_after_with( + 1, + &source(), + ETAG_V1, + vec![ + (header::LOCATION, HeaderValue::from_static(location)), + ( + header::SET_COOKIE, + HeaderValue::from_static("session=new; Path=/"), + ), + ( + header::CONTENT_LOCATION, + HeaderValue::from_static("https://origin.example.com/media/fresh"), + ), + ], + ); + origin + .state + .lock() + .expect("should lock origin") + .status_after = Some((1, StatusCode::TEMPORARY_REDIRECT)); + let signer = LargeSigner::new(LargeBehavior::Refuse); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let publisher = TestOrigin { + response_header_rewrite: Some(crate::response_header_rewrite::OriginHeaderRewrite { + origin_host: "origin.example.com", + origin_host_header: "origin.example.com", + origin_path: "", + request_host: "www.example.com", + request_scheme: "https", + origin_scheme: "https", + request_path_and_query: PATH, + request_is_get_or_head: true, + }), + ..TestOrigin::plain() + }; + let mut initial = whole(); + initial + .headers_mut() + .insert(header::CONTENT_LOCATION, HeaderValue::from_static("/stale")); + let (_, response) = run_through(&claim, &services, &publisher, initial); + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response.headers().get(header::LOCATION), + Some(&HeaderValue::from_static(expected)), + "a discarded cookie must not justify redirecting to the same serving URL", + ); + assert!(!response.headers().contains_key(header::SET_COOKIE)); + assert_eq!( + response.headers().get(header::CONTENT_LOCATION), + Some(&HeaderValue::from_static( + "https://www.example.com/media/fresh" + )), + ); + } +} + +#[test] +fn a_version_change_after_signing_is_refused_rather_than_mixed() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + // The hash reads the first version; the signed delivery meets the + // second. + origin.flip_after(1, &other_source(), ETAG_V2); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + + let (_, response) = run(&claim, &services, whole()); + let (response, body) = collect(response); + + assert_eq!(signer.jobs().len(), 1); + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "should never send other bytes under the signed version's headers" + ); + assert_eq!(header_of(&response, "link"), None); + assert_eq!(body, Some(Vec::new())); +} + +#[test] +fn a_cut_hash_read_signs_nothing_and_is_never_resumed() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + origin.cut_next_at(23); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + + let (large, response) = run(&claim, &services, whole()); + let (response, body) = collect(response); + + assert!(large); + assert_eq!(signer.attempts(), 0, "should sign nothing from a cut read"); + assert_eq!( + origin.ranges(), + vec![Some((0, 63)), None], + "should neither resume nor restart the hash, only ask the origin afresh once as the reader asked" + ); + assert_eq!( + body, + Some(source()), + "should deliver the origin's fresh answer" + ); + assert_eq!( + header_of(&response, "link"), + None, + "should carry no manifest" + ); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V1)); + + let (_, response) = run(&claim, &services, whole()); + let (_, body) = collect(response); + assert_eq!(body, Some(source())); + assert_eq!( + origin.requests().len(), + 2, + "a kept failure should never hash again" + ); + + let origin = RangeOrigin::new(&source(), ETAG_V1); + origin.cut_next_at(23); + origin.flip_after(1, &other_source(), ETAG_V2); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let (_, response) = run(&claim, &services, whole()); + let (response, body) = collect(response); + assert_eq!(signer.attempts(), 0); + assert_eq!( + response.status(), + StatusCode::OK, + "an unsigned answer is the origin's, not a refusal" + ); + assert_eq!(body, Some(other_source())); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V2)); + assert_eq!(header_of(&response, "link"), None); +} + +#[test] +fn a_source_no_public_address_names_is_never_hashed() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let queried = TestOrigin { + identity: VariantIdentity { + fetched_url: QUERY_FETCHED_URL, + reader_query_free: false, + ..identity() + }, + ..TestOrigin::plain() + }; + + for _ in 0..2 { + let (_, response) = run_through(&claim, &services, &queried, whole()); + let (response, body) = collect(response); + assert_eq!( + body, + Some(source()), + "should send the origin's answer as it came" + ); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V1)); + assert_eq!(header_of(&response, "link"), None); + } + assert!( + origin.requests().is_empty(), + "should never read a source no public address names" + ); + assert_eq!(signer.attempts(), 0); + + let reader_named = TestOrigin { + identity: VariantIdentity { + fetched_url: QUERY_FETCHED_URL, + ..identity() + }, + ..TestOrigin::plain() + }; + let _ = collect(run_through(&claim, &services, &reader_named, whole()).1); + let jobs = signer.jobs(); + assert_eq!( + jobs[0].source_reference, None, + "should never name a fetched address with a query" + ); + assert_eq!( + jobs[0].reader_reference.as_deref(), + Some(READER_URL), + "should name the reader's query-free address" + ); +} + +#[test] +fn a_signed_origin_address_is_never_named_and_its_reads_carry_no_reader_header() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::ConfirmThenSign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, HeaderValue::from_static("origin.example.com")); + headers.insert( + header::REFERER, + HeaderValue::from_static("https://www.example.com/page"), + ); + headers.insert( + header::IF_NONE_MATCH, + HeaderValue::from_static("\"reader\""), + ); + headers.insert("x-test-origin-key", HeaderValue::from_static("from-reader")); + let signed = TestOrigin { + headers, + auth: Some(OriginSigning::new(Arc::new(TestOriginSigner), None)), + unsigned_headers: vec![header::REFERER], + ..TestOrigin::plain() + }; + + let (_, response) = run_through(&claim, &services, &signed, whole()); + let (response, body) = collect(response); + + let jobs = signer.jobs(); + assert_eq!( + jobs[0].source_reference, None, + "should never name an address core signs" + ); + assert_eq!(jobs[0].reader_reference.as_deref(), Some(READER_URL)); + assert_eq!(body, Some(source())); + assert_eq!(header_of(&response, "link"), Some(MANIFEST_LINK)); + let requests = origin.requests(); + assert_eq!(requests.len(), 3, "should hash, confirm and deliver"); + for request in &requests { + assert_eq!( + request + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(ORIGIN_SIGNATURE), + "should sign every pinned read afresh" + ); + assert!( + request.get(header::REFERER).is_none(), + "should send no reader header a signed read must not carry" + ); + assert!( + request.get(header::IF_NONE_MATCH).is_none(), + "should send none of the reader's conditions" + ); + assert!( + request.get("x-test-origin-key").is_none(), + "should send no header in the signer's namespace" + ); + assert_eq!( + request + .get(header::IF_MATCH) + .and_then(|value| value.to_str().ok()), + Some(ETAG_V1) + ); + } + + let queried_reader = TestOrigin { + identity: VariantIdentity { + reader_query_free: false, + ..identity() + }, + auth: Some(OriginSigning::new(Arc::new(TestOriginSigner), None)), + ..TestOrigin::plain() + }; + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let _ = collect(run_through(&claim, &services, &queried_reader, whole()).1); + assert!( + origin.requests().is_empty(), + "should decline before hashing when only the signed address could name the source" + ); + assert_eq!(signer.attempts(), 0); +} + +#[test] +fn an_answer_whose_outcome_cannot_be_kept_is_never_hashed() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + for cache_control in ["no-cache", "public"] { + let mut answer = whole(); + answer.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control), + ); + let (large, response) = run(&claim, &services, answer); + let (_, body) = collect(response); + assert!(!large, "{cache_control}: should not enter the large lane"); + assert_eq!(body, Some(source())); + } + + let keeps_nothing = + build_services_with_http_client(Arc::clone(&origin) as Arc) + .with_media_signer(Some(Arc::clone(&signer) as Arc)) + .with_timer(Arc::new(StoppedTimer)); + let claim = claim_for(&keeps_nothing, &Method::GET, &[]); + let (large, response) = run(&claim, &keeps_nothing, whole()); + let (_, body) = collect(response); + assert!( + !large, + "should not enter the large lane where nothing is kept" + ); + assert_eq!(body, Some(source())); + + assert!( + origin.requests().is_empty(), + "should never read the origin again" + ); + assert_eq!(signer.attempts(), 0); +} + +#[test] +fn an_origin_manifest_link_is_kept_rather_than_competed_with() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let origin_link = "; rel=\"c2pa-manifest\""; + let mut answer = whole(); + answer + .headers_mut() + .insert(header::LINK, HeaderValue::from_static(origin_link)); + + let (large, response) = run(&claim, &services, answer); + let (response, body) = collect(response); + + assert!( + !large, + "should not sign a source that names its own manifest" + ); + assert_eq!(body, Some(source())); + let links: Vec<_> = response.headers().get_all(header::LINK).iter().collect(); + assert_eq!( + links, + vec![origin_link], + "should keep only the origin's manifest link" + ); + assert!(origin.requests().is_empty()); + assert_eq!(signer.attempts(), 0); +} + +#[test] +fn a_warm_not_modified_naming_an_origin_manifest_gets_no_kept_outcome() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let _ = collect(run(&claim, &services, whole()).1); + assert_eq!( + signer.jobs().len(), + 1, + "should keep a signed outcome for the version" + ); + + let (_, plain) = run(&claim, &services, not_modified(ETAG_V1)); + assert_eq!( + header_of(&plain, "link"), + Some(MANIFEST_LINK), + "the kept outcome decorates a 304 without a link of the origin's" + ); + + let origin_link = "; rel=\"c2pa-manifest\""; + let mut linked = not_modified(ETAG_V1); + linked + .headers_mut() + .insert(header::LINK, HeaderValue::from_static(origin_link)); + let (_, response) = run(&claim, &services, linked); + + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + let links: Vec<_> = response.headers().get_all(header::LINK).iter().collect(); + assert_eq!( + links, + vec![origin_link], + "should keep the origin's manifest link alone" + ); + assert_eq!(header_of(&response, "repr-digest"), None); + assert_eq!(header_of(&response, "etag"), Some(ETAG_V1)); + assert_eq!(signer.attempts(), 1, "should never sign again"); +} + +#[test] +fn a_source_without_a_strong_tag_is_never_given_a_version() { + for etag in ["W/\"v1\"", ""] { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let claim = claim_for(&services, &Method::GET, &[]); + let mut answer = whole(); + if etag.is_empty() { + answer.headers_mut().remove(header::ETAG); + } else { + answer + .headers_mut() + .insert(header::ETAG, HeaderValue::from_static("W/\"v1\"")); + } + + let (large, response) = run(&claim, &services, answer); + let (response, body) = collect(response); + + assert!(!large, "{etag:?}: should not enter the large lane"); + assert_eq!(signer.attempts(), 0); + assert!( + origin.requests().is_empty(), + "{etag:?}: should not ask the origin again" + ); + assert_eq!( + body, + Some(source()), + "{etag:?}: should send the origin's answer as it came" + ); + assert_eq!(header_of(&response, "link"), None); + } +} + +#[test] +fn a_pinned_read_refuses_another_version_or_range() { + let origin = RangeOrigin::new(&source(), ETAG_V1); + let signer = LargeSigner::new(LargeBehavior::Sign); + let services = services_for(&signer, &origin); + let url = url::Url::parse(FETCHED_URL).expect("should parse the fetched address"); + let headers = HeaderMap::new(); + let version = SourceVersion::new(ETAG_V1, 64).expect("should take a strong tag"); + let reader = VersionedMediaSource::new(&services, &url, &headers, "origin", version); + + let mut part = [0_u8; 8]; + block_on(reader.read_exact_at(8, &mut part)).expect("should read the pinned range"); + assert_eq!(part.to_vec(), source()[8..16].to_vec()); + assert_eq!( + block_on(reader.read_exact_at(60, &mut part)), + Err(MediaSourceError::OutOfBounds), + "should refuse a read past the version before asking" + ); + + origin.answer_wrong_content_range(); + assert_eq!( + block_on(reader.read_exact_at(8, &mut part)), + Err(MediaSourceError::InvalidAnswer { + reason: "pinned_read_content_range" + }) + ); + + origin.flip_after(0, &other_source(), ETAG_V2); + assert_eq!( + block_on(reader.confirm_current()), + Err(MediaSourceError::VersionChanged) + ); + assert!( + SourceVersion::new("W/\"v1\"", 64).is_none(), + "a weak tag names no version" + ); +} diff --git a/crates/trusted-server-core/src/media_signing/test_support.rs b/crates/trusted-server-core/src/media_signing/test_support.rs new file mode 100644 index 0000000000..f6e5ddb075 --- /dev/null +++ b/crates/trusted-server-core/src/media_signing/test_support.rs @@ -0,0 +1,164 @@ +//! A [`MediaSigner`] for core's tests, which records each job and appends a +//! fixed suffix to the source in place of a manifest. + +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use http::{HeaderName, HeaderValue, header}; + +use super::{ + MediaJob, MediaNamespacing, MediaSigner, MediaSigning, MediaSigningFailure, SignedMedia, +}; +use crate::platform::RuntimeServices; + +/// What the stub's signing appends to the source. +pub(crate) const SIGNED_SUFFIX: &[u8] = b"|signed-manifest"; + +/// The header the stub writes on a signed representation. +pub(crate) const MANIFEST_HEADER: &str = "x-test-manifest"; + +/// The `Link` the stub's [`Behavior::SignWithLink`] writes. +pub(crate) const MANIFEST_LINK: &str = "; rel=\"c2pa-manifest\""; + +/// How the stub answers a job. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Behavior { + /// Returns the source with [`SIGNED_SUFFIX`] appended. + Sign, + /// Returns a failure. + Fail, + /// Returns signed bytes with a header core owns. + ReservedHeader, + /// Signs as [`Behavior::Sign`] and adds a manifest [`MANIFEST_LINK`]. + SignWithLink, +} + +/// One job the stub was handed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RecordedJob { + pub(crate) reader_url: String, + pub(crate) content_type: String, + pub(crate) source: Vec, + pub(crate) source_sha256: String, + pub(crate) source_reference: Option, + pub(crate) source_origin_host: Option, + pub(crate) optimizer_output: bool, +} + +/// Leaves `/api/` paths alone and signs every `image/` type. +pub(crate) struct StubSigner { + behavior: Behavior, + limit: usize, + jobs: Mutex>, + namespace: Mutex>, +} + +impl StubSigner { + pub(crate) fn new(behavior: Behavior, limit: usize) -> Arc { + Arc::new(Self { + behavior, + limit, + jobs: Mutex::new(Vec::new()), + namespace: Mutex::new(Ok("policy-1".to_owned())), + }) + } + + /// Makes [`MediaSigner::namespace`] answer `namespace` from now on, as a + /// changed or revoked policy would. + pub(crate) fn set_namespace(&self, namespace: Result) { + *self.namespace.lock().expect("should lock namespace") = namespace; + } + + pub(crate) fn jobs(&self) -> Vec { + self.jobs.lock().expect("should lock jobs").clone() + } + + /// `services` carrying this signer, as an adapter hands it in. + pub(crate) fn installed_in(self: &Arc, services: RuntimeServices) -> RuntimeServices { + services.with_media_signer(Some(Arc::clone(self) as Arc)) + } +} + +impl MediaSigner for StubSigner { + fn signer_id(&self) -> &'static str { + "testing.media" + } + + fn handles_path(&self, path: &str) -> bool { + !path.starts_with("/api/") + } + + fn handles_media_type(&self, media_type: &str) -> bool { + media_type.starts_with("image/") + } + + fn max_source_bytes(&self) -> usize { + self.limit + } + + fn namespace<'a>(&'a self, _services: &'a RuntimeServices) -> MediaNamespacing<'a> { + let namespace = self + .namespace + .lock() + .expect("should lock namespace") + .clone(); + Box::pin(async move { namespace }) + } + + fn sign<'a>( + &'a self, + _services: &'a RuntimeServices, + job: &'a MediaJob<'a>, + ) -> MediaSigning<'a> { + Box::pin(async move { + self.jobs + .lock() + .expect("should lock jobs") + .push(RecordedJob { + reader_url: job.reader_url.to_owned(), + content_type: job.content_type.to_owned(), + source: job.source.to_vec(), + source_sha256: job.source_sha256.to_owned(), + source_reference: job.source_reference.map(str::to_owned), + source_origin_host: job.source_origin_host.map(str::to_owned), + optimizer_output: job.optimizer_output, + }); + // Signs under the namespace current when it signs, as a real + // signer reports the policy it used. + let namespace = self + .namespace + .lock() + .expect("should lock namespace") + .clone() + .unwrap_or_default(); + let mut bytes = job.source.to_vec(); + bytes.extend_from_slice(SIGNED_SUFFIX); + match self.behavior { + Behavior::Fail => Err(MediaSigningFailure { + reason: "stub_failure", + }), + Behavior::Sign => Ok(SignedMedia { + bytes: Bytes::from(bytes), + content_type: HeaderValue::from_static("image/png"), + headers: vec![( + HeaderName::from_static(MANIFEST_HEADER), + HeaderValue::from_static("instance-1"), + )], + namespace, + }), + Behavior::SignWithLink => Ok(SignedMedia { + bytes: Bytes::from(bytes), + content_type: HeaderValue::from_static("image/png"), + headers: vec![(header::LINK, HeaderValue::from_static(MANIFEST_LINK))], + namespace, + }), + Behavior::ReservedHeader => Ok(SignedMedia { + bytes: Bytes::from(bytes), + content_type: HeaderValue::from_static("image/png"), + headers: vec![(header::ETAG, HeaderValue::from_static("\"forged\""))], + namespace, + }), + } + }) + } +} diff --git a/crates/trusted-server-core/src/media_signing/tests.rs b/crates/trusted-server-core/src/media_signing/tests.rs new file mode 100644 index 0000000000..da92c92291 --- /dev/null +++ b/crates/trusted-server-core/src/media_signing/tests.rs @@ -0,0 +1,1544 @@ +use std::sync::Arc; + +use base64::Engine as _; +use bytes::Bytes; +use edgezero_core::body::Body as EdgeBody; +use futures::StreamExt as _; +use http::{HeaderMap, HeaderName, HeaderValue, Method, Response, StatusCode, header}; +use sha2::{Digest as _, Sha256}; + +use super::test_support::{Behavior, MANIFEST_LINK, SIGNED_SUFFIX, StubSigner}; +use super::*; +use crate::platform::test_support::{MemoryLoadingCache, noop_services}; +use crate::test_support::tests::create_test_settings; + +const SOURCE: &[u8] = b"origin-image-bytes"; +const PATH: &str = "/media/42"; +const FETCHED_URL: &str = "https://origin.example.com/media/42"; +const READER_URL: &str = "https://www.example.com/media/42"; +const ORIGIN_IDENTITY: &str = "https://origin.example.com\0origin.example.com"; +const ORIGIN_ETAG: &str = "\"origin-etag\""; + +fn identity() -> VariantIdentity<'static> { + VariantIdentity { + reader_url: READER_URL, + origin_identity: ORIGIN_IDENTITY, + fetched_url: FETCHED_URL, + reader_query_free: true, + } +} + +fn signed_bytes() -> Vec { + let mut bytes = SOURCE.to_vec(); + bytes.extend_from_slice(SIGNED_SUFFIX); + bytes +} + +fn signed_etag() -> String { + format!("\"sig-{}\"", hex::encode(Sha256::digest(signed_bytes()))) +} + +fn request_headers(pairs: &[(HeaderName, &str)]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for (name, value) in pairs { + headers.append( + name.clone(), + HeaderValue::from_str(value).expect("should build header"), + ); + } + headers +} + +/// The headers of a browser asking for an image, which may be answered +/// from a kept copy. +fn image_request(pairs: &[(HeaderName, &str)]) -> HeaderMap { + let mut headers = request_headers(pairs); + headers.insert("sec-fetch-dest", HeaderValue::from_static("image")); + headers +} + +fn origin_response( + status: StatusCode, + body: EdgeBody, + extra: &[(HeaderName, &str)], +) -> Response { + let mut builder = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "image/png") + .header(header::ETAG, ORIGIN_ETAG) + .header(header::CACHE_CONTROL, "public, max-age=600"); + for (name, value) in extra { + builder = builder.header(name.clone(), *value); + } + builder.body(body).expect("should build origin response") +} + +fn whole_origin() -> Response { + origin_response(StatusCode::OK, EdgeBody::from(SOURCE.to_vec()), &[]) +} + +fn part_origin() -> Response { + origin_response( + StatusCode::PARTIAL_CONTENT, + EdgeBody::from(SOURCE[0..=3].to_vec()), + &[(header::CONTENT_RANGE, "bytes 0-3/18")], + ) +} + +async fn body_bytes(body: EdgeBody) -> Vec { + match body { + EdgeBody::Once(bytes) => bytes.to_vec(), + EdgeBody::Stream(mut stream) => { + let mut collected = Vec::new(); + while let Some(chunk) = stream.next().await { + collected.extend_from_slice(&chunk.expect("should read stream chunk")); + } + collected + } + } +} + +fn header<'a>(response: &'a Response, name: &str) -> Option<&'a str> { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) +} + +fn claim_shared( + services: &RuntimeServices, + method: &Method, + headers: &HeaderMap, + shared: bool, +) -> MediaClaim { + MediaClaim::of_request( + &create_test_settings(), + services, + method, + PATH, + headers, + shared, + ) + .expect("should claim a media path") +} + +fn claim(services: &RuntimeServices, method: &Method, headers: &HeaderMap) -> MediaClaim { + claim_shared(services, method, headers, true) +} + +fn keeping(signer: &Arc) -> RuntimeServices { + signer + .installed_in(noop_services()) + .with_loading_cache(Arc::new(MemoryLoadingCache::default())) +} + +fn split(response: Response) -> (Response, Vec) { + let (parts, body) = response.into_parts(); + let bytes = futures::executor::block_on(body_bytes(body)); + (Response::from_parts(parts, EdgeBody::empty()), bytes) +} + +/// What `deliver` decides for the origin's `response`, with the facts its +/// own headers give. +fn delivery( + claim: &MediaClaim, + services: &RuntimeServices, + response: Response, +) -> Delivery { + let facts = OriginFacts::of(response.headers()); + futures::executor::block_on(claim.deliver( + services, + &identity(), + Answered::Origin(facts), + response, + )) +} + +/// Delivers the origin's `response` to a reader whose request is `method` +/// with `headers`, expecting core to send it without a second fetch. +fn deliver( + signer: &Arc, + method: &Method, + headers: &HeaderMap, + response: Response, +) -> (Response, Vec) { + let services = signer.installed_in(noop_services()); + let claim = claim(&services, method, headers); + let Delivery::Send(response) = delivery(&claim, &services, response) else { + panic!("should send without asking the origin again"); + }; + split(response) +} + +#[test] +fn signs_the_exact_captured_bytes_and_owns_the_validators() { + let signer = StubSigner::new(Behavior::Sign, 1024); + + let (response, body) = deliver(&signer, &Method::GET, &HeaderMap::new(), whole_origin()); + + assert_eq!(response.status(), StatusCode::OK, "should answer 200"); + assert_eq!( + body, + signed_bytes(), + "should send the signer's bytes unchanged" + ); + let jobs = signer.jobs(); + assert_eq!(jobs.len(), 1, "should ask the signer once"); + assert_eq!( + jobs[0].source, SOURCE, + "should hand the signer the exact origin bytes" + ); + assert_eq!(jobs[0].source_sha256, hex::encode(Sha256::digest(SOURCE))); + assert_eq!( + jobs[0].reader_url, READER_URL, + "should name the reader's address" + ); + assert_eq!( + jobs[0].content_type, "image/png", + "should pass the origin type" + ); + assert_eq!(jobs[0].source_reference.as_deref(), Some(FETCHED_URL)); + assert_eq!( + jobs[0].source_origin_host.as_deref(), + Some("origin.example.com") + ); + assert_eq!(header(&response, "etag"), Some(signed_etag().as_str())); + assert_eq!( + header(&response, "repr-digest"), + Some( + format!( + "sha-256=:{}:", + base64::engine::general_purpose::STANDARD.encode(Sha256::digest(signed_bytes())) + ) + .as_str() + ), + "should describe the signed representation's digest" + ); + assert_eq!( + header(&response, "content-length"), + Some(signed_bytes().len().to_string().as_str()), + "should frame the signed length" + ); + assert_eq!( + header(&response, "accept-ranges"), + Some("none"), + "a signed representation nothing keeps offers no ranges" + ); + assert_eq!(header(&response, "x-test-manifest"), Some("instance-1")); + assert_eq!( + header(&response, "cache-control"), + Some("public, max-age=600") + ); +} + +#[test] +fn merges_the_signers_link_with_the_origins_links() { + let signer = StubSigner::new(Behavior::SignWithLink, 1024); + let origin_link = "; rel=preload; as=style"; + + let (response, _) = deliver( + &signer, + &Method::GET, + &HeaderMap::new(), + origin_response( + StatusCode::OK, + EdgeBody::from(SOURCE.to_vec()), + &[(header::LINK, origin_link)], + ), + ); + + let links: Vec<&str> = response + .headers() + .get_all(header::LINK) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect(); + assert_eq!( + links, + vec![origin_link, MANIFEST_LINK], + "should keep the origin's link and add the manifest's" + ); +} + +#[test] +fn names_the_origin_host_even_where_the_reference_is_withheld() { + for (fetched_url, host) in [ + ( + "https://origin.example.com/media/42?signature=secret", + "origin.example.com", + ), + ( + "https://user:pass@Origin.Example.com/media/42", + "origin.example.com", + ), + ( + "https://origin.example.com:8443/media/42#part", + "origin.example.com", + ), + ] { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = signer.installed_in(noop_services()); + let claim = claim(&services, &Method::GET, &HeaderMap::new()); + let response = whole_origin(); + let facts = OriginFacts::of(response.headers()); + futures::executor::block_on(claim.deliver( + &services, + &VariantIdentity { + fetched_url, + ..identity() + }, + Answered::Origin(facts), + response, + )); + let job = &signer.jobs()[0]; + assert_eq!( + job.source_reference, None, + "should send no reference for {fetched_url}" + ); + assert_eq!( + job.source_origin_host.as_deref(), + Some(host), + "should name the host of {fetched_url}" + ); + } +} + +#[test] +fn answers_a_single_range_from_the_signed_bytes() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let total = signed_bytes().len(); + + let (response, body) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::RANGE, "bytes=2-5")]), + whole_origin(), + ); + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + body, + signed_bytes()[2..=5], + "should send the asked bytes of the signed body" + ); + assert_eq!( + header(&response, "content-range"), + Some(format!("bytes 2-5/{total}").as_str()) + ); + assert_eq!(header(&response, "content-length"), Some("4")); +} + +#[test] +fn answers_suffix_and_open_ranges_from_the_signed_bytes() { + let total = signed_bytes().len(); + for (range, first) in [("bytes=-4", total - 4), ("bytes=10-", 10)] { + let signer = StubSigner::new(Behavior::Sign, 1024); + let (response, body) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::RANGE, range)]), + whole_origin(), + ); + assert_eq!( + response.status(), + StatusCode::PARTIAL_CONTENT, + "{range} should be partial" + ); + assert_eq!( + body, + signed_bytes()[first..], + "{range} should send the tail" + ); + assert_eq!( + header(&response, "content-range"), + Some(format!("bytes {first}-{}/{total}", total - 1).as_str()) + ); + } +} + +#[test] +fn refuses_a_range_past_the_signed_length() { + let signer = StubSigner::new(Behavior::Sign, 1024); + + let (response, body) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::RANGE, "bytes=1000-")]), + whole_origin(), + ); + + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert!(body.is_empty(), "should send no body with a 416"); + assert_eq!( + header(&response, "content-range"), + Some(format!("bytes */{}", signed_bytes().len()).as_str()) + ); +} + +#[test] +fn answers_several_ranges_and_malformed_ranges_with_the_whole_signed_body() { + for range in ["bytes=0-1,4-5", "bytes=5-2", "items=0-1", "bytes=+1-2"] { + let signer = StubSigner::new(Behavior::Sign, 1024); + let (response, body) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::RANGE, range)]), + whole_origin(), + ); + assert_eq!( + response.status(), + StatusCode::OK, + "{range} should be answered whole" + ); + assert_eq!(body, signed_bytes()); + } +} + +#[test] +fn honors_if_range_only_for_the_signed_entity_tag() { + let signed = signed_etag(); + for (if_range, expected) in [ + (signed.as_str(), StatusCode::PARTIAL_CONTENT), + (ORIGIN_ETAG, StatusCode::OK), + ("Tue, 01 Sep 2026 00:00:00 GMT", StatusCode::OK), + ] { + let signer = StubSigner::new(Behavior::Sign, 1024); + let (response, _) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::RANGE, "bytes=0-3"), (header::IF_RANGE, if_range)]), + whole_origin(), + ); + assert_eq!(response.status(), expected, "If-Range {if_range}"); + } +} + +#[test] +fn tells_a_precondition_naming_signed_bytes_from_one_naming_the_origins() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = signer.installed_in(noop_services()); + let signed = signed_etag(); + for (headers, names_signed) in [ + ( + request_headers(&[ + (header::RANGE, "bytes=0-3"), + (header::IF_RANGE, signed.as_str()), + ]), + true, + ), + ( + request_headers(&[(header::IF_MATCH, format!("\"other\", {signed}").as_str())]), + true, + ), + ( + request_headers(&[ + (header::RANGE, "bytes=0-3"), + (header::IF_RANGE, ORIGIN_ETAG), + ]), + false, + ), + ( + request_headers(&[(header::IF_NONE_MATCH, signed.as_str())]), + false, + ), + ( + request_headers(&[(header::IF_MATCH, "W/\"sig-00\"")]), + false, + ), + ] { + assert_eq!( + claim(&services, &Method::GET, &headers).names_signed_bytes(), + names_signed, + "{headers:?}" + ); + } +} + +#[test] +fn answers_if_none_match_against_the_signed_tag_only() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let (response, body) = deliver( + &signer, + &Method::GET, + &request_headers(&[( + header::IF_NONE_MATCH, + format!("\"other\", W/{}", signed_etag()).as_str(), + )]), + whole_origin(), + ); + assert_eq!( + response.status(), + StatusCode::NOT_MODIFIED, + "a weak match of the signed tag is a 304" + ); + assert!(body.is_empty(), "a 304 carries no body"); + assert_eq!(header(&response, "content-length"), None); + assert_eq!(header(&response, "etag"), Some(signed_etag().as_str())); + assert_eq!( + header(&response, "x-test-manifest"), + Some("instance-1"), + "a 304 carries the signer's headers" + ); + + let signer = StubSigner::new(Behavior::Sign, 1024); + let (response, body) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::IF_NONE_MATCH, ORIGIN_ETAG)]), + whole_origin(), + ); + assert_eq!( + response.status(), + StatusCode::OK, + "the origin's tag never matches signed bytes" + ); + assert_eq!(body, signed_bytes()); +} + +#[test] +fn fails_if_match_that_names_another_representation() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let (response, body) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::IF_MATCH, ORIGIN_ETAG)]), + whole_origin(), + ); + assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED); + assert!(body.is_empty()); + + let signer = StubSigner::new(Behavior::Sign, 1024); + let (response, _) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::IF_MATCH, signed_etag().as_str())]), + whole_origin(), + ); + assert_eq!( + response.status(), + StatusCode::OK, + "the signed tag satisfies If-Match" + ); +} + +#[test] +fn signs_the_whole_behind_a_part_that_can_be_kept_and_ranges_the_signed_bytes() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = keeping(&signer); + let headers = image_request(&[(header::RANGE, "bytes=0-3")]); + let claim = claim(&services, &Method::GET, &headers); + + let Delivery::FetchWhole(fallback) = delivery(&claim, &services, part_origin()) else { + panic!("should ask for the whole representation of a part it can keep"); + }; + assert!(signer.jobs().is_empty(), "a part is never signed"); + + let delivered = futures::executor::block_on(claim.deliver_whole( + &services, + &identity(), + whole_origin(), + fallback, + )); + let (response, body) = split(delivered); + assert_eq!( + signer.jobs()[0].source, + SOURCE, + "should sign the whole answer's exact bytes" + ); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + body, + signed_bytes()[0..=3], + "should range the signed bytes, not the origin's" + ); + assert_eq!(header(&response, "etag"), Some(signed_etag().as_str())); + assert_eq!( + header(&response, "accept-ranges"), + Some("bytes"), + "a kept representation offers ranges" + ); +} + +#[test] +fn passes_a_part_through_where_nothing_would_keep_the_signed_whole() { + let cases: [(RuntimeServices, Response, &str); 3] = [ + ( + StubSigner::new(Behavior::Sign, 1024).installed_in(noop_services()), + part_origin(), + "no cache keeps values", + ), + ( + keeping(&StubSigner::new(Behavior::Sign, 1024)), + origin_response( + StatusCode::PARTIAL_CONTENT, + EdgeBody::from(SOURCE[0..=3].to_vec()), + &[ + (header::CONTENT_RANGE, "bytes 0-3/18"), + (header::CACHE_CONTROL, "no-cache"), + ], + ), + "the origin gave no shared freshness", + ), + ( + keeping(&StubSigner::new(Behavior::Sign, 1024)), + origin_response( + StatusCode::PARTIAL_CONTENT, + EdgeBody::from(b"part".to_vec()), + &[(header::CONTENT_RANGE, "bytes 0-3/50000000")], + ), + "the whole is over the limit", + ), + ]; + for (services, part, case) in cases { + let claim = claim( + &services, + &Method::GET, + &image_request(&[(header::RANGE, "bytes=0-3")]), + ); + let Delivery::Send(response) = delivery(&claim, &services, part) else { + panic!("{case}: should not read the whole origin for a part"); + }; + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT, "{case}"); + assert_eq!( + header(&response, "etag"), + Some(ORIGIN_ETAG), + "{case}: keeps the origin's part" + ); + } +} + +#[test] +fn asks_for_the_signed_tail_the_origin_refused_as_past_its_own_length() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = signer.installed_in(noop_services()); + let tail = signed_bytes().len() - 2; + let range = format!("bytes={tail}-"); + let headers = request_headers(&[(header::RANGE, range.as_str())]); + let claim = claim(&services, &Method::GET, &headers); + let refusal = origin_response( + StatusCode::RANGE_NOT_SATISFIABLE, + EdgeBody::empty(), + &[(header::CONTENT_RANGE, "bytes */18")], + ); + + let Delivery::FetchWhole(fallback) = delivery(&claim, &services, refusal) else { + panic!("a range past the origin's length may name the signed tail"); + }; + let delivered = futures::executor::block_on(claim.deliver_whole( + &services, + &identity(), + whole_origin(), + fallback, + )); + let (response, body) = split(delivered); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(body, signed_bytes()[tail..], "should send the signed tail"); +} + +#[test] +fn sends_the_origins_part_without_validators_when_the_whole_fetch_cannot_be_signed() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = keeping(&signer); + let claim = claim( + &services, + &Method::GET, + &image_request(&[(header::RANGE, "bytes=0-3")]), + ); + + let mut missing = whole_origin(); + *missing.status_mut() = StatusCode::NOT_FOUND; + let delivered = futures::executor::block_on(claim.deliver_whole( + &services, + &identity(), + missing, + part_origin(), + )); + let (response, body) = split(delivered); + assert_eq!( + response.status(), + StatusCode::PARTIAL_CONTENT, + "should keep the origin's part" + ); + assert_eq!(body, SOURCE[0..=3]); + assert_eq!( + header(&response, "etag"), + None, + "an unsigned fallback carries no validator to pin it" + ); + assert_eq!( + header(&response, "cache-control"), + Some("private, max-age=60") + ); + assert!(signer.jobs().is_empty()); +} + +#[test] +fn answers_head_from_the_signed_whole_with_no_body() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = keeping(&signer); + let claim = claim(&services, &Method::HEAD, &image_request(&[])); + let head = origin_response( + StatusCode::OK, + EdgeBody::empty(), + &[(header::CONTENT_LENGTH, "18")], + ); + + let Delivery::FetchWhole(fallback) = delivery(&claim, &services, head) else { + panic!("a HEAD of a bounded type that can be kept should ask for the whole body"); + }; + let delivered = futures::executor::block_on(claim.deliver_whole( + &services, + &identity(), + whole_origin(), + fallback, + )); + let (response, body) = split(delivered); + assert_eq!(response.status(), StatusCode::OK); + assert!(body.is_empty(), "a HEAD carries no body"); + assert_eq!( + header(&response, "content-length"), + Some(signed_bytes().len().to_string().as_str()), + "a HEAD advertises the signed length" + ); + assert_eq!(header(&response, "etag"), Some(signed_etag().as_str())); +} + +#[test] +fn never_signs_a_response_the_origin_marked_as_one_readers() { + let private_markers: [(HeaderName, &str); 4] = [ + (header::SET_COOKIE, "session=1"), + (header::CACHE_CONTROL, "private, max-age=60"), + (header::VARY, "Accept, Cookie"), + (header::VARY, "*"), + ]; + for (name, value) in private_markers { + let signer = StubSigner::new(Behavior::Sign, 1024); + let mut response = whole_origin(); + response + .headers_mut() + .insert(name.clone(), HeaderValue::from_static(value)); + let (response, body) = deliver(&signer, &Method::GET, &HeaderMap::new(), response); + assert!( + signer.jobs().is_empty(), + "{name}: {value} should not reach the signer" + ); + assert_eq!( + body, SOURCE, + "{name}: {value} should go out as the origin sent it" + ); + assert_eq!(header(&response, "etag"), Some(ORIGIN_ETAG)); + } +} + +#[test] +fn reads_the_origins_own_headers_not_the_policy_core_wrote_over_them() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = signer.installed_in(noop_services()); + let claim = claim(&services, &Method::GET, &HeaderMap::new()); + let raw = origin_response( + StatusCode::OK, + EdgeBody::empty(), + &[(header::CACHE_CONTROL, "public, max-age=60, no-transform")], + ); + let facts = OriginFacts::of(raw.headers()); + let normalized = origin_response( + StatusCode::OK, + EdgeBody::from(SOURCE.to_vec()), + &[(header::CACHE_CONTROL, "public, max-age=31536000, immutable")], + ); + + futures::executor::block_on(claim.deliver( + &services, + &identity(), + Answered::Origin(facts), + normalized, + )); + assert!( + signer.jobs().is_empty(), + "the origin's no-transform stands under core's policy" + ); +} + +#[test] +fn claims_nothing_without_a_signer_with_authorization_or_off_its_paths() { + let settings = create_test_settings(); + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = signer.installed_in(noop_services()); + let none = |services: &RuntimeServices, method: &Method, path: &str, headers: &HeaderMap| { + MediaClaim::of_request(&settings, services, method, path, headers, true).is_none() + }; + assert!( + none(&noop_services(), &Method::GET, PATH, &HeaderMap::new()), + "services without a signer claim nothing" + ); + assert!( + none( + &services, + &Method::GET, + PATH, + &request_headers(&[(header::AUTHORIZATION, "Bearer reader-token")]) + ), + "a request bound to one reader's credentials is never claimed" + ); + assert!( + none(&services, &Method::GET, "/api/items", &HeaderMap::new()), + "a path the signer leaves alone" + ); + assert!( + none(&services, &Method::POST, PATH, &HeaderMap::new()), + "only GET and HEAD are claimed" + ); +} + +#[test] +fn tells_a_media_request_by_destination_accept_or_extension() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = signer.installed_in(noop_services()); + let settings = create_test_settings(); + let looks = |path: &str, headers: &HeaderMap| { + MediaClaim::of_request(&settings, &services, &Method::GET, path, headers, true) + .expect("should claim") + .looks_like_media() + }; + assert!( + looks(PATH, &image_request(&[])), + "an image destination is media" + ); + assert!(!looks( + "/photo.jpg", + &request_headers(&[("sec-fetch-dest".parse().expect("should parse"), "script")]) + )); + assert!(looks( + PATH, + &request_headers(&[(header::ACCEPT, "image/avif,image/webp,*/*")]) + )); + assert!( + looks("/clips/intro.MP4", &HeaderMap::new()), + "a media extension is media" + ); + assert!( + !looks(PATH, &request_headers(&[(header::ACCEPT, "text/html,*/*")])), + "a page is not" + ); +} + +#[test] +fn leaves_untransformable_encoded_and_unhandled_bodies_as_the_origin_sent_them() { + let extras: [(HeaderName, &str); 3] = [ + (header::CACHE_CONTROL, "public, no-transform"), + (header::CONTENT_ENCODING, "gzip"), + (header::CONTENT_TYPE, "text/plain"), + ]; + for (name, value) in extras { + let signer = StubSigner::new(Behavior::Sign, 1024); + let mut response = whole_origin(); + response + .headers_mut() + .insert(name.clone(), HeaderValue::from_static(value)); + let (response, body) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::RANGE, "bytes=0-1")]), + response, + ); + assert!( + signer.jobs().is_empty(), + "{name}: {value} should not reach the signer" + ); + assert_eq!( + response.status(), + StatusCode::OK, + "{name}: {value} keeps the origin's status" + ); + assert_eq!(body, SOURCE); + } +} + +#[test] +fn passes_other_statuses_through_untouched() { + for status in [ + StatusCode::NOT_FOUND, + StatusCode::NOT_MODIFIED, + StatusCode::RANGE_NOT_SATISFIABLE, + ] { + let signer = StubSigner::new(Behavior::Sign, 1024); + let response = origin_response(status, EdgeBody::empty(), &[]); + let (response, _) = deliver(&signer, &Method::GET, &HeaderMap::new(), response); + assert_eq!( + response.status(), + status, + "a reader that asked no range gets the origin's {status}" + ); + assert_eq!(header(&response, "etag"), Some(ORIGIN_ETAG)); + assert!(signer.jobs().is_empty()); + } +} + +#[test] +fn sends_the_origin_bytes_unsigned_without_validators_when_signing_fails() { + for behavior in [Behavior::Fail, Behavior::ReservedHeader] { + let signer = StubSigner::new(behavior, 1024); + let (response, body) = deliver( + &signer, + &Method::GET, + &request_headers(&[(header::RANGE, "bytes=0-5")]), + whole_origin(), + ); + assert_eq!( + signer.jobs().len(), + 1, + "{behavior:?} should have asked the signer" + ); + assert_eq!( + response.status(), + StatusCode::OK, + "{behavior:?} keeps the origin's own answer" + ); + assert_eq!(body, SOURCE); + assert_eq!( + header(&response, "etag"), + None, + "{behavior:?} drops the origin's validator" + ); + assert_eq!( + header(&response, "cache-control"), + Some("private, max-age=60") + ); + assert_eq!(header(&response, "x-test-manifest"), None); + assert_eq!(header(&response, "repr-digest"), None); + } +} + +#[test] +fn captures_a_streamed_body_within_the_limit() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let chunks = SOURCE + .chunks(5) + .map(Bytes::copy_from_slice) + .collect::>(); + let (response, body) = deliver( + &signer, + &Method::GET, + &HeaderMap::new(), + origin_response( + StatusCode::OK, + EdgeBody::stream(futures::stream::iter(chunks)), + &[], + ), + ); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + signer.jobs()[0].source, + SOURCE, + "should join the streamed chunks exactly" + ); + assert_eq!(body, signed_bytes()); +} + +#[test] +fn streams_a_body_over_the_limit_unsigned_and_whole() { + let signer = StubSigner::new(Behavior::Sign, 8); + let chunks = SOURCE + .chunks(4) + .map(Bytes::copy_from_slice) + .collect::>(); + let (response, body) = deliver( + &signer, + &Method::GET, + &HeaderMap::new(), + origin_response( + StatusCode::OK, + EdgeBody::stream(futures::stream::iter(chunks)), + &[], + ), + ); + assert!( + signer.jobs().is_empty(), + "an oversized body should not reach the signer" + ); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + body, SOURCE, + "should send every byte read and unread, in order" + ); + assert_eq!(header(&response, "etag"), Some(ORIGIN_ETAG)); +} + +#[test] +fn keeps_a_failed_stream_failing_after_the_bytes_read() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = signer.installed_in(noop_services()); + let claim = claim(&services, &Method::GET, &HeaderMap::new()); + let stream = futures::stream::iter(vec![ + Ok(Bytes::from_static(b"first")), + Err(std::io::Error::other("origin reset")), + ]); + let Delivery::Send(delivered) = delivery( + &claim, + &services, + origin_response(StatusCode::OK, EdgeBody::from_stream(stream), &[]), + ) else { + panic!("should send the origin's answer"); + }; + let EdgeBody::Stream(mut body) = delivered.into_body() else { + panic!("should keep a failed body streaming"); + }; + futures::executor::block_on(async { + let first = body.next().await.expect("should yield the bytes read"); + assert_eq!( + first.expect("should read the first chunk"), + Bytes::from_static(b"first") + ); + assert!( + body.next() + .await + .expect("should yield the failure") + .is_err(), + "should hand the reader the origin's failure, never a short body as whole" + ); + }); + assert!( + signer.jobs().is_empty(), + "a body that failed is never signed" + ); +} + +/// Signs once through `services` for a reader whose request is `headers`, +/// then gives back what a later image request finds before the origin. +fn sign_then_look_up( + signer: &Arc, + services: &RuntimeServices, + headers: &HeaderMap, + shared: bool, + origin: Response, +) -> Option> { + let first = claim_shared(services, &Method::GET, headers, shared); + let _ = delivery(&first, services, origin); + assert_eq!(signer.jobs().len(), 1, "should sign the first request"); + let later = claim(services, &Method::GET, &image_request(&[])); + futures::executor::block_on(later.kept(services, &identity())) +} + +#[test] +fn answers_a_later_range_from_the_kept_signed_representation_before_the_origin() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = keeping(&signer); + + let kept = sign_then_look_up( + &signer, + &services, + &image_request(&[]), + true, + whole_origin(), + ) + .expect("should keep a fresh signed representation"); + let age = header(&kept, "age") + .and_then(|age| age.parse::().ok()) + .expect("a kept copy carries its age"); + assert!( + age <= 1, + "a copy kept just now has almost no age, got {age}" + ); + + let ranged = image_request(&[(header::RANGE, "bytes=1-4")]); + let claim = claim(&services, &Method::GET, &ranged); + let Delivery::Send(response) = + futures::executor::block_on(claim.deliver(&services, &identity(), Answered::Kept, kept)) + else { + panic!("a kept representation is answered at once"); + }; + let (response, body) = split(response); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(body, signed_bytes()[1..=4]); + assert_eq!(header(&response, "etag"), Some(signed_etag().as_str())); + assert_eq!(header(&response, "x-test-manifest"), Some("instance-1")); + assert_eq!(signer.jobs().len(), 1, "the kept copy is not signed again"); +} + +#[test] +fn keeps_and_serves_nothing_for_a_request_whose_answer_could_be_one_readers() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = keeping(&signer); + assert!( + sign_then_look_up( + &signer, + &services, + &image_request(&[]), + false, + whole_origin() + ) + .is_none(), + "an answer to a cookie-bearing request is never kept for other readers" + ); + + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = keeping(&signer); + assert!( + sign_then_look_up( + &signer, + &services, + &image_request(&[]), + true, + whole_origin() + ) + .is_some() + ); + let cookie_bearing = claim_shared(&services, &Method::GET, &image_request(&[]), false); + assert!( + futures::executor::block_on(cookie_bearing.kept(&services, &identity())).is_none(), + "a cookie-bearing request is never answered from a copy kept for others" + ); + let revalidating = claim( + &services, + &Method::GET, + &image_request(&[(header::CACHE_CONTROL, "no-cache")]), + ); + assert!( + futures::executor::block_on(revalidating.kept(&services, &identity())).is_none(), + "a request asking for revalidation is not answered from a kept copy" + ); + let not_media = claim(&services, &Method::GET, &HeaderMap::new()); + assert!( + futures::executor::block_on(not_media.kept(&services, &identity())).is_none(), + "a request that does not look like media is not looked up" + ); +} + +#[test] +fn serves_nothing_kept_once_the_signers_namespace_changes_or_fails() { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = keeping(&signer); + assert!( + sign_then_look_up( + &signer, + &services, + &image_request(&[]), + true, + whole_origin() + ) + .is_some() + ); + let claim = claim(&services, &Method::GET, &image_request(&[])); + + signer.set_namespace(Ok("policy-2".to_owned())); + assert!( + futures::executor::block_on(claim.kept(&services, &identity())).is_none(), + "a changed policy finds nothing kept under the old one" + ); + + signer.set_namespace(Err(MediaSigningFailure { reason: "revoked" })); + assert!( + futures::executor::block_on(claim.kept(&services, &identity())).is_none(), + "a revoked signer serves nothing kept" + ); +} + +#[test] +fn keeps_nothing_the_origin_did_not_make_shareably_fresh() { + for cache_control in ["no-cache", "public", "public, max-age=0"] { + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = keeping(&signer); + let mut origin = whole_origin(); + origin.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control), + ); + assert!( + sign_then_look_up(&signer, &services, &image_request(&[]), true, origin).is_none(), + "`{cache_control}` should keep no signed copy" + ); + } + + let signer = StubSigner::new(Behavior::Sign, 1024); + let services = keeping(&signer); + let mut origin = whole_origin(); + origin + .headers_mut() + .insert(header::VARY, HeaderValue::from_static("Accept")); + assert!( + sign_then_look_up(&signer, &services, &image_request(&[]), true, origin).is_none(), + "an answer that varies on the reader's Accept keeps no single signed copy" + ); +} + +#[test] +fn a_kept_copy_ages_and_is_refused_when_its_body_is_not_the_one_signed() { + let mut headers = HeaderMap::new(); + let digest = Sha256::digest(signed_bytes()); + headers.insert( + header::ETAG, + HeaderValue::from_str(&signed_entity_tag(digest.as_slice())).expect("should build tag"), + ); + let body = Bytes::from(signed_bytes()); + let encoded = encode_kept(&KeptRecord { + kept_at: 1_000, + age: 5, + headers: &headers, + body: &body, + }); + + let kept = decode_kept(&encoded, 1_030).expect("should read an intact copy"); + assert_eq!( + header(&kept, "age"), + Some("35"), + "should add the time kept to the origin's age" + ); + + let mut altered = encoded.to_vec(); + let last = altered.len() - 1; + altered[last] ^= 0x01; + assert!( + decode_kept(&Bytes::from(altered), 1_030).is_none(), + "a body that no longer hashes to its tag is never served" + ); +} + +#[test] +fn strips_every_header_that_shortens_the_origin_answer() { + let mut headers = request_headers(&[ + (header::RANGE, "bytes=0-1"), + (header::IF_RANGE, ORIGIN_ETAG), + (header::IF_NONE_MATCH, ORIGIN_ETAG), + (header::IF_MODIFIED_SINCE, "Tue, 01 Sep 2026 00:00:00 GMT"), + (header::IF_MATCH, ORIGIN_ETAG), + (header::IF_UNMODIFIED_SINCE, "Tue, 01 Sep 2026 00:00:00 GMT"), + (header::ACCEPT, "image/avif"), + ]); + strip_representation_preconditions(&mut headers); + assert_eq!(headers.len(), 1, "should keep only the unrelated header"); + assert!(headers.contains_key(header::ACCEPT)); +} + +/// A manifest the origin already links its bytes to. +const ORIGIN_MANIFEST_LINK: &str = "; rel=\"c2pa-manifest\""; + +/// `response` with the origin's own manifest `Link`. +fn linked(mut response: Response) -> Response { + response + .headers_mut() + .append(header::LINK, HeaderValue::from_static(ORIGIN_MANIFEST_LINK)); + response +} + +fn links(response: &Response) -> Vec { + response + .headers() + .get_all(header::LINK) + .iter() + .map(|value| String::from_utf8_lossy(value.as_bytes()).into_owned()) + .collect() +} + +#[test] +fn keeps_an_origin_manifest_link_and_its_bytes_for_external_and_embeddable_signers() { + // `SignWithLink` stands for an external-only signer, `Sign` for one + // that embeds a manifest and changes the bytes. + for behavior in [Behavior::SignWithLink, Behavior::Sign] { + let signer = StubSigner::new(behavior, 1024); + let (response, body) = deliver( + &signer, + &Method::GET, + &image_request(&[]), + linked(whole_origin()), + ); + assert!( + signer.jobs().is_empty(), + "{behavior:?}: should never sign bytes that name a manifest of their own" + ); + assert_eq!(body, SOURCE, "{behavior:?}: should send the origin's bytes"); + assert_eq!( + header(&response, "etag"), + Some(ORIGIN_ETAG), + "{behavior:?}: should keep the origin's validator" + ); + assert_eq!( + links(&response), + vec![ORIGIN_MANIFEST_LINK.to_owned()], + "{behavior:?}: should keep only the origin's manifest link" + ); + assert_eq!(header(&response, "x-test-manifest"), None); + assert_eq!(header(&response, "repr-digest"), None); + } +} + +#[test] +fn never_signs_a_whole_fetch_where_an_answer_names_an_origin_manifest() { + let signer = StubSigner::new(Behavior::SignWithLink, 1024); + let services = keeping(&signer); + let first_claim = claim( + &services, + &Method::GET, + &image_request(&[(header::RANGE, "bytes=0-3")]), + ); + let Delivery::Send(response) = delivery(&first_claim, &services, linked(part_origin())) else { + panic!("should never ask for the whole behind a part that names an origin manifest"); + }; + let (response, body) = split(response); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(body, SOURCE[0..=3]); + assert_eq!(links(&response), vec![ORIGIN_MANIFEST_LINK.to_owned()]); + assert!(signer.jobs().is_empty()); + + let tail = signed_bytes().len() - 2; + let range = format!("bytes={tail}-"); + for (refusal_linked, whole_linked) in [(false, true), (true, false)] { + let signer = StubSigner::new(Behavior::SignWithLink, 1024); + let services = signer.installed_in(noop_services()); + let claim = claim( + &services, + &Method::GET, + &request_headers(&[(header::RANGE, range.as_str())]), + ); + let refusal = origin_response( + StatusCode::RANGE_NOT_SATISFIABLE, + EdgeBody::empty(), + &[(header::CONTENT_RANGE, "bytes */18")], + ); + let refusal = if refusal_linked { + linked(refusal) + } else { + refusal + }; + let Delivery::FetchWhole(fallback) = delivery(&claim, &services, refusal) else { + panic!("a range past the origin's length may name the signed tail"); + }; + let whole = if whole_linked { + linked(whole_origin()) + } else { + whole_origin() + }; + let delivered = futures::executor::block_on(claim.deliver_whole( + &services, + &identity(), + whole, + fallback, + )); + let (response, body) = split(delivered); + let case = format!("refusal_linked={refusal_linked} whole_linked={whole_linked}"); + assert!(signer.jobs().is_empty(), "{case}: should sign nothing"); + assert_eq!( + response.status(), + StatusCode::RANGE_NOT_SATISFIABLE, + "{case}: should send the origin's own answer" + ); + assert!(body.is_empty(), "{case}"); + assert_eq!(header(&response, "etag"), Some(ORIGIN_ETAG), "{case}"); + let expected = if refusal_linked { + vec![ORIGIN_MANIFEST_LINK.to_owned()] + } else { + Vec::new() + }; + assert_eq!( + links(&response), + expected, + "{case}: should add no manifest link" + ); + } +} + +/// One ISO BMFF box of `kind` around `body`, with a 32-bit size. +fn bmff_box(kind: &[u8; 4], body: &[u8]) -> Vec { + let size = u32::try_from(body.len() + 8).expect("should fit a 32-bit size"); + let mut bytes = size.to_be_bytes().to_vec(); + bytes.extend_from_slice(kind); + bytes.extend_from_slice(body); + bytes +} + +fn concat(parts: &[Vec]) -> Vec { + parts.concat() +} + +/// A C2PA verifiable segment info `emsg`, as a packager writes it. +fn c2pa_emsg() -> Vec { + bmff_box(b"emsg", b"\0\0\0\0urn:c2pa:verifiable-segment-info\0fseg\0") +} + +#[test] +fn tells_the_parts_of_a_fragmented_stream_from_a_progressive_file() { + let init = concat(&[ + bmff_box(b"ftyp", b"cmfc\0\0\0\0iso6cmfc"), + bmff_box( + b"moov", + &concat(&[ + bmff_box(b"mvhd", &[0; 100]), + bmff_box(b"trak", &bmff_box(b"tkhd", &[0; 84])), + bmff_box(b"mvex", &bmff_box(b"trex", &[0; 24])), + bmff_box(b"pssh", &[0; 32]), + ]), + ), + ]); + let fragment = || { + vec![ + bmff_box(b"sidx", &[0; 32]), + bmff_box(b"moof", &bmff_box(b"mfhd", &[0; 8])), + bmff_box(b"mdat", b"coded-samples"), + ] + }; + let segment = concat(&[vec![bmff_box(b"styp", b"msdh\0\0\0\0msdhmsix")], fragment()].concat()); + let segment_with_emsg = concat( + &[ + vec![bmff_box(b"styp", b"msdh\0\0\0\0msdhmsix"), c2pa_emsg()], + fragment(), + ] + .concat(), + ); + let chunk_without_styp = concat(&fragment()); + let progressive = concat(&[ + bmff_box(b"ftyp", b"isom\0\0\0\0isomavc1"), + bmff_box(b"moov", &bmff_box(b"trak", &[0; 16])), + bmff_box(b"mdat", b"coded-samples"), + ]); + let fragmented_file = concat(&[ + bmff_box(b"ftyp", b"iso6\0\0\0\0iso6dash"), + bmff_box(b"moov", &bmff_box(b"mvex", &[0; 8])), + bmff_box(b"moof", &[0; 8]), + bmff_box(b"mdat", b"coded-samples"), + ]); + let mut large_size = init.clone(); + // The same init with its `ftyp` given a 64-bit size. + let ftyp_body = b"cmfc\0\0\0\0iso6cmfc"; + let mut large_ftyp = 1_u32.to_be_bytes().to_vec(); + large_ftyp.extend_from_slice(b"ftyp"); + large_ftyp.extend_from_slice(&(16 + ftyp_body.len() as u64).to_be_bytes()); + large_ftyp.extend_from_slice(ftyp_body); + large_size.splice(0..8 + ftyp_body.len(), large_ftyp); + let mut truncated = segment.clone(); + truncated.truncate(segment.len() - 3); + let mut zero_sized_last = segment.clone(); + let mdat_at = zero_sized_last.len() - (8 + b"coded-samples".len()); + zero_sized_last[mdat_at..mdat_at + 4].copy_from_slice(&0_u32.to_be_bytes()); + let too_many = bmff_box(b"free", &[]).repeat(MAX_BMFF_BOXES + 1); + + for (case, bytes, expected) in [ + ("Shaka-style init", &init, true), + ("init with a 64-bit ftyp size", &large_size, true), + ("media segment", &segment, true), + ("media segment with a C2PA emsg", &segment_with_emsg, true), + ("CMAF chunk without styp", &chunk_without_styp, true), + ("segment whose mdat runs to the end", &zero_sized_last, true), + ("progressive file", &progressive, false), + ("fragmented file with its media", &fragmented_file, false), + ("truncated segment", &truncated, false), + ("too many boxes", &too_many, false), + ("an image", &SOURCE.to_vec(), false), + ("nothing", &Vec::new(), false), + ] { + assert_eq!( + is_fragmented_stream_part(bytes), + expected, + "{case}: should be told correctly" + ); + } +} + +#[test] +fn origin_freshness_keeps_browser_and_shared_age_and_processing_independent() { + let receipt_seconds = 1_700_000_000; + let receipt = UNIX_EPOCH + Duration::from_secs(receipt_seconds); + for (control, age, date_age, elapsed, browser, shared) in [ + ( + "public, max-age=60, s-maxage=3600", + Some("0"), + None, + 0, + 60, + 3600, + ), + ( + "public, max-age=60, s-maxage=3600", + Some("5"), + Some(120), + 0, + 0, + 3480, + ), + ( + "public, max-age=60, s-maxage=3600", + None, + Some(120), + 0, + 0, + 3480, + ), + ( + "public, max-age=60, s-maxage=3600", + Some("200"), + Some(120), + 0, + 0, + 3400, + ), + ( + "public, max-age=90, s-maxage=180", + Some("10"), + None, + 40, + 40, + 130, + ), + ( + "public, max-age=90, s-maxage=180", + Some("10"), + None, + 90, + 0, + 80, + ), + ( + "public, max-age=90, s-maxage=10", + Some("15"), + None, + 0, + 75, + 0, + ), + ("public, max-age=90", Some("70"), None, 0, 20, 20), + ("public, max-age=90", Some("100"), None, 0, 0, 0), + ("public, s-maxage=90", None, None, 0, 0, 90), + ("public, max-age=bad, s-maxage=90", None, None, 0, 0, 90), + ("public, max-age=60, s-maxage=bad", None, None, 0, 60, 0), + ("public, max-age=60, s-maxage", None, None, 0, 60, 0), + ("public, max-age=60, max-age=90", None, None, 0, 0, 0), + ("public, max-age=60", Some("bad"), None, 0, 0, 0), + ("no-cache, max-age=90", Some("0"), None, 0, 0, 0), + ("public", Some("0"), None, 0, 0, 0), + ] { + let mut headers = HeaderMap::new(); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_str(control).expect("control"), + ); + if let Some(age) = age { + headers.insert(header::AGE, HeaderValue::from_str(age).expect("age")); + } + if let Some(date_age) = date_age { + let date = httpdate::fmt_http_date( + std::time::UNIX_EPOCH + Duration::from_secs(receipt_seconds - date_age), + ); + headers.insert(header::DATE, HeaderValue::from_str(&date).expect("date")); + } + assert_eq!( + OriginFacts::of_at(&headers, receipt).remaining_freshness(Duration::from_secs(elapsed)), + OriginFreshness { + browser: Duration::from_secs(browser), + shared: Duration::from_secs(shared), + }, + "{control}, Age {age:?}, apparent age {date_age:?}, processing {elapsed}", + ); + } +} + +#[test] +fn invalid_origin_date_cannot_gain_freshness_when_rebased() { + let mut headers = HeaderMap::new(); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("max-age=3600"), + ); + headers.insert(header::DATE, HeaderValue::from_static("not a date")); + assert_eq!( + OriginFacts::of_at(&headers, UNIX_EPOCH).remaining_freshness(Duration::ZERO), + OriginFreshness::default(), + ); +} diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index f8555ff0fe..a0a072eb4d 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -46,7 +46,7 @@ use std::rc::Rc; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::Duration; -use http::{HeaderName, HeaderValue, StatusCode}; +use http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use crate::attestation::PlatformIdentity; @@ -59,6 +59,17 @@ use crate::streaming_processor::StreamProcessor; /// unless it says it handles another. pub const HTML_MEDIA_TYPE: &str = "text/html"; +/// A module's exact original byte ranges that no document transform may edit. +/// Called on the complete, bounded document before core or middleware runs. +/// Each classifier returns ordered, disjoint, nonempty ranges inside the input. +/// An error refuses the document. Core treats ranges as opaque; classifications +/// from different middleware are combined, including overlapping ranges. +/// +/// Transforms see an opaque comment in place of each protected range. A +/// middleware that signs or hashes raw whole HTML must not bind that intermediate +/// view and assume its binding remains valid after original bytes are restored. +pub type SourceProtection = fn(&[u8]) -> Result>, std::io::Error>; + /// Whether a middleware may run on a response of `media_type`, written as a /// bare `type/subtype` in lower case. /// @@ -154,6 +165,13 @@ pub struct MiddlewareContext<'a> { /// a reader's own page alone. A page from the store of shared templates /// was never one reader's. pub origin_private: bool, + /// The headers of the reader's response as core will send them, being + /// the origin's after core moved its addresses to the reader's host, for + /// a middleware whose change has to respect a header such as + /// `Content-Security-Policy`. `None` where core cannot say, being the + /// fetch phase of a page that may be stored and a page replayed from the + /// store, which a middleware treats as headers it does not know. + pub response_headers: Option<&'a HeaderMap>, /// What the adapter reports about the platform the deployment runs on, /// as the attestation evidence carries it. pub platform: &'a PlatformIdentity, @@ -567,6 +585,13 @@ pub trait Middleware: Send + Sync { false } + /// Classifies source bytes that must survive all document transformations + /// unchanged. Default off. Only middleware in the applicable chains may + /// request this hold, including serve middleware before a template is built. + fn source_protection(&self) -> Option { + None + } + /// Whether this middleware moves the origin's address to the reader's /// host itself, in a body of a media type it handles other than HTML. /// Core then leaves the addresses in such a body alone, whether or not @@ -927,6 +952,15 @@ impl MiddlewareChain { self.middleware.is_empty() } + /// The original-source classifiers selected by this document's chain. + #[must_use] + pub fn source_protections(&self) -> Vec { + self.middleware + .iter() + .filter_map(|middleware| middleware.source_protection()) + .collect() + } + /// Whether a middleware in the chain writes the whole document, see /// [`Middleware::writes_whole_document`]. #[must_use] @@ -1115,6 +1149,7 @@ pub mod test_support { origin_host: ORIGIN_HOST, status: http::StatusCode::OK, origin_private: false, + response_headers: None, platform, document_state, max_buffered_script_bytes: 16 * 1024 * 1024, diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index fc493f74e1..6275024e64 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -69,6 +69,7 @@ fn reader(request_state: &IntegrationRequestState) -> ReaderDocument<'_> { origin_host: ORIGIN_HOST, status: StatusCode::OK, origin_private: false, + response_headers: None, platform: test_support::platform(), request_state, max_buffered_script_bytes: 16 * 1024 * 1024, @@ -2390,3 +2391,119 @@ fn an_html_entry_ahead_does_not_shadow_the_entry_that_makes_the_file() { "should make the file from the text/plain entry, not as HTML" ); } + +/// A format-agnostic source policy supplied by a test module. Core knows only +/// its returned byte range, not why that subtree must remain unchanged. +struct ProtectedFixture; + +const PROTECTED_FIXTURE: &str = "

keep

keep

"; + +impl Middleware for ProtectedFixture { + fn middleware_id(&self) -> &'static str { + "fixture.protected" + } + fn phases(&self) -> &[MiddlewarePhase] { + &MiddlewarePhase::ALL + } + fn create(&self, _: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction::pass() + } + fn source_protection(&self) -> Option { + Some(|original| { + Ok(original + .windows(PROTECTED_FIXTURE.len()) + .position(|bytes| bytes == PROTECTED_FIXTURE.as_bytes()) + .map(|start| start..start + PROTECTED_FIXTURE.len()) + .into_iter() + .collect()) + }) + } +} + +#[test] +fn source_policy_protects_before_core_fetch_and_warm_serve_descendant_handlers() { + use crate::html_processor::create_html_processor_with_source_protection; + + let fetch = chain_of(vec![ + element("fixture.fetch-attribute", "a.edit", "href", |_| { + AttributeRewriteAction::Replace("fetch-edited".to_owned()) + }), + text("fixture.fetch-text", "p.edit", || { + |_, last| { + if last { + ScriptRewriteAction::Replace("fetch-edited".to_owned()) + } else { + ScriptRewriteAction::remove_node() + } + } + }), + ]); + let serve = serve_chain_of(vec![ + Arc::new(ProtectedFixture), + element("fixture.serve-attribute", "a.edit", "href", |_| { + AttributeRewriteAction::Replace("serve-edited".to_owned()) + }), + text("fixture.serve-text", "p.edit", || { + |_, last| { + if last { + ScriptRewriteAction::Replace("serve-edited".to_owned()) + } else { + ScriptRewriteAction::remove_node() + } + } + }), + ]); + let html = format!( + "{PROTECTED_FIXTURE}\ + outside\ +

outside

" + ); + // The source policy lives in serve, but must already cover fetch/template + // construction, without running any of serve's actions early. + let (mut processor, _) = + create_html_processor_with_source_protection(config(), &fetch, None, &[&fetch, &serve]) + .expect("plan template"); + for chunk in html.as_bytes().chunks(3) { + assert!( + processor + .process_chunk(chunk, false) + .expect("hold original") + .is_empty() + ); + } + let template = String::from_utf8(processor.process_chunk(&[], true).expect("finish template")) + .expect("UTF-8"); + assert!(template.contains(PROTECTED_FIXTURE)); + assert!(template.contains("href=\"fetch-edited\"")); + assert!(template.contains(">fetch-edited

")); + assert!(!template.contains("serve-edited")); + assert!(!template.contains("b#e+4K43 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

RJmz>Ew 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

u3r)Qaif{ z9N|c3n^So5vk$L}yc$y}+8X1U<<3k8w)5sx9l<%dJ}AMF_f#XRoTE?NE2^M7Tu4B7 zTRVv%6m7wUO2=zr{LZbz(K^cNqLEpQnId({E3LdO+1SeXi)QFngGR<)9)aL;thK$q zse$jx?ULK-TjG!iC}=FfJT);VQI3OL&HSX>6R{@7Ez-;loLr&I{R(x7NJl2Y>S-e6 zQ$}-0UGs4-ud1xPh(8(P8pLYBBiwXib@&b_t`@EXEe*g;AWb9V#3VA`w7n^oWK3FG zxIuwHfmF)PDXCQJ)wR>7>Eu-{*SbViDP>x?mkj3H2>!G%I?)FD!*S3S#ev|GX>hT% zEz4*{sgE|04hDmZw7#>2vC>`O4a`fV9lv<+6W8iiu4rxnuiNG_Cc5`b_fjyUSvjV{ zXp(i{2`eFicoJ(*CkAt4c#`WlbTAZDBupw1Dq3SP<}nd1Z*FT;Tzanf>ZD8-TqXv4 zk_)Xr-u=Tq;~Tt|C%H%&oRqg4y7tdzSG*0Bb34j5jD)VtpoaIRZ>4Ur1iu zn5|ATAG9&6BdVE5oa%J{6~1$^SfV-AlmtgwbVE=BWiPpYLRl2N99L0S)0Rr5>(pYW(!>5gy)5WFS(#2 z63=4g%Yb{C##AiR$eLhk8aeSE<@g47(bc!KC*ue>Db>>g9=3KsKadDZVatuQIyx=3B8Ld#}Y7b9rzC?1whM)9*) zcZrU8OB|Yz<(7^%ouyBu8!Hm5!H!s@CCgk~3C&^MGbYIlbJ)UkcPge_b9gebwPscOVS*KvFbF1hFaBLVIo5*_9ahKthl|#8mig`%WwsaC&5`|@z z!}7ROpkCvpy3@^YQm8$?&V$cy1J5|Um$-0nDL9dC<;Z?i0o&T3&qFx{q+Uij&Ct}& zqG(HH8(_qBtfMAQ`$Aa~9E{}*ZhZ@957MlC=z1(s1u|xcJx$TXb+c!eisUmVomWk% zEoIDNkvKS~CdH!}hPFBxncLaOg2=|(G8bHS*=4i?+8FI*y=R70hwo}@YP%fTonT!j z*syJ#-!wM&>573RVTcp-Dp6iiGGR3Jw6$lN;3Qdwrn(*+_;kg%3SGA_nlXaLz|wJq zy7*!)*+?XqPq+(MMI#h%5E{UBFG=Wa68=EkdT@qoW3nR{(}5)eOX`K+(1datGp;UO zLnOrdR_3}<0toeqH#af1Xg#zb)mjm6ZM^t`F~bt=@LK9+D62uL&PMR9i9^p&i9|Hr z-gxD8Q>N%NLy|GP9Asq-;VKCtqd-SlTZ~^A{7wxv0q?Vo&=iP2ysw?>dt#kc^#77ehExx!F0E1J-Z==jW7sD&;~CVmrd8ebK^tqJby5MW3;Qc4t#>Xq$?|jRJX)hip4L{57~6`h zHFm_Po4|n>@z5feb*zQ4BS+nI4La^{zE^VRT_qPy8h+8(;UjKpjx{t)`P{6j(9)*4 zii8eoXOJ(bqYqmFJe2hS{=loUj9s}dHi?3-O~@k93uL5ZcI@y`H(gy@JF9G1WmQ88 zPE8%U(qMuvmBSb+D@P7tEIOMZ z^~5!gvSOjLsRqVkNSNb`;5ciT!9e^&PGwD`JL{R(Y$uqd&>`po?g1G%F$6~FY-Asx&cLPn|$=E3;_SbidQ<%DZ9;7PJ<*r?is=sCE4{dLoDLrEZ) zr&_A-8d!4mlnIkrb3-C^b+i1gkLtoQ(NnxPebk)SW+pRU>;ghV}A}^W2|ci zX2w=jgeeGBlziJbc*q?kWDUbNFQI$f9~*)HMY84B%(`j_6RQKv{4HUPLm}@P2r7)= z8Obw>XEe_kp38<+n*U+j8a9k)I1l-Tk#89JhLLX=`G%2iI5~&Q21dT&OKfB2N{0D=AY+*-FwX$x}(*O3F|HgTX^S{sW@v#4gWho>4p_c}DOI=NZOR z#Y4U^ndmzLDe`NxqTf8%dAp z3u9L`{4&@Qo#!7iYpV6tjc5~DW>J`jHk^v4nvl!uAy|=Dcckj#5a?I8L?W42w_v1# zZy^21bd`c~qLJ_(sb_AZ@n*vBvtke;;l${#24W;Sq=etQ>ey z9e2d>J)v_d(%2qp!>zbInPN#Idqcw#?FcD|=ftZ`p-)60j$ES1_c>x+&)PTt-8Z^!G)sbTri2VY07AmS+vm)BU*wSP799zV*kYDt^MFC ziK%1RvAZd=gx_AIrp;oG}Rt&8}^qGOBSeu3YXk#F(aTXZJ8cOo%2U0^aB7LWoZ+99itvSk%_??sSIi#ZINU8&C{diZmRpkr*@zi48C1@`_BRozElZA{M43!eOl-&_hBJGBxunWs_Ni z_)Mm*onUZA^@Ryw=*qT7J6ViTsn-}Kq-ye)p~Ffe<*qejVlTlMl^M}TrhusI6iG%6 zs|+TECZh15Ueh3K0+>T&c|kTs&eCm6i8Km!S{iNU_ICI)3V&OysU4Gnx$dSBGluL7 zaA&xE6st+R9@U0$Iw&&;j?rur77XMnrZCe1sX-_~F_1MS6IIoah;*!u?xW4OXPZ!y zD9h(E3^tY%nP=4LD5yu9KnM+IdutYbLPRBv!62ROoCIZPkp`ERZHWm*Q#Fwykv!5U z9I13?yHV3MO!W*bhO728r#7jtE!EcECiU3th;&Ys6$hz7>ih=pEdsb091l+`6Eq>=3?7OfSss^OWdv;rV)SSz83 zjo?}&iFtzIL5=D(=620ihVCa~vQ<>5gHb473=>B@(P=Kc2J0f(=62NAEOr`D0Kq<~ zMq|;8&|ldTm~Dck5FSVtkxYFjNS{H`(EV>o28{9R&@>~@VT_5#>Lu346DE|V)Jm;9 z_`J^I*K41so^6hV#2&JP$}-t0lAQ!GEJ4Qpnoe~h6>-TulZO;T|AZFEPv$-ug!OEb znNbs%jeQYN0_#W&%*7<7C64r9_6aq8R8Y>J$)yz(ogX8F5 zty(1JqL>?nIoRzuC!a#PWr#b7FU5DI_9mf%7(ryAyiWDW6bXe)*YjO4NtV8h%#4|6 ziugF#1CsSvtB|r3nVq?eHGr7eCbSpSVnnWLFkvne%G#B+GBZi*a<(X!1n`c@CRmoRyCCF>BB(9qurk!vH-cili(tk?mBXxtae~Rq#3Z?jjziOE zG!xLq5CUSRfk?~7rqoU?Fm!SzA1PuUN+h4u>P|MIKo76clr>7|t}y4UHObLgTAbud zMorie#E5v5n}Fh(I$1V) zDl2WZdWw)$w%}3|IS%TW%oP2ad7BLb(<}8L7O&I%brV+s-OLIrmu*)LChfzTl z3^lw%B2AQyMur!|ZU###I-jnaXFxKs8d+pw$vM~0zW9RSmsKM(w#}QP$bxL5PCv z#F=LHXhj|psNLNBs+E#9_fpZ1VKotSIAkvtBdaJKDJ`oYye31>;Hm_MP`#-msBDn%Mx$G^APWGLH6vw3X6UCQoLG(v-{#7-uSKur{(IE}Ic)_)#u$o!L0t$;u_62MkQ7vgq!)C{S{Hpnf34G=s8N5rd4>`IAN^j`Ze^J1A=u}Tx0 zZd^sIwFcWtX-zpXEXHFC09zB;@ul^W4zZV~JBP{Kiy5}PEe>A7dL!vJ7Cl`AHH@t~ zBid08LFYtb6E<+!2Qw8IA}X{4QLQZs2ny%R>xeNHlVv|6^Iv5PD=mT^JFrAnePo(g zdu)98j=QR5WT|EkB-w}xd*+!D2tpK5mtR1uCT(vi)-;+2LTctpM61%yNK+~`M8@(7 zl0davm0=-P)u02lH_E_lwDj@PSu%GGVFasWb!YB0&svh$MKhK2Mpag7*Ps*(235p5 zvJnQXH4Z653y&pOVB;!eT&72G^)gMKP^z4Ml$;6H5k>=j|1#_Z<0?4D8B4>qlP#09 zW`@aNs!blOP*pYKSeRL4W!Bd)wFMC@*)k-2=bc0V!7`}boWuo{kTi2lU;B1!O7T$< zI84-Ie|mQ&4pxtoE+BqbQ8x2^Tk!;Z>Q%fuC2SaPZYUMIxhoKR$M*5)i-I)l%&d#TeK^IjDTP*P8o(1M3S<)++n;` zF41ISQ?05LL#WnvUATN$d23voXr(fHBWf@`lOU+3Ac)pMsii8kWyXg~ORHf9@FN+zsWKOe>`@}H2btVJ@i zrXg~BY0dnmvif8d=GBt2s?s6VlETMpw-Elu9*t;8M#iLSMsM8t;9)Fbf*Qf>2AbIq zH%ph$iswG$WP3R)cVG$IPa$aZwE)) zQyB8FGa#gA*aj4a%QC{&h7?fjA~Kr1x-pA&g!zRI$R=XMckfoP5`2ZLl1UYn3;hS-R> z$Yw@NTWfJ>Ejxt_L_E@fXsPX^IN}}y+Xkgb)p7TFO44;Y0*WExql-$4M9N9er(UAK zOW2W&!G~Vm*_u)(Q_TXAYhFFjq%0bPPQ-uqW!jsOk&%Yd>?JWwd??k3eh`UcUP?gu zQ*iN23W1No*IY8Z?8FGS;MZ_EnXHn@6a)+bs-8BpdSrrC#4?0Y^iDWgI>WZN1V}~| z(fQS#NhsHRwu92h^e}mx+xR5089Qkyy<`Hj`ULt8%Q9IS=~>Kp9r!V&9JO)~1ujYpaxzDUroKF<)L-t!T3 zV(_Lp0(#5I4*8W`mNc9U(NNBF8SP^UMZw|(TZS1;GF8zIuqjHn_=fC+LR;)Gv{W0N zbNM)}0!gCm*)T#f`>}0Rm(r#!TPVnN5y`e@EkuYb8c!o2rl9 zG{drxtyxxD3!pG4&N9HD#Xh52kYaaK78_X@HQlCz8b>sD*`qY85i~Naj@W#JyIJvu zL=0CAOf;C5Fkv!kAR)pRi!<`-0yA&SV6#>zx(3Lg8r7hUnrSM~zLZR#wCdI8g`HaL zS|{{GOo_cJerr&}#SvNXRK;k~QtjwMJU{`Y%ajC2V@#D=frP>h&8GX%E-;%;K=4IN zkmngzFxx0>(q0${tTd<@)GB=g!5lLzKgVRgX_n*|i^wFKxmdbtb(F~zuC~JP8B2N1 zh$w(V%wIZ#RfXmocEL1(9_%?u3`b~5B--1g_=0*UwxT`lt*|Xh`IkJa zF&)rS=zy=_*kUyEG_%zii<veNRkMA5K(Y69PdlAz5YOEfa# zx;|TmF6cK#LL{j;Gu>g-bu1xRYqanhJN^61nIEV&b_(|Q@H3(YBy2FrzeO00Z*29-ke3zBk%5hA zmas3&AvRRLXLhe?KWe44Y$z|568LKp-2}ZXJ54l)?m*V%uK-f9VJ{0=UIaZdBCC_&H$8Q+R0HQ1~GB4Ca;g$PPj+uz9otoXKS3psUOm zZG$<$pI97l!nwlC*mY*aw9G*;ElLR&;=}dy1YrU%JH%&M*-8tCorDTeBAVm&4 zWWa1B)DmMY7LZJ}H#R}jViG$LZHXx!$Izykj=-5%HClDlMluA%>=iIC+69J^FdPk7 z@+oEZQw{!f8WoYHAnyc_5zN5b4LrxF4dyUs;1VmASClNa+FQXIuEsQ$Nwd~~k$5vR z%mp&hcF_mlnrQY8aMXYnM+d$3b%VGs}Dx!miq$r>wm8=7O5dx@|{$^{3rm0W&64Au#{ zJmeT!PxoR7Kh!x?7g02V?JkO+`5wUZKK zjpJMton*$41u6rY3H0g5;Kn$RD#bsRm58r?CX4QVhpaX zm}&%CgV9hWwbthdMkB_yrByF_HC@W0d>lk=u*WAlXQRB;({UPzXaT8=UYuk{yqb2h zGL0gRrz<9p=s1~e@$yZzfR$3cN?EMP7Qz-HC`13`Jt1vqY`9ioQDsqUH2YGSiBqnc zIwL%tB_X~HAC{>cYT?Ju@h?rDYQ7@Elg&(NWwK+l4J~E8vl^nn^;vmuv1V}vh$Lp4 zb;0)k%%m~uVB|(+D6N@=9F9l7j9Cy-c_uJx)G*Uf33!51AK{HgKp!6;+Rg1}HG92I1z2E&;wr8U?swYphj>`k)rOHp5?hf@psCv=T%0$FZSNn_^ERLL@Ur(r)D zLxw{GnJAUzhOdj0R*OgY_oYS>Rc4PtzkuWnAsdqnM|P|+JjfQ)-daAvZ~?QiB#B6@ zPjn-CwQt;)JurQJ?UiL?O3l_|c%tC~<>4DJKWn2fo-+1gHpA=5&$y_}hBWZ=8xq~8^5*UqmBbbr%{3NEkf;(#0X}Z{W#WRJ5`J{#wZzuN^8)1kdewI5Dmr^ zOSFeqEViag1`{N+nRUrd@lfPy@X$zK#ur^0FwGonR!4fakpqRhsyW(;_50HzcD0bX z#wIuqJwl;1;slwm3H%F}!_ZApv%ie5A>*%hir|^u zbm&VfT*&w%$ViC^U)n^ZoGp4r2KfNfjcMUncpSW7Cd?)s*0x3|#y#18%p{{CXfuqF zh4jE#*w;XIful|GH8B)ssHoWzo^7~KNy!aRWn9M5+l+U>SPDXR*+B%ik)9d7QiTF9 zH8WmQ*RB;I%#UWK8y+o=BS{k9LPS8(WNlNG*33nwgI+Srl0Z=7;ZO*O%%?^isf-n% zZOa&(i4CHf7Q`$J4$zI#8nZQ>8E^K7&4zHe$<$WqFZhy@smXBIxJk@HPi={pnL*?m z(7}m@z2XAI*rD3hsij*x!a z9vecp;QNxk<7xTEm4=DxDkZ_xTxJ$O{AF+_)_w`_actx{d!DkC~-i`?hpHyrgHV z*L7%h&;A8{FRtl2vax%?Hh#~oJJh}Ofv$rub?x)I_Pvl>^ipo+zMiccdsZ#(-FhJR z_`2N6ZCwX9b+5law`^ng+C6=1cag2@z=5s<&*fGx=w0$cZtsq+R~B^bd#wA(O+D+E z=k{*uI<&rb!J3|(2fJV1*uDPVo|1FWuYs z?8AKv+V|8WJ?r-67VYfXx4iezq3%`ddv-n5b?691dJoa6J-PLp zdk!4w+INtgy}R$}e)!Se=N9)p^+0aj6S-&Z@7}pNxA;DN{owlUHG6v3uFNf7l-s($ zd&TD7`&Z@m-JM&1pnK0!#-Zo=1N1Am@`2pGgnP9S`N6y}SE~_1*U_@22d-+xylY>|XEj(*5xM+$$?{_pR+Z@KX26Rb7YIso4is zbg$c$d*-p;Babp@3`ozxCwrdU)VE-D_r87IOIPOhAIUwiEVpP+*TILWzjx#2+}3+@ zi#GSXWTxYl$8rxm(tBiG@5cLj?%tnUa{+urw`En=zK44ry03TZ;ohwq zbBpfp-L=2_p+|e~emS>nLvGO~HGA1+s%2(#4=rY%HD*7jzxm+r8nH z?ls%G?^^)c^gX($Z}s8s6`Od^ZQYYQc!0)qg8(a+_dT+xciX}4oe$*>FYh{Zf6t+< ze4=~ziazh&?&Z%j8r>WA<_@jS-Fts->EfOp2fJ51NFQ_SHgxR+ub%0CWHqCjTeYI^ z$(>#M>FNEw3lH^q4C4~AfoVO@Jpht`nf%U2bI&Zvty&85p|6ThxfUj8x3>Z;V!hK-A}Ai`_~=rUb_fXq~*Cqd+8UIfTg*m zYgskSV{YHWU|Me5;+_o)SXRQ@70>0CvhKEnA-N|HfF6u3ts*~2)wkq+^%3kna9{4> z_1!N$1@?lCRK*}`6&zUC>n-fv_7K=ERD5!O@766eSO~f1NbbP;?xjrkfu0rHa!)Pn zd-l=pecN)Y?$a=C-T@|1s?USec)6{QY3^1&ph;i7pnJoT+>*uSb1QnEdpfsxCoN=P z4NY54{kfIPdUrj|kajP97z)EefaY`^e3}*4^V~KDB)5sxdVq0OPuF<4HCuB_XvX%Q zXAeSQatGExa~Kx#gN-z)=aq+h4?|dvpkfK#AP4b={9|0)gqotxA!dE(zkXqmGrII z3idM2eUCqp+qbWG`HtRgTY4TnoICJrZr^>~>(>b%Htp=*wx;*+La0jbw#B{A-J_Aw zO57@hU%R>c9`JKP&&!8%ho0|V3mF%6>U;4KQJc*WuKj(FKgB%uEqNgK%stS2$V=a& zt3X94Uf=3HEH|wbI<<6h*MS#%?tNZMd$$QoyEklPMtZkx%B@(OTlsQsVI(%S7U=vlT$|GjsAZt07?Tc6=`xg(4E zHXelL!x3`ZSL&lnAIN#aQl-A|ApAFX-=gm2FZJF1H1vV*iV{LU7cUYyT1D6PYo)V( zpIb~%`yPFYg(<#yWC=~}S+fJ4B1DC9E;(Sv`F@hRw>%A1r-R*(JlXxwqOJqW!5{J8 z4NK^w=>M{fy@yw`68R$N$Bg%E^xzjFWd}f;r@@(?E#UKNxSJTzs)f*7Ar;JJ>4Uj# z`}uC~y{z|b;>e5d)B1&0tbelSv3peZ#pOL)?(I479K<~L1f*$Y-wXHUwmzXXzH|@W zr>Tevw5MmwVYA+s=TTJGmuCB5NX6K6#pGTl(PO!q>0nq&)Y0#Mz}&3#Xq&Jc~~s$ycWjE*;zJ z0Vnjvp3{*L*x;r2ZyY<~L@d4j)%B-8U;66fXeozIq_xv$qRHUxNA>%xgP&bHW6@4} z{@R7NmQGx85^ds*y}w$x^s3j%yK7&*w)pYaH~?`Uu4R``KN|)c&Eyr5jwHSD-oAxT zE=S8U-)Fx>A^1lA|K;I@y%(~oZ@#;5@!Y~;r{HO)a^byqoYTd(U!Wa@EZ=+Q+Qnz| zwe(~p>Eg==?09JV;(3Z0VHb);kdEyq2k9Hc*0n3A7hk%#aCpzP3(qee{Pn_fXPi*n zI9%?%GfN-riE?u4wJ2ksetrGKJ4?@hQ495<$zK2RgX>?Oy7tw(^pNm>GV$b5ni5tP zeFG0Xe&oiRdzW5+e(~4D*;_Y`Jx|BTdHn1PQa<$g)vswa*N>cv%KG+EE5G>Zm)Bo; zb?J(8eUivC3*sKfvn)#oXs`!RX7tZDkI)cutX@8Rg9a7aep(kswRGmGFzs{uqcPH8 z51m{(e}sTo*yo&T>I&r(^)9~hRn(iquP&WFX>oF9X~>74yYc>kXjzvph5bJ9!P2u& zEjaQte_5>QU7xs=UFwMnJf0d2Kt1n!*{?Z#u`_E-O zFYJp>NYf)LsWiu_#GmVQ>7C~mE?&6){^98Sw5+daEZD_9x^DOhm6eTi;LPF^Z!bQD z2%VZQ7M?ro6p%!w7XA6eS5AkOTzvK*RvA*n!7ehF-U@+T{tcS3-7#Jc;vP>UMccjj z_7${BNyD-nw3th;{o3rJ@*Q$g-VBRoQ#`ldf=5hWB0(;pnVhMY&r?JV@5U4FhI59- zUcGVTL;Pgv{Drh*g0}L_3pZYUE8LiL`08vV9jgjIr_Y?em=(5MhssKN`zU@GQ<3&~ zI$ML_OUe0s;p|=q2j^V6@{U7F04+SnPW;-7F%7=>VBy3GEFxA6smFkQ`$RKo(JMqrujn=f8}`xv>5HN^_M z^bzrNxD1gUpRcu>Av>m zS4fDYaKuM1747j(#qqii=AJKSgrpW#66RoF*KDf&Vo6 z!7I)z>}3sIj3mVvue=bl!IZANx^(VXbhgha65Rc zVm%v<9lenSr_<-Zj0`&X9IM>!5Iv}9@$Egvh6b)(_zLw#e7{b*?OS~7qs$S%jgi96 zJ%+5$i9P<-k)^Zs`BQ9xx1#RuIeq=~S*k_s z^s{>i!G*)G;H8m<4AnO;Sb0Y~TJeE>H-2@5UE|m5M~QRFtY_YexP1DE)4F*2P$=r?JG@v6XP#L&OPD?%1MR>U zOXuHR0*cIn_Jaf0pLYuML6e&3GYc%7d7xw%App@DF}Jableq+~n~EEm>gY>=f!joSc^8r#@T3IlovihIX9lz69)VI5qnMP3LDf2$Kcn`Q?0Lm zc?Fldell9n^)C-tF++8Z*m3c(@CQCa34GIzU%PN3Oz+Hvr7LeO?0Y>^e(NAh_WEzE z^Fj8@(%GZP%tB@4C%(G=<=Z}7c#aOR@7k58xG7AEAwV9*>2&$Eg+m18b2Nd)kB=>V z##6^aF}8!z6CAD=q8;I}Vbt_4j;~kn-uKcAPtm)Vb86l_f8&kA*WZ33-0AoS25%P@ zKY2M6^7^Il>StfcDLega4i4#X@9!xO6?OM{RL5CI z$-jP{^?hoAZb|pvLxVjZe-8cd={HOCI3qkBUzExe8$S~K^g*t+MT+0qgPdT+<=$kz z;VN-~zW)m4ITpt&Pv7|Ptlc4BJd<_U$GXpXVTND3c<{z+=N9%JwEHCc;^jTpE}dT3 zM=^Nj`tu(~#(jO)PGXOrJR7Tum+8>S>o0vtX^Hm2u7B-$;y$`4MVdG7 ziKpVYJNj{CG|S?xXBIE)T|Du|;tTIw|MS<25|F&$OD(m2NX?333gRs+jJP)3AuVw*fq z>4vK3W`gRrO&X`PNufT8eo2@WJ>set#}PNr=U0!)v7C|wTKTinB1|d5l5{t###c(I z#7OyQiKxo@kz%0DYDc6ypH$Yo_^hC#6pbFK2t%hU=htt$iADM*eYdUR%@c1 zMsBu~lI6z5Q$)3QJGl{;Fgxm%_2O?b3YRQp-m zxtYrCkr^YYO0JQtr+jX{wMwcpB;cBtB;Dyk1rSwAU25V`$!1QJT`tenqjHK8W2;P0 znL@H!W>PsJr6I|gnWGgGGZmCf<+l8*#}Z<1Xok#JD?pMAMpMmr_p zLZ-^hm_gW)#92|pM0Qz(r)HFOH-6E*T@&(MrGwZ_IdxWBj7-k$L$b5eezHN8bAhhPx0Q9> zH(8k*_DITVrBRDpKQ3oiZW~hArOZo?o0`Nik@;!+D~r6g9GulH0fC9lbNvohk- zev*oObZp}B%KH=-3sqFL*JyVC@lZIx2D_Lshva9keh9__J)GM${%cJ`Qy% zu}jfjy~xCqXPi{llmSUg=x>)YD#urf^W&pekBX62`s$cAy)Ef=mswOYEJPMrYW7g( zv~*Spdrm_ zUwSq*^S`QhQ;~oEx2MutwRrh=`#pKt6QBA0%lw@xe(&b3GkQ8d_+C}LvHkW)`ptLR zs|tSarG-n?UPg94b^F@M*e?I}=xI#+F)tZ?zG6_sOn>ya}HGn!o@t;DR^I z-GA@Ye_88&)%%%xCgaKMAQ$-ivcE6ZI2(S!*S~vGYv;c`{_?jc_J4c);F<5@qMtqUNj}WqT0UM6pYZcT5^F}M>&dsiJ^og`wfEcOAAfuN{rdUUZ;u%EZ2s2QYc*PLwRU`BwAD_&{j8q!x=;Mw+BEWmHH*`O70`<( z&EnJg#00hGLvI&POeVsMCtUypD&KOgk46X%C2`#n}f27aM_msDSQSq4>pZDjKPl9T3pwKWrsL|$) z^m8`z(q_GDgvYdfz&t%^pPsFsKB`f`xA4tT&+PY2 zp2^dnWpDKMffd{1-(y)NZ=dqVKGryn)$`Wb-oefA=>a_-vNk7cz4rUmo_zE1@An(? zId6XG+mC$rRL$bjH=p`spE;cKM!tF4fBXH(3b)biu|C<)jKm6S2^NUYg4%J^@4*Fu z++nuayQe%GypYh`N509ZPG(iKx>qk*nLJ}%JfAf=m2p+CS%*;Ev5Y|b`!jQNbM~7r zGKR_xi;h4Kkg7Dk9TuR>4N0z(+gk>9sddC$ax#;Rn6#4QGZI!oci5JEWqDCMrH8C< zIrf!uBGqhL{Say6g(T#V-Nc>_`k?tFC#!x|`_flVE^AvRZ{;SZx+Zm;kTjF{75z+F z&hF`gUQmOa%-3i)GX2IR%PXC1xx7i}g9A3NO>a+aX*1GaAi>qzByWEdBC5#S@SSYPzK~r?yC8i?W5?R8uMi0j>8akE63NRAnhA;WEj5 zDxXq6t;l7HzAE3;dtm7*Em6*uC5S>m`+6*cz^K(?QflTDP)*4vuC=Bfw4D4g8BnrE zl_@0CRyA>TD|t1Enb$*EvUtlPQpo0ov1H0-ev;bU z)GA9bNH{-l)Ve#x29xu)rq4PE-Fq?#{{ETS`pATI&sa7-&nPWz{m+7A+_(eNv$)q)S z^aO!0D>?;{LY9 z`paBefebrvR~9Q43OZA(6V#lx*8NZ&i+95htEamf6X&rLi49a_}(x$O3p^(wZ z`x@^F56P)kEafTZI_F_?s@sgqE~s{IsnkGzs+Od3!N7TpHmdu`@f>aSXX7jDErN8=z!4w2| zsSsgyYI9>&0jD*iCVPMBXoM187u!vX$SQ>cAZ{4rNktf80)Z zDuxj4s>Yv+q?o9RCJiu$QJQKV@mtkiA^b3)$$9zv4=NVWEkFl|rHX0h=5HgboCh7T z$V>#pP2eUcJT-oIW;Q)G+*27by1JEdpk1{G1+^^gQ^FMy(&Py;A~JY02kE58fX%hwHl zu-833X6|=74BKZOi*Vx!QRg#ZgBr6UvL2h5+C7z&aZTeZ6JubEa&JUXNKpv^Z=5(^ z%lBsXOLjLEBT#mf{lyF-=9ShU&)m(qrRgC=NSrR=B{Ng=4>YyHnw4w}PY#3Avi?j# zUn6Y^q}GS2-vR7kb_{C9j-28LW{AeQUMuJ5-_HxsSaWIvcJJyXnYERJtbKI67ufGj zxV5cnRT}+YwrV|W)1vVq6ocTP=-c<0N{D@}jh0y+$?mtUk-B{VtJ5lR=8_j}fLoHd zA1nxk47PE6R&Fr=PVpC%mN|gMQ87mCh+YymfU^C$9uD9zm#9O~^Z{R!z9~h>KA2xn zyR)Letg6x@Y#dZa3WL`2nsKWEAfI&Efc;CUr!!n48K`)cytiq0>R4Nq8Kjm{zFJio z#~K(gay+1E9*Nd9y`vB?ZW(f)K~@)wN@=8>O7%#{4WZiIsdo#qQu z5^8Z~ZDne3qB&-d3=me)_#KE%)sSu@?Hk|B*^KI!OlgV+2VLYte|K%;FI!1ON;(X; z5~7DUhKX}J1>;>~5d^+lP33&JD>+i~bG2t}k65<@Bazl;OA!#WoTH zQN@r(+&7j9gAcn~TNJfwrxQbx{#5M|MZ@(rDZFfjtd89cldP+vY~_r}&!;Jt z57*o=kJ^z|AFFU?Q~_fB!%Y^g3;i{a8vBQdkCoaKl8)vIY8<%4#I6ALm}X0wCze&` zL`|^-Dv&wNY*ng5&31EB8AG97h#I7LDobvXw^eN(MarQZo%~BWv!dVon>tBEhFb-) z9>~g_2m_-Zt@6Y;G*cL$K+Bj_jct##N0Cu@xEV=CRzb~Wbz7x7n<{NR@v(rI1U|^d zV(rAW4E@=M1DCbp2P@ZT8#;wmN?r5=&5YOrw+Mty3JAuhdw~37R1`eZEkpNK<(X1D zwVh+*Dii{G(@K{6lDeSemder8>+Yrq_xqmUw? zvPl2i{Y9$^21@92BBdey?3x}nHCFO-Ru%ix-3C$Pb18x)*cJ#v8hnZ-dK-R0M5fL( zC}CJ^uf!?2pjc&W{b&{T8k3@~>E0&%cXpy=+|Kda2j;5S1RV@%ScF!#3|1muJG}OH=1SExs}OMAeF>SZ_KqAY#{P6 zn>yMnt~Hgaq}lm3sSyjxeTmO8dqSKAoQ7m8`LIL9UicGsQFWgah>NtJ-d!x|CqYWu znNu7_71htLxvn5umytWAQGNgoHHMNq!!z6vDZ3+_kAMahl+~)G3uI>1yN9_r%+c;g zCtWy>eWEk5C)0?ggs39_bkvF#*KZ|?EyjH@|3)Y8Z_bWZ^2&UVShbeq*CXRw91rbn zb(pqK&fl*IYC?aeml#+q8>Lg56WosTq!RLcD} zHMb(d6{}kzR+$mQ8)Nmr`q*O9xQ2y9&;17#+ZhD45G$A+p)G9NxuW!fp`lz7piQPi z!H-pWYlr->;ep^{%m$9jS)ZpK6ko$~&|oVeVt!!zYyV{TWE~*PFt&T3D73k2CsSZs z!NdD){4pMt&L5I{Yo;GIvD=%It1au$+Fl_6rc+c9yeI%SWl-6wxTiS9C?I4)mABq9 z#JIFYBY&z03bs%xuyrI%hK_kGrG7m#9HmzSv^O=ED(Y| z#;Y2A|1sfd6^sRNJ$n2`#9nHf7vM2Yzl!tC+_AWbxPi&fCB2s+DYbwYlmloVx zFe{U-)~|15m* zi1%k&DR_TV1!L!-#|3fhc19( zht3zZ;cf!iaKE2RXx83qTrK*5nC*=?MO^qc?{oL|0^fAWC_d!sa%bDo(z&6-EyCxXa1m4cs^3rjhawOzePYm-z(_ z!-r&le`Zy08j)Y9sprvx|`(!yq_BWoUXeRl&Xfs&Y}3uDGEG zZ9k)jw3yP`2b#rR(qc}re(4(b$5Fsl-JmOe4j9?eG?zk*yJ&v};c7jK*R9(X3m{k* z5+TNC?FmE%QOYb-caZqrdpQ^n#yDXV8Sw-FWT zzNQaWEhr_k8w}%w7VD^3L3OCK&RkRwupnvy&bM_w^42QFn`YzCCxTStUM|FZr*X_U z{45tn*|?#hEd-^zIXD9!zBOE*A+9Q13ydqQ%=Iq@>B&N@UAb;ktgAliJYS_c}k6xnAla~Kyf0F z_5_~!6rz-vsQIy-8 z#M|6l#|`tQzp@D%L&$lyRk;x^T7o$kNqlm5H$!tf^h~nXS;=K=Bl9wPW=5v!_oQ)(^8EB+F$23N{y=SD%5BDx@HB-*j|_py#g8;AG^n{<8F76h460UY9u=_ zB)`pLZoii#m6Ws(b{MgYbK7R_w`wyMfrNwdTlabY^qw(%bm#)+NT0LAUR z%P_q7X`pq?vULzGVnJ?9g}`i@V97=+G0K+*)TSshYG@!`NG^~jyK5nL+Cd5ehf$De zw(o8ps9poT*mx(c8lbl239d|`7mSbntc5)?Tm#Q4+2R&cbQ_}G+BP2jEQ@T9891$; ze~3JZk8G>K5&?L@X105O0^r8@bfmoqX60)G(Q zw6}qGB!HX53nBryS?`E)6mOqfIYMf#SXoY^mFvQR z-iun}HpEHf}ohV1f(7 z8y(-GZ-9pUU^`|6c>4VZM|Xri32z;(Wi*Ac5K>`*;z#k&_J+3vsdC;i4|Fqi5`yM&seay_>}GltG#tq5g%(RmwHZ44wZxq9BuGP zVr|4K?9>FJ!U_@(UM&0?+i)Ab$Ws&^Y>l?!++)p+1_`h5Ppy#KAkE6n9x^|L0?Qx( zuH=y)G+2nJ2ejX&j}Nc;kG=KZ`>&hJNb7At_LjvRa`8y^SbxM=uW7$ATjIrO;-%kf z#sy~Rg-0tmxD8Zkf|0qlG7vSn5z-43$np;(;J7)8yY=YH@zDKcdF@t|-yo83`wX~{ z+@fWgZDIfo=h!~eclV&Z-Ad}kc9|RLm4J4o9gx_etz{51LLqd_tj)Sv>k(X%3C4JCyylJc*N1SYeV9*YsV)9@Lcwf&GgP@>&tQHw~5bj?)&wjp%1) z-OSvPW)045rrR*Nf>*l{_jD+PA5Vz=hbvE6oSD>)2XcZ5HCVs1qPL$pu@?NvB2 zGj8E@xJD;8M^-Vt8K(5R983!mpFLB3b#w8+{EFefe$C_r#9*jCC0V9}&4NGNiOQb2~}auWN89vD;%ji@NAHZ&qy zVWk2yQ+`&?J0$gRoNsF^AL)eK_WmsQth%y7?gxKCu4_$W=+t|+VVzETq4xva;^fmu zQ-WirhYL$D@mbw!%&DPi`U&LaVuwDbLxcS`Ey<1}Wb+ykpmILY8={po`daIzMsuFO zr{LWBd}}!hcR_8dGdc^zyU!H_(yX7m-UIho;D24{dzWMfki|G-4z)EYH%xq-Ogh?y zlNW%r9Fq7b%&%hOJ>E3|lH6T75fz|h+{E0UWm6xXqYLx1!wXFeB=7mB-^mH5BdGk2 zalE0!D|3x0+u~y#n<;k# z3XkoJU2dXW()Fm)wgK_saPGzoKBnJTbLi(TPBkl0cL|t-n@NnifrRz~$8TZoY--xd zxYufqUYKSx+f*~8u#`B5UsDf&(D#6iKg5ojogZnpX3J>^f~wmP zN@HpCym~|VPt)tKjvPkxa@8b5zl1E%^A&R%{q^9KiM|YMNHnA?J*j=Bz zh#b)TUu)@EF)lWgkfK4jUjs|iSo~+BwMD&6t}|f~are4mW?f~mPxkI@IZCYZK2tuN zgo8M`R6>ZZ+6b$heuU@)0~%jtE)U37)f_mOYbKte3qrH?mmR2E4|*2M!fM7X1llM= zOzaR}>TjRdEb9_Q(qHf%s*#PmzlCFq_@?79bNr{GHko_1l8S(GYID;k>#BL?R>V}m zx$TEo*|8pCm@!<*`PK=0Hho){?NOQIGan$z=09$2E(;zov=N%}PXM zU-dS{FVf2u3_~Uc8cx&2AnZf1&3dCN|2(;4cw8x1#qH6rqe-IOS^pD(OUxTKAJb&C zmm&{$Nk)^hJa(AVbGikC+0@0sb_Y#<`r+B}%4O4KD6(jfeU#)!6Z1#^_qC8gG#yzk zRyT|B3*2b>*8Y7htXd$Gqk6z5Vh5CT#IvHbS&7(V(X0ePlC^_fG+fReOjRJ&9C!y3M2?fwY~xjJDl{C$sA*LMj}Uq z5au5wI>%Q!T_QLcysvjp^+&=7E&cGu%Fx=}y!CK2g9MyGn@vmuCz^T?cVA=o*}IT% z6mYK0oOf+n*gz47cBy{_VVKmDQqv5HjQXXc6&oF9W3GC*yD97O49)ddjRq`_TiENC zLij1Cg&&ODeD*<%#mK8^4x%_LV06!v-+V;_F|L)D{esn)t2--l0Qn2tT6{4+BzL;NxJ^VQP~jsubmZ@bW_mTb`Ae}+myS_}9uDMvONCm6<})#%l0nDi zNRMl$L2R$$7RiqBJZE9H*1?;qJLHH@M6;XpDr=sMvFi~NxMFUuNkRef5;}txPMZ+- zPWr=azt@69_qY5dOeS0Bti)_i=+vYWD~Mu^5oqkkynYmw2xwc}7(>W7MnaB^sYV<1 zUiOrOwr<$#DXJZx*2XD*mjy%xWnH^pK5_Q4dJ+vAo2YB(5>BU& ze4n|q&bTrAWYXDvN?#oAOi~Ykc;XzodY&5$;ly$K9}|?p$g-u5`+HSCi|d&AKUy zcK9&x-r3Qgr2ep$d%5tXLq)f)0jXnsHpDziffcw#1j+7vZ@Yaq*uJ7vDBpbhAK^N8$eSuaaXXG z!-=f&q^pGrS5ojR{(|Ju*J}#6K+TNeO+L4s>pBbx-!lPD*RyBZPJD zzMnoQi6c31tHL00TH&8e(^%rBFA^T@l_`14P!ske5hG4V33%KM1)Iij4kt8IS2d9R zbwuZ8h9e%fleq-DHM%3oS`4vV_(k<8OFq0Ao8dtZ$ofxmo7ed4e~It?dp$oHDvny3 zj-!Q7_xi>0GSVm-e~Mh(&eiHAI&m$YT6b5LfE7i=Vl$(JQl@u2pr`TKRhfhH{?l?K zN1))su6n>PQD^)nquglavnr{CCfWwBYpF*KOkJ}?hZ9oHg3q+ z_AzY7)-)jg@>#J+Bz@3P^pGguXgjdEvcwB_w=jID1{8b(_6FKCt6s|^TOZ?i>gS{D z0t@ZZ@`6DJyK8Y>EKkaykmAX9wC-^ZcZ~slJsB~g? zQMGz=77_E)72Y<|33IO_<(b-_Dy_PmF?xO#<9IUtUA>g6m{mV#3N8ZT-h4!0Cq+W- z3MU<{r^dbR80Sthqac~rwOXlru~gVOmcz-ZrRZv|j8bD0SOSx; zebCtUDAukKYR)(-k?JXT-EiPj_W}SA4LsNa`Hi1HgHbM>x-$zhclFfVZptL5ykKdn z&Iv2OSsU6((ulPW1U_j;`HsFJLk^R!3itlh)uTyPnG^DdLFu&9&JmW(sZN) zAwb3%dosWUe6>1MAa1(5bH$lfJyOdWWMz{k(_uYC6J!KU(Gih$Y_UY^6HL$VQSvPz z^5Od25zY`b9lcrGL_|fp;&idqlL>eFD(YZt!1A9+d%;q6NWJ2bw;yq$MoZ3Cad-&j zk;u8f*<0K-f`>KbRFNeKs{iaY|q-mVNVjTV2y36hoB zRqMDr^byQ*y4_A%^EFQE68}?DTdYITGtsYbNyS)&>^R97_t8FE(vdus=9Q&A!l~x& zHa_lq`LW5}Cw|#n<@F7-i#X-mt??!pm3?68)5iU~HD|X91w#zwm5v6?n0En#+_BJ~ z$fJiUOdqC@cqYX#ysam@t76IeN8y~}!VgJAb1zP@!mcQXYb4%i$qh`GSQ_2F@y5)E zw7F=a5^BYWyM1on9fWV#@gHZ9QN6=&lOrK)Rf#i$!h$BS`JG~_c#h>1PMl-G%*vq< zpC#K7Zn^s1wmIu4Y7r%0ls3sR zNE1;d)ih}t(fUINm5Yxy^;;ex+a#{wVrOR~^jPyU=Po|y4j&4)Eo^riaN)H&koY!N z#IMm}D6uNy)RSX_o@FfZfQ2c^Wsww{+hdC@=k}@QaN)KiQ7u?83e3{y31iC@PPSlD zOmYB{PL(PGXV6^}oFzC&iZQw1H+P@ic5%GcFj)A``% zD@~p6xw5S7H~W!Un{;dhX&qXon0|Lmtd(Fq<48_(j(~solkth- zt3{vXt8gZ43qBgpv2i;8a&Ikss}92_1M5*fB2wS2m6ai#ON-?=d8#WF;>&X|tasj< zrX(RtMy2you|`G0wq^oHlnH<>hILeRon@*KV|n^?R{ldvZ(?1xTiG_c)!Coaz!X0U zXSFD~(79Y~HCpSlzRS9`-{q>f3mrx?>uRZ;%PP<&Yg!(CsMy@>BhLzv6g4@^pdX{# zoqcu+`BJqLQz%t1dGHrj)IfX9|FB{Bo@A5f9C|OTqJ%Xg>-LVX#p;RP+&TJ&x*A_; zSoCumV)$klcImC;QoEVJ(nS|$jgel0ibQwx#Iufm^7ZG0$Q{#%Wcg)TA}dw?&9l+1A4d@?}k9- zigxbIe=PlVURk-@uJPWOo~S?~melNW z+vGO&VCywPGIk(L5HWzPmiKwRQf; z<1SM`B;xU+u+ELxTiKb73?iIye5~D@Gi2SYyMv?ATp{T?ch?ki_jD)%{Vr|3+zH`H z-rj2dQZVby`N-nP`-j|V7Jg|SDZw5|7&`56P!|{i@+QIY^Oht(9J6VZnN(^525-UR zUK(riGj|}FBsn$GYi?1i>mSCtr5MTB90Sx?*E840cW zF(*GPHYcz63=w)anU>QU`4U~&Im4+Y=hyabtr*~~IbplzZmrqc7nx-q!)GfOEj4A- zJpR`vSGUjK$%cu{FDI}a8dUKZ59MdNzsUxYpQ$SpDoKL>BSjywKa68)P}nugtr`M} zT*3$3rDahKJC2Gv*%AyNi=DI?bw*DX--+B9m(!c^ZgdJzPTg=-`^^AfE}6}FVzh>g zH*V?lVp091!b~^$Bk3-NMI?ZM^6c^kG^fCth(ii+gR=hl>vV@@Tj- zo*X{SLpO%RQr*`-x;X6c6>Ij+YaY}TheOJs1oa3B;V~YMLbsGnS*IJLv9`s z6hB1`IVnbkMJXBX)?d&+-rhO~_}{1`^p?JJN0%g%SvwMR>fD|=+#8>5{3_|5*+8|f zyErI#B=A?~f&MAhk2E6WZ3_`w3xmq*ajq|1Y+kBKOaACJ|I&tzJgT-R`>FPC3sJ`_ z>SSs+!Mc&47=XfH zH6!W}PoyYx^Giw+dcM8Wo60~n-x1}A4|0E@Mr$~^eRIyGN;@hU7u)Fc9S&R04}J?r z1V$V)#F0{mi;{9a{2P9lXc&tM-?2~RP};G8L`3BtR}v#~?IE)aaD4TD{?GsG|L#I? z)}7;~EWa7=v%N~!ht)wrpO{|zgD!$V?(@%~<*cjm^;fs}#Kv*~SjR5chr^ou^Lr=n zkq!RiM0D{MB?*OeGt$Wm00330+!u4Epv=w>4Q--y&(`fe-U30*X_ zoz5G-Zf<5vWoD>(i@vgPMu>;44rD>(T)M9* zT+UduL$t zHy7Y7L%$4U%&z`lbkHIF4m@H&ma+g7!iX!e_zawkFov~Uh|NDa*_A_;VikR8GewLt z-_fZgW{SNd;MlkIw`Za?tjFH@!+C{@HGs?c8qxjEzrDx%5 z9h>|?_2YZztg@rmx5#uYTlse|R!~HbiM=PfKh%jgevl?`6JPdv$^gp)-y#0=dYxj` zsW(0JU3uXZ{&8;6W86}Ts*4?|J4D|a3e%7N5PdabucdW3KOKxx&&tyYo+8-{=6Ds`bM_X85>@f+3piVd`L z0c0fFj#8ACsI`7gXKr@iul>;)gcV0$Szq{0XSa$~B*SsI*8$#i;b`2VOC#$*WQJC> ztKe3yXqUmQ+$n@Q=eD@`LdweH{eExyzdtp1_44e)S3eksq;@vQZsj7q`e9PT<1$U@ zdz|>lv+Oc@1b)P+B7~2c*=ePR$Bs_DDg95|MsJ*98>NJ2}Md96N($N|{1+44Y8_izvQa_pE=6A?QN_ z3c8H}i91`*#~DYh2vaIId(nHPPnwTbqCsO94K%3>Ht_$HVLQfS&lkCMR1-$?IE>R4 z7-)8b>=!Gc2I?C&qiJRT_;R6#8V0CX?U5Ep(&WHp@ zjM|PQ9$MeCJ{(J*+iZQ%HU{>F5n3+{As=Udtkw)Ppx8^bk6A-5rqXC)A62lRd1;J} z^2k9}^cMy1p{wU?Rk9g0E8TVO>WAB+V0h0(q;v3`Mc|BIpIb*Cvgp<)<`ep;0krDW z6fam!c*a!a#Cr4-5~AF;FqUyvWG8-C6aj@c;2eT3@sr_n(LM76`n-CM0b{+5GwX)J zl89;-owE3S8agkIRO?jxVVzWm*7{c*!MYoNlFK>Q3rm-`4-sfj3$T{ZYx4_cTf1{r z39_SNJ0Vj~3FBCYv8(4&M%m$GoZ!oz~^Dz zeg^Rtl5PHlc>C-!4)EFEJ(J%%G~vs?eYrhXsJub$4IXdMbPH$rcYC|%f_QtTu!X%} z?H0;!kZSdX3qq!8UIA$HDUr!tnEjjC`P*&xN1^doHNSFo8Xtac54!84k|xnHu}q>a%c% zy%9vud z|32@1y`|eXeAILl1*wgA2c*a?W;3WqI`HZ*hv9{0T z?RvKD%QMiMgU|fDaer+E?Ngy1pX=5y>uP3N3%c}Io@wvr@60#rmw!V&(3bO*wu&z5 zvu8s$MMn)Ox-grK_X5n*LRHPbFjb+m!e0$8Y(tC4Zr2k;VW~6!l3-1SYI0&%k z{W=AN<{{d2onng?6jZbv#lcp4*Swm+y4{`-AVa|Nihh?Y}?zPW$gWfAEKYV} z{#$?ZkN(CV{=s+t_IHQ=&fmS|@BRJ1|MzbByMJfsyMOy1{KJ3rkN?R({b&FDpZ~Le z`cMAxKl+FN;9va9fAz2b&A0T? zG*9?&TF@txbyXJK?GN6*=CQ(Hs{C<=LdrDx5o1!xr%R}tNQ;YCjIDfLX?nN%Yl4*} z*O~RKutasD>BpdFFtant=e)6?Imr1@!O0Zj@gYwDEXWXB(9*zK6hmLlZ6UX@*>j%J zGW~=>*MU$_W~)Mw=01oq_Xas$q07 zWL{Krm4qO=v&%j890Fu}OP`ScAfbbjkSEH+*jX;O48?_5T_mKTWvPY}F|wr~{N*6= zau9y&S58|D=uMQ8c^XdC;>x7pB{9oH8#ue-oaJ2{1V=lh0UB-PSy3kFos=sn{R~^{ zP1*W>L&I9XreS>=YV*+vj!^R)a{BNU)YECMJTOT>WmW zd^l5Er;*NmZMi0U65M6ROMi?0Uj6{LVo+zZr%W-rQ0hg>^4mDn5L0|bjjU>#K=Z}? zd?e}e5=DF^F-QDRYCjT+8azAS{E5EmJmtZiL_P6MBfS`V{VB6(dy(|k3bStFuBW#1 z1#rK`rU79YgNor-dE0SKCE1OFH)4fabQ}euizb}T21Q5 zig*zu$_xQi4$x*ks^ZU9Bgsn;k-BJkKVJp>kKNnyousx)qK}hrdPXsLIL20{eC0M2 z)r1uoMs{6we}xZt25}0;6k0?8sV5khw%P*39KXpFDBjyqIL|^h8{gnMDcqF2P*kpr zRO4E1IGp;b98s>X$cj`O5E@o<7fcy>-V%MVQqGN#iIuCYrle88&S?<1cS;$pma!cP zFou!rHYqdlW{Ui$=R#o!yXZ^dwszcX&ATq`h8rPa$o{en==wsUqe7XXC}U1_$JX&K z27+;8anNFWzmYMO^5B1zxzF_?M~^SK{bp&wuD}95J^mmU}JU;V8yy6%>)0Y1n@3Ov>3&18fBvB0$K=>Y-%Xh z$x=_8k8d3tG!c7omt)dX;=M!7R`cQvvTTGw;y)%z&@6|e=_K%6vT5L%{BGxB4?AtS zN+W2tMdNfVznQbZ3P_w*MKQ=z>>NY`9*PijsOcyUKc!>Nryv00p_Yob!|*M!BB`Q8>C%vXeZ#dcY#3fZHR)qy{2f zkfg_L>}7HkBnTvsvZ)K2C+J{>4fVdx9UQ-M!O^XEeF64Bv%=Z2TqSCX9=1Y-r`P4k z#O)a8%dYV7c&vj(*1H$8&IYfN9pBUpTN&)0AnaPgq^&#m88OAoFz7DZz(xZ~Cuq*e@Q1X5+814rQ;tBQb1)ifSV7n3OVo>*{rUjJ_S5|+iP#jO@}H}axfYZRfx zY<2pRxYaW~i$7n5Dj{{-1Y0Y^Z8KJ* z{P$}$NM%CvUApM**+KNpdb9waOj5y{FcW;Ev8KfCj2XkVEH$30o=e#C_EPn=+40_( z69j8AeMwnN0RE&6Y7iC?E|rPMKxHKCj&gRfYph&xuvs=l%~uHYzj94(^Sdcov4OiOBcW>bo-nh2;&A;-O=g#ISb7vwH^Ty<_9?ocTn zQ5jtU33I4yHndV$FD4ofB-0YR!0uF}ieFd+ybCK(P4j4>Llt*VSyqFXKyUn!EiZG5 zgBll92oNQTu28yYY2q}sG9F>ZQgT!rkydJr5`w}^%n7v%P{;+sMc}ZeW(%9H0wO;c zSf%i`3bG)8MDEru7jH>B)S;$cIz*H~joneuq8Ols4-6Gkv97{#Lc&|hh`1lG%9>6V zNP6}^Jd>jvqZ3-A;*f+);L49^&{j#80oqd-kT8^5lxa(sT-NJQ7O+ZNNjIO_F7K)^ zl#p$&Et*|!oQ)w}x?~})aaLDSl-?~)UXPkMzj|PxHl58v)q#y36*j*bize~6cS?3* zh0%zlm~XCXQo2oOZ0r2`H7&3h(97`OR_t6F#z3PkqOC*Ja`JT{_!WLHj2x81?-Y~pz5E8YK(SS|i!KlLiU&$R| zQeA=4ltz+nLSWn(t|L%az!W9T^J4L-xa@0SS(Qp!%P>rIddGvMkQ;m}Z%9lrd20az zm=>ClQZM31su~#PKvSyMaZ#=egiYaVm7gq`1Tp~q59E4A48aw3q_NDR?mh`^B$c7e zfztWC|2RN7fC`z`@5l3Ey>j3G>!Qs6+CeZ+r7?DHjbtFDFqu3Zq|h=-5eh>9s2T+YF>rCGh^vWH4W5i!yFE0bZV8g1O-6ZQ1WViHn0>r3y5Y1LNsL`G2NSC zkP5{nB*WCR;0|Su)+*(eb>))^kf^~?BEvw+tyfwd5j)u~w9u(Tt~Scx z+BUa&Cr*uiRj9Pe0hAibH+UIb~8+Q-8PT$`mx z=R8(wb&B+eYRlzZb4V6W*hb5uTf8~-$8{hE3Aj-_$|f4KK7j+prGV-cs~l{~hLB!K zt4cSEl7rWDW|!qis{eAOfi5~P(ZEOTT9z&;mtO$O#L_$7f&h#k-RAO3@l>mBWgEo4$b+ z46>*Kqpj0bir72PRcG4P9_LrVw3rbK_rFp*y+x~SI)k@5jH_-L;; zmRrNUC zPB;}B9&WVC=uiPVf{8cjPL9%&Fls4wm2%l(noi8sVNGsaZ%cO?2j?Q${kldWEj`}g z^GT_@N)bRt$T!lw)f)^XS)(J8W=gM?uiiLH0021a!X>oBWS^u_hqoZDZc!VB z2bhHK-D%Aj9R2ow)_eA@2k*V7U?Bp%KCjXwUe$G*CD0<@#vx0XSVpF=<*l4-npuxm zEy1d_b$0a19Y3hwWA9grqgw?!*+i;`tN@_;yMh!54Q>CAqW~v4WgnUNALsu2vwpQ3 zqyPQ6?uTzLKK<>5SHHdR-r}n#7T@}K>7#dA!rsEES6jxOAO7E$-}=8lJN5s(a{k(d zOV_@9;@ZUnOQ(-sKmBU%s{i<qWie)G!0b5AV3_1watqYJN}ymskVOHaOZ z{i_!j4_sMz>&&%FA6&a~_S(f4Zaj5zVgIG;2M=67{`KPXzgjx_?Be@x`EcoluNL3E zymaNwYZsnaJauC6rBAMX`P#ziw{Lv(?9wMsUVrz=g%4g@c=5Gsm)}@?`OM;Trxz~$ zdhzoEOXn{xJ+XJ;&^rrzKVCR}eChPbrPG%dp1rjA#o2|o&MjQnv-qo1i!Xg`JPXgh zdhOE3K3O>a!NRHcufKF;>GMO2Z=dk@b<=raUmCx1bn(egjJfgol%mhdzGB}}JR*H8 zor-uWSRy1m!$Prglj_n3*;yT@l;xH?=wo3<*Gk+Lg?C1cBb`!m8E>lo8QxWW&ig9o zExA0oHivChcSDOH=AkqkF#cVq$8YC5SAS*?zJ z%fJ#QxcVUnOU!Njr{!d|B(hda+qy6z&J>h-l>QR@7G_nYlE!ZEVNe)(irk`uDPq;99PhE-a*6_M=`-F?Med1#5 zb}6{~vt9D-QgGk8*xOg`n`m!q%+^1Fa{T&drY%F^s}4G{rsDH zU9&Dn_q-4Nzk12_-iB&Yt#>znw-Nfb-5UP+;433}rDo@vaNV^bxu?AK)h;RbuKU!D zyoQ<|Tq|xC&sL9Z#k}5bD1W6cPHpXT4fz7qQm>GFtbnd)H8P+U{}J zi(h?Zre4v$pZ%Z_&$NxJThg;BwQ=1#TPRCyvai;Rj?|cq!33>_i`d(|TBohA_PTaV z(7PUPx2+!;ER;5lu#M-13?`j6LaHg}D`RV$?vq0`Y+YC)pZlTS%Fk`(ePSeLVP0*u zy=AR*v`w+NmwJdYWzc06E6ZD8hpvPS4zyXnP7tDdnt7R_i~ z5AXAuQRd<+%+E&A607}XcM%FtuG@n{+v~>sY)uf6wDGj1vk0DSowlL+)#g@5-I$+<(bK;&j`?u_V>j zbeKKvsH7z_Q}CYeCq-sm??*rQVOrkOS{@_;$+ZyXgGx8Gm5Q9Z$Z6N$odZiRCejSm zw6d9tll~?zyGfNq0oa(G>*v1a(!ySefQmns69A)*4beHg{M2eh;|3q(6|A2c)LBUT z83eJzKjRT-Z(zMuf8TDIpe9OsxOG+U)}L)!jmdY>v*j!#RvFd2JlQpt=;mX;$gSxq ztr(0&S+G*|PDxB=J`G%NaVl(`O+=%`cN0D9tZm;e`-J}_nptFuSVfcL6ggu~EY)*K z)DX(;WLK)GtW^TH6%^#_;g}3M!Gp^?ct1T&RU`D!XQCxcG#%gZ=28bQ1qEZjM>@U?OLjV zcQ+g<(Gm`pD31u#8jkUOJIYLWbg;Q4QlYShAB8p(X? zRCHNp-lJ`|tDwUF^6kCLTuS0pCFV?Q6BBIk&clhTCHv*C#n>fz4bG~ubSt^blJ|F} zJux3fZ%#ZCu~*?VQuqP#XM9P`YPnQ}f?PD2{FFvR%ckPk&L0j=9`hK+Zn8?c^aE!r z_HdDbN@LZ;6$}1xEN0J4&kYUTPUDZyeK}4Ti^IIbFOul#$aoHm)oMn%IbOj!gXM{+ zc~pgQ`HFKQr7QUBK8|jv-#iyv$KjyVp2wXYHphC#){^M1Gz+hQtprn5NRbvnLLmQ+qKf_E-O(gWlMOBW5Ur+#4yg3wljEp z@^@w*PIgVk-BM0>{%AlOxJ5Y|HVm&=c4|8lxVR081gq=ggF{s82X=bm0a*&^Y{_)1 zGsx@QNwD%t6`dlw&P~vzLkKzy@~B+)Bc2a1T7ze0b5yrJ$E@Q?>gx-RZBNa)+{!N;Zkak*vs?ivH5FIXfChISxjw zB#$M^LRVn2ag~vHN$ae=%&}OZYPyqZky}Z4!gD5bGhGN_JJ#9UyG4D4H*|kXclGiQ zm*3shk3TxPPq;uh3l|O@hO@?uPV~MW{Nd(5#4;D&KE#WveVj&bw&LhY!iI6-M z^a--DFl#QBaQ=kalQT=gYCMTo{c%{$<-6C%J(UR-P2p$!K=5n55QfWB|>XzkB zqW81}&b-BwXMCYA2MtYj(R*){E|8)4(?ynCAUscrhc%lH_;R0O5$IZKbcL)!O63=ja>M66Ya@)tK+LuiB;UyWL8Zhh7 zpIMEdl7{k^e9A4KsjELXdfX=!)y{A8>xUnBsws>OE57m5%AblS@?IWy&gIGRRfDsT zD^8)<7Laco&h6rimXi@z;)BatWKtP1Z{w3A1-U)gE|u^YNmy1qUnuGnj0vOXgDq(9 z3T3vG1Ei6vQVwXVn7m^uZ@Bhy$eWKlZ$|LfskIdHc`RfgG)ZE*W(#r#d!jMpWf|Z9 zN#p!^Zjiq=&^)YfR*3ksLbZwoLi635G#Q6d@f-;dT_QaR+2y{J+qQxvg9y)f|Mq1b z7CBf3M$#(9DN$R2VMZXut=8+J3pwYsFe^0B`G>w+4u#5saekxv_!du$mg%RPnM@tl zckK?6+Euz;bl^(sZejdHa_H7p6@w$UQd1t%zGwGibT^BmWU!GRFArJT2&+Yw5^)=D z%CI|-a^l-5wfXP>Q`JYSscQ2L!!2#%j(Cmh+vwQ?&Iu5&rJ$isK}vg(5xD-S;|I%? z71KZcYtm)FW082$aisQjdZLvqV*%@ayi}TYjyAQFd5oMVUsx??Mlz+pLt*5{Y~!6n z%fV5C$??wGqoJh18Q;$TG~mEZ(uKkbgSftpd(t`gT(Rlm21(Oq*V@9)-Ms+HWs-n z-I^wP)9M(yk`!EkN*&@0rwM*|g`vY)wJ!Ch=r4{-mRy%4lz{o%XmZ5olCs`*YdO#L zM;#!F&-;&d30EVX^+f$j9xDY27kE^fZYY0ia=-k?X z`CA^VG;gWL{_G}h6vds4+*bXCFlmMS2Lr>%X4mn1l(TFe5EAKSEi zYL_FtQ}?uFPRW4F>8?LoE?Vi|y{zZN(j2e5McdlvTE2l^p5S~iHgb9@Em4ZR>^Jf0 zcGJ+7W&OTG^22!6fHvz;C^~cuH(Zd2Elz-=&v!xTmd#`pV-QsO(5mj9w4y|0lH`t| zY~SuFXJNkSaCEwPB;4ZRXe}C6az~aG%YbJ@?Gt-+jYV!vpoNDfaX#vE(uE|vbSGfC zG+!LJL*y7(J!{47lWCv4kmUk~Ub`tZC%$gJs-*48ZW6GAl1g&69H&MwcAs$8U^%gPIiEczzCeFmkr@!unI3czoPA(H;)Ib+n#`KX zoY2a0f|cx`={}UWS&lpjs{o~(f^~wi4w|U>UChj;pBAr+D`3ZRF$&ZYJR{BJ%Je&$ z#oSpZ3CLT(>MWPjw45#XI~6{qR_Gvn6B!j+=uUj)S!>UbBoaj}9%V~bus>Mr*$rwz zE-}iByPcj6|IW@Jjd&GUB8Jn!;ElO5xH6{umRoc*WFC#oG2{oOOVMcSe0Zf@#}#Fk zZ^UyCM$~vZO*o0@vW^9)TtUtes0y!OGjos-=(0lSF>7 z{W(DRO3C&dvf!W*aAbHPQ>s)^K+`Z#BZ;%hPHy%sV;(EbZICSQuZe~VFCS2pEj@cqX+^8g;bmr%Z<#MXcf6Wf*}17lFP^UPnXkPbJ2a`^>#I~v3V9NaBe<5rK^K7gL@|6$;-v&lUdHZv#b(uDjT6~ zvwkb}LGex{LWXWoA|+l@SbpOC31uZ!(EZo)YS79cQRG&89{Aa2Y0h#U9bnnMyXSB-L)j^@lSeibNWUnS0JAFt($uf1Wt$Ra>0xBI%fUnBB zK0*F1|P6H0gsCpG%QjwbC)U@L8gG#Tfn;z3g zyY_3jLK`qXE51rlZf|^7gRQ=$_ro9jpi-x+^FK3T_}SL4dNcxzR>_)H&iVp2S4n^ZC=ypIHqQ7$;PRpF~sXv8>&+ixbUjM=Mf6fsc%D zm1ke9kQ|Ldk#m6S1x!;btzatTq-MKIUAHFjvs%WYm!f+bKv`(XsZ@g1T8VjAT0!=w zC7XrXNfBgQ293Nc*a68mE*e5Cm_nxfgWeBaN#xJE+m?MY%}o>s2e}^`O&(8rXb}mg zw)57G7(&wv>%y7hTsWuj8CQXOMmrt-mH}?;itWtn_zqJV(jZ68x6@U)y;%v&mh5h7 zwW8t3*d;fz3yybpyFvLF-mU75kcq3x_+pdjS8q)YI?7xk9Ch{FF3yqeC*2VW z`fXGqc`ImU6*9njDw-P`cc8x{qpD*560Qajge#|BIo)I}`vl2{*bsv^fb@VKn7F4?Y_wLOyds>%q*igH>R~ zNFN9Rj2B+f=Y>dgwxLVGC<=xV>?c2c3XLeZO^5w_1}afL-S6kG3wH{xQ!tDC_jTbh zeYK2p6;z~4{+xqrbS4;4@SE~vLz8N_Q-w#B_WVgNUktF5;2d>mRo?v^UQ)1?U_5!| zbGS-R={xvJPZ`G%eFVGel4pXi^d#i14k4=g{1npE7eQR|M$OW*L4FESRYRQ0PZ{46 zwFZ~#@4|G_TCk!nc5f8cm8XIM6;!CK#@F68s=}Bujv8U!>c(s=1C*!q_LP+i!qk;+ zzUR#xZkOjT41ThH>7n6e1tlt|R4}%>ezS^JzWU9W3!AEX4fa-6GHa1_?%ws;Q~AW~ z2RjmMtzcYz;m`Tyd1TaoeYdj57qYsoz3w^aX3H&GmwM+yVQytLtdP$NpGtp6wg1&` zFZnizV^8?x@^9Z*?&Fg0AdPk7HP3qbOs&tw8Xv5(`nJ%?g70N5?4>o$zSSBGRxP8{ zFMKa;zTu~as&;(PZ}7}AmTspSCYklC_A|nugdNse+snhh+t;Co?eS**1a&P4Yf#Oy zAIt5X`R&_Uu}7$+_AK7ojqw%UHz8hy3=T%)r~wVuHcgX(3^;h%+S zvkG~#)~kN{-Sa-n{${jK6oy-7e7=n@pAWF+j5{>?Rc4d*^Ru(;vjOeCU`%_p|7lRP zAUS>V`-9({h@^n<6jZSGZ^IC~7*wc+E(IkjC{{t?+6#Ls$XZaS{G7KwgBo>q8D(tn z?&m%|QFv7_w(5;N(5BiOTGm<+#2}Xyf)u1KpMB)_r~SRhsDjoN1geH7cD}Huf~-|& zR=)j0|A+M3p@eDKJkkn8t}ncm=iq2*{b{IUANu*MmJj7~p9Pnz%{7=|rYm&4&1e3DoK@opqE}{~)iC;u53X1B@%#OQxn&Oi1jXxQp^FtdR^e~KHA_oDQmeUT zg@U4Ww#FBfE@Mf1`h4=Yhb>6d=gBtPpYqAlLetG&wZvx_-Nl8)0P?4Iur_= z?`t+%h9vg;19~$m7++8OJU^$mbDlrz?el(vI+kAY`R@_KyBM1I=4h=?8%sTBd_iWjC(zugw}!jcMqrgrYGIFW zzpw(qDzksp>OO0_r;VlBY%@ZSX~m4}yU#O%Jy|#XT=WK9x&CeQ5035#D$j@^k|I<1yBv<#w)LA3N-7^b<0)dDT|2iIlY79n^H5dB;zxSBcC1J1^?4- zY;3;JjI2ESv3L^@8APKYg>=!y;f*o?1_?f;o;OGm8d|Cc(-;-ftV>bd+=26GH`n2G z!G=v;$*51`hG?lF(LIut7o$aD*n<)kDC{eC&rYuBuCAvFNpaNfwqSqDzFAE@Ut2~< zg=A8!R{kvQuDt2Vx*2EhkNo8RAOA&RZ2iTumNYlsf_Li!rhPNgvo6=1bmQGgQ?gt! zyM~7D8k9|D&AN1?ayP9_P+8W-awcP(63xIAd1sC375(Z&Se!da z7;*1X$f(6wZSc1K(07+Dotv2=)bLjE7W=kCB8zL7jh5JmAL*=JI>Ffgc9Z08`oPzf zvlqw}2Ujeu?)s8iv&wNb+h+A!_k@Kz$M&Y}J9w=%-dw|-F?TQG8nOK@8&k4f$+$)q z1g5gswvM-JvTAOX?-$QxF03xsI74P^{ms;(@~qg>EOG)0Nl%EWFEQBC81TvwzyKlf z6)Q^(&!&t{-cAAM4UV}cQJ{G}Hn{I0a7b-Y`B%G>WmC#QlBEs^Svzm*Z#oxRDPK9{ zLn@vN{oYK=5dpR?&FL@u9p z)6627Wx8;{Znw}0ou-_F(VX#=C#gBA!d7D3bFmKmYStlnhKUne74gT0H^}XtQzd!P zHPEY@iI`xYyDSErpiV_g)z0dR9DwahWkuE|lvv4>iC(xEHo3Vi!TSNkB1JF+Gupx8 zd$vmn3tPw*cH#YSf`G*snmrcwX9v&BR!Ry-fO@m_H`5!NncXdQLfU)_oa!yNIr%km zeY(V>L{9-3Ek#bB5OmGv#>~hF6ZT_csTrQ2w4VI##RoD#D(B{A=EBUznrh#^-X!9%WwGJvcF}W z%tRSBB|AH;r1CU&v6S-XCi$voUi;7KmK`Lt&v=^ zwF3dbCEL=M1JV(g3;vTtBV5SIy_}4mWVP_~&sWM*bBz>#Y=W*54|~g*>myw@Q~2QB ze~p>X^#+)aC67^no$o60xpXQK0y`=0m|Vx)Rx+{6vo|xkZDlJsaPS7Pvz#}-MS^6r zy5MdWpo=5LH6A(+RU?cp9c6HthX>7~%Zj8B#WU74s)R$Vg4b>?iRdi06KFn8-Ee{~ zdKM*=liUYQiey)Y^qcspgxKMf#P+Mr;@7=@fys9(Lx=AV(vM>%9p@2Cqz>+{J zSt1zFjYzTzi}=>I9F)n$T_u(5vSpP~l`Jbj2W(<)AwZK0Nl38c5EH-w1I9LnI5CMQ z$#fH^C+UHi?it)eoRG|9hVGtB&n2n(z3*CkfBPJ%pPoFKf9C09m%g*lx7WU|z4qGc zwpsH?!w{O;a*6S?A222b-*X7=Y#~QGLXx0DsY5GyF_Yl zvkQH>^M_x>SwUev4k$|@0UGLq*I!faNzw{{*;1IQPMsxvo_r-0O(ZYu5*V`6Q2WUM zUbad!=45Y24RyFt2E$*%Rv>BDvCJ<3h}IWoa)VhiqnJw?2-+NbPt+Z zu2_(fjytEqz#sj}vM26{Cjr4fL}3jhDg@XMBefnyAmAj{G9lJnWc_p4f*Q) zF&Q6R0Z~AI2M+YEOKm*g?ffbKRBj4&L=<0wW#Oxa={P>?RY>pifWq!`p-s|v;cuGn zv7>90RKBK57;KX;TVp~RwcgUy6f8O6_u_gq13Bt(%ZOM6?!AKuz|!(& zKT9Kbr{Wt!FG5j;MrH2gRnk93=e0@kQbXKrmfJ=W--!4Wo4cb=adn}c*Y-&S$So#wu|xv}b33;LuHnX8@QzeVgH)kg4sD*NyE}~= zcVvOWKow&YS_Wq{56RV! zvWCL1pd7>5`SNK?D}JS>eX+X+M%gvNR8!Q=mIe-VLF3Q{rT>v|;tXt#-n&DzfSH%U zml`9&&#qt-U<`3nNevB0=S<3TtbGW^LnA4-)TJ#7gj%nV!-79KT`dA|;SBtld;-k{ zc_6*d$w&ZG60Hl&OR(F*jLv2=Vo}n%Km?>?I5+RwS`U#dBc0FHrjEkA`e1jy=sZLb zUO?`)SWm~je?>BqOH?H=0flRm0Z@8tu=T()K{aTi43dL^W8RYd%4p0gS=#F=V(A56 z!of|q>SbVbxe`liNUPEMr9T`+JHj0k0p%+}p6M}!AC12409Y7>WGeU{*r6E`PtV}- z3ef@W<$rF~u95kPEsD{KY$m7zn#xEC3^NlJsxjlVHLk)wInga~enkShd-7263E|LL zP3IT+m0;$<8-W3^gR&i|9;v7$v3X3J8&8Wo3#=9mX%feG&i{aDsM!%^{iTV`JCt>0 zDtld;gubEL#%VcHv8p2mah{eqsAzR3?ufnBAqDi*m?^dezds1Wx9zpxE)u$P;hD}zA3eyNahdY&xk}YuM zjzdn`Ca7#eRIKLY8O6iN=z#Xcssp65VN;O7kg9>zvlv@C94StE3d|)AMwH$%ycl1z z&7dW{(uR}Xc403iQN_+M9gsVlHx!?Kh=RYs-Q#ma6-U}oJ)H^l4@2TR(Ah*KP#C_i^OMdx5DHW)X-l%(%=@cys3)IFv5q z)SKL(%_%hjEH+&^5OKYId|)}L$zoabl;ISdLXBN3FM}WzQ10lPW$E~ilY;Qm9f$(M zu9FX*0Wzo*VLA_?xrHI;O=x^mLUfEJl?1xnmP_7P7v@Wi3p$&!<`?V(Z09VF0f-`u zzcNXU;ZumPTz(v`ZsU$`DJI0rq$D!ev0?=xUPd}fPizQC6hlV|>^w4rJ7v!*Ve`ma zoPsuPYUDIB-&43YHK+D$Vwdk$2oH`ff(Cl05cPt;THR)5*@HPRVWxQ!A6ixmUZ#{K z@*ZK^Skj6rnl!a@fVFr`c5_U>s50kYA-EaH=xe$ls+$x>ERGd*>MZzMtg83qkxcx`3ve8S(}RSXjj}xy175RAzA|i**c} z>c+?-TwW9!izYTcA~{P8wXbNPWb_TL$6-bDXT#gHwO=@Kl~%wt+KFUFCN@U*pOe%+ zKP!`4(QMfY(m0l^Z#XU|OZuj|b0>?PSBQgWi)3vM^s4dAHq>T-e03Q;(JK1xSnObc{rxFdv&j{MLL>AX1h6(&(Mi>&y!676_+U5opuQx5ri)-y$+` zi~>&>GsWIasf;y^u?a353!Bztv_f`Hm$$La9e}7(Ldy5WoH7V_XA-ANT4ma;`(%1* z2H{hTcRFck!1yVQhmXmvkRkC6!n_eapsK!Gs-B2WSo#;IG;dBueM*Jgo$!WpH>|2$N=$c!EN9H$850puI)I z&EBO!W$tECCYmP@dki;b&c-ZYY7kXIEOex-^CwEIYJzEou8YkmqU*y0&j{so@RU?* zj*#~Z591;+UXtY5B~E$l=P^*lf|_!5`pWkDJOnQCD~$}~fk0#JnqV4+6p>vCbKd3_*ik+r%xOx=Hy_BN7Cl-Wi}}(6n}g0wc2@zMv#7}ba< zJIq_deqJ0NI7KL7qp~f!H*oAaf%pUlmz5!g)+AgmgKSK@Cz*@xfCo$An|L(pAUM2= zt5C3GQ9e5U<{C4|(L>>D<-!4;sb=Lsd?vv*TkUFUlTcB=8o45}f%?VXqFL8zC*tu@ zo60esi3Q;NM6c5iP>tCfMAs5tsV~c1a2-VWTrm4}3X-kUJeREw!&mK)RG{MJuaBwl z*5mVgKm{S9?ix2U0@sH?4l5G2mN(BMe*^o((5n|$7EL@0qKI*|;~>kUNLr$qPYNz# zp7SO?JlmUFtqMy~Dc^MH@nJ^DYCW7eu~b4RZ(yMrEB`|^{7L}WnY=2(+<)8}!HbMo zwHfOLMiQ-pNE=7bAS>)GMVM&?M^X)K%`xZpahcoCoDqROBmPzO+4 z`}Jng!|bNKcO%vl@$x;XYOk2Xq-P8@M$dj@);(}Xd>YPYLQox`RUTj|I>A4O>&P;? zwswC9c0?RZ1P9@_SF9Hq_y3xo%+Al7$w|IM%PF~)2v1|WPAw%&ffhRLf(k6?L=|JY zRT;!lFWe@JaHX`bd2f@LFEk$pV?F2)glH><&lx~f&N4OhtUS3CR@qoC#yDpvdm%P0 zcWCvhEstcya8p)HSxykBEM))-1_n}Q7lqg0W-$b^ zR?M+ZEgh!URfx(i@|IHzOFO4}bPT3!E9ipf?bc-LbZA%SsZmw}ArA4JBSjN4$Dw&M zE#q8a0}1Di*w*@rq*$b*wiiSS@x8`3e5Z`|GK|W(uuE|$^UT1-f>dF_)F0c=E=T_m zTWpqPKkyI$f^5QS+JFt`4dl~WI7~Oc>R<{^tZqz%mO8H;%sCXSo4i3rd|)a_2AEMJ zB+Bp_ayWI+o2fw5{_+ZzvU}F9%U8ct8pOJj-zp4$B|fGn55b`K(On;Oyfob zN1zOhFP*i))(oK>bpAD25$dQz!FhR2n&|4cI9VpoRkbCj#fI8Wge-Yl1rZd8An}jQ zQldZetxS%XJ)u3>lU7fqBd>5mQa7m)b?+UGbXa-F+d@y_dejPAM?oN{qMU^J)$1b4 zrJGVb3lC@m1Oei>gtmMUH5by6YotGpje!Bu#Th~6RU0VIr5*Z}InYqRr^?^L8=#_< zwRv}H*A(AkLoI|jz z!vDj=7ZG*EO4At5VO^X^sa%fO6zFRY5^YxdP`IX`3qKV;7&A7z@UJ-z+^s$%`8F{p zfns%s!;Sn1eTf3-!mi0GN3ctiU{bifp7SRloMVvz5r9{zI>M1iRakc$ePg$TJ|f4${HcrJM;2{Yq|jKgVCBrC$z}baM~S907m%~t)Mvy~b0z$@ zHD1`4Ay6eM;f@+1j7{CvGt3Qwz7w~iZ-BML3{xgqvZ&w$wRLL^)HHDo!ELx_%u)nt znu5^l>-e4e@1xrVQ5{gJ&7wjx3sqe`_dex9TWRr#Oa*#ss)5 zZSG}!c6I~0?H1w&V5IH7mF#zWp8y=kkHdTxFI(|9TAK`jjz*zX>4EEWJFkaQA6eSE^}yAPxaK9MRCWR4df_VE z!AdI{>rtCk*?1QG+26FG#xCJ^b_nKu{@9Nas>c!ZJaT&`>d^x!hK>;#aAWheajg=y zO5Le_#M6Ro+!t%I24{S)o7XD zsHmPvGj)c5yFD(^v;#bfQxt2b6NNZOmfPluDKtSuBoy~e6iIJElr0LL)}<4K?JZ`z zQz2T~(UTWXqoUZRz^S$;q`!^7%Qo~s&72kkWyE%3`8*0@PNZ%RH9DsN?FHTwgYAHX z(Ec~oNZ#n*q!Feg=zFdCa|Lv1-$`ys#y^-%+Dd(?DFgLN@Knn5TFwcB0gx=$<5n--riX7r(zlog@N46a z522NOMI5Zac+1Iui!0f|hj#1bya-@aaYL}E&GfxL<8<8nFENLg?cS%33 zevSA%K7DU(Bo94k?CBjh9?!Oeiw`@88Q1jb5A{jRY(s6ilR z#p=d8O8Y7KOrwe+l5gsiaYIOnVWT-hcp2%4rL(22jTWwkc2llUX>;T0g$qu1=7pA5 zQBTqjokArwsX=}>a^&S4OYb&KApx)PN|+U0-)1_a{H4q4OVNu{RZ-_ms9x~MB}kA4 z*YAFnL!IER@Sq&y#%TK2?GX?$=uQkn(A7$c6)b6kM3q}*6^Q`BHcfeSvnZJrbp|1; zukm_oX9l%{GK)a5=s^k%k@{WKXhD*Mg>()91JO{4{x|YjdcU`Kg*hgzg~l`(jcc`5 z;P5J|Eq9kI$Gfu=Eyun$gHtqGj$sUu)6V33mz;M zK5#Q!MS;#f@8mNI4Q^dc&gcjaZYTcJ&uqWFHsTvRU;u)k46# z^NvD_82xo%>g+qA&(Qqj$+gBIlsa%`qT90$fZX{=OWMFFVq#o!!gMvg?eU)aq7i6SQO;13IcswhC# zcd^`OmYC3LU(UP;Eo=s1-ouOxc+9pf;hT88>ltXy^B7gna2}LQM3nu!3GKPeh4o={ zoYA!~+QQqbjkYt#>*B@oit(30CY+;AP$gm=f+~0p0{%mnL(Nz0rM41tvFy2$YnA>= zjFy^}nb>RM-%jp_y$Jc?ha|ux#%N48e1cS&T2$8m7P?2ZJ)a;rFX?GzTI%GiuuG1T z{Sm};$EprPqbF(|=iJ~Y4$46msgQU@$f<4XQo!cBAyaMTf#1&<*29ljm7MF4vBzo_ zpJtj4y2~*o)|$khAb~1D{#1NarPprA6(X*>mxZ1#w9uU4Xps&j2#_pS12H86);!+H za+a4@)w|P2{e&wa>pE*X0W)J zY{a4*+GC&+TWr$D9Iu>lfcj2LM+wxO>uQFE_sPGv=X1gx0yG2WW z#k+b9YOH;3H7||tATZ*bB>fhS6-G8;S=o6uQKhP@)4%r!pfm|n<^#>OA=4HaXy~3i z3*oM`*-2R4nQu<2oyB)0Vz9z2QCBfcRDPkXv_;i49BsYYEA56E2@)VCXdN0%+N{0R zW0U;0t+iNXUXUz{;)`mcY0Xqp2xdGCLm30);OLL|g@VgAqWWaQQE20yW^*cv7437{ z)6+#3L!9bPzsNbIQpDIFDV}ha>?8I3oBJTWAtsj&l{jNCHP|TA$Yi2rX$Dg}iS~zk zWGr!IQ-f~D(1?d@Et1bGaa^TrNWR4uRkT5Hj3C}Caf66?(Z;g}`6(2RvNh<8t ztdTF%EVM)NPe#aY8vGX5{?cymQ+Zyt4msB*@_u$GV&00Hq+C;a0BM$#MSM^>$7pSa zA$xc+TcIH2r7`e?I)QnQh73W=0p@q`L{`y0Qv{t<;`kXGIOcfL58H$(e`j_#F`@i2 zdSW^km|e;j3r*j>`lP8@x|y*ROD;PFV%V3M^ASld?wg0pow zinA~=y{J;va7Kix2Vplqpotf^p+!|y=VTOL!B}%R(7qh&PX)05)E4<_(=?KyzuwVB zv&64T3B(w2UKXZ!aZH46T8PY@=fQ8Vm1778r1n0gU(pdgV=M?2O{yZ zOpJBJRwd3&NvM_3n5jYllkWfGtG(H#nmN^ZW=FJUgM;z?Bn~R3*%nWNY|a*A9<#&& zR9SVO3Yi<0OfoCaH7fzM^2fOy!ptef(i7T%ywT3h3dYrO3G4w}1`r!L<6KOfDMTn( z)<(z-QE4+2vfH2}JEGS^6VV8z7*Ss}M>)1${PiMl-d$n7^g8pUV6zp=%HJ3uO0B}& zOBSGuaXDzg0g7H#Ew1u|58ep)9%kaf6KLFcXPP2>C`?SOFWSZ)1PE(9+S(jdwVB%3 zN$m;EBN-Wlh73!4304X-;anYGJGeh`)-4a*qYGiK#+r(30IF>v@f2}GmYW+p<6z5l zja^+AQ1k1=(f1A@*Mk{JeZ1NRpDY}cc5!p%i|91dQbQzbd^As|v9H-^6>01>dzub6 z=7%Wmi-|ux39OHHQ_=Hiyh=80q~F4d6N+Z6Q0Hu4=8baJ>9fzgcrY($ z%737foI_(hr>znxt!f)J@tw1bPAGCirXxw0>=s*Sh4?WAR#8b>^qLXKu(jN1l@M?; znjDm9&|EBQhBTj=XZ?>Y9iNL_k0>~7^_l`cpglc#6`?2GO}8-8jH!L6hQ$ zVq<0622+)HHy1l7c6ZeqF&E_4mA9Y6I9#7fY^`zWK@`PQ{AUyUXWP~v`;o<0y^X(@ zZ@p=5(_3{CINh6j`%bl|?B#OEDtk}iskKJrWnP?VtR2=SLS5*{>iL519#0p-Maf%= z1CaP<7{P}B2}ft~#xm^H{9*?#g;8(g4L`7{`+ohVr$45MYmT<}_|x|hpX~`EuPHX3 zBGjGz{kMoqRU>je`{4cG`Sj0x`_TuV{keC?tGI_d zKKku1fBD(ZefZg*dE(E${ITzR^oj4h`=|cwkuUN;7d>-p^S2*;{M(Pc_t}qp;M-sQ z*q?vl@jrk3F_FZ}FZJn*hucUI6t!qHgD1^~uK+v7>J>s+qFoJup{JYv}_ zyGE_f8o@X|j0RF4=RDG7O9_vg&7RsF8Qitdc~Do`)@1uasG4a7pQ zbrkE9d5wwHo-U`;53)0!noU!X`59vS5mS?xfr{LBuOl12lL&o}f;|}*l4ygDyGg7_ zN0a1@;vYsV$P%}cScr~Ws0fQhWmE)6MJRL}K*h9-XoV#jr($&0x`?s}4jXGfub7IZ z9KI`Vr(+G0U(o~=mr^+e|5f4)zMp7HQe#--WRklZ!@GH_)<4Y1e9(PS zMAP?@E)hE;Mrp)M^ml3??kA-`TE~R=pG|bjhv~h?hj}Suqv(x{GXK4w{zl|Q#WejQ z>Bp$|@#ryns-%C_cMR+WIR|HCxpvaw%XB1J#o}{<*BU`8M#9>uz&CK(t z`}7cL#ZgrxNX?legpyaWUL9YO{(YHwc>;`4FUvS9BBk2kxRLZwX0c$Cc&i%JYnT3c$_=b>T+f76UsRKF68Td2$*4S}NSyi=7`06EDNNNUMtc>b)N`tK zGhZqp;>arUrsi5W^A-0){UT3aU$j!nTs#`}DZ(dZRvdQCs!byZPWkP_ie&oqeWc6U z(YVF93-oW(Ma{4`SO}l zFPVAIiz2hYvibcf&FuN+lMrag2a^1FE&TPkIIKm$gS9~q^9rB!tuxrNFT;}*3?+ZY*IMX|K- zXI(oj2gkhn1mX;;H}XWk$jt9Fk1mii@Qn<#9|WU7*PfFCvIP!An^`+E@E<_h!~YBg z4TvPLk>}Sv`_Tq|q=ZU6sPxl`dn%R^D49`W9_ck8WR8Z>3wl(`r}43ZzS50@ss>Nu z6^)M>k2b`5VIn9bYLD@Zo<`fKJ&>7}l(nYXnG~S-!taGXWHRQ0SJd@Wo@l;?#x%(cpi_S14oc-ZAX;_70rq(H`> zk|DW#p+-ueRjPtW$6w%&*v5{KOe|+dJ}yz8zd+>VjMz*ZW}-_I3t7>izvPI>U99Ga z)%}y-_&mQ8o7$&UMg5N7iKI*&CC2h+DS^n$)I&UN@;EX$QI-`iI_ioz(vJGA`iQ~o zU+M{1<$nQqMP=6a&!|m2_4m1NM$~bAe~=sxWSI{V8CoUrJCU7%RlI5P=u}PgZ&F00 zrkqDJzan(s<6Mew{cc)Z`uNZ{?$7Uc0sE`&#k+_ZP5<=gdTEc#rEQA0onvypTAk%9 zCOEOs)qjs^iH!Y_I~~niGvjhx_9OoOMIv)QOs+5R=l*3mj%FS) z&3Uf~+A)t0y6^G!^O~a^li#Fo@5^~t@5?-DHh=P)(SCk+ooa*Pdow=#l5>%H9wO>D zt#Q835|2G2jx&!MlbA0>R4?-)%=5TV=4bi#9+xcq|1v4XNmtbNh>5OxKdJE+Jl`Mt z#-qXp4(r(IA^Fp>-HvgVq)E}SyD>A1*x+e1qmzadnQ-t)+Hx4&l)Ld67_%KRE zM0p!GNrsW}TF%IdN0PnkC6_10*hvH#suh*#RBIdD+T<_B<@2dQLt7$S*wAew@0R3O z!K~!Ngiw?^Eze8k2|@;>GuBKMHH*|4Kt=2_;^yc|-6{S-#~cRywwT#btUDyl>|G`O z&Jk|pBJ2sxm4Qz>@ml#;{GlymFJf3kYPcI_M1{r%;$>X9<_iwPQ(zkLaj|+O9-OsM z-1KfjK`a^|&pv9f3rG+b{!`+W8ZDVJF#KfeUR01+b z?;}hylP|@mx^00IQ?I~1l4J|>6g001_nkJ%*cmR)DNZ;j!^j}GTD%H_0BKDaEB?O| z!5UuGDBzIaS6!T>mKf*T5EmhN4=s^3ejX~#YrUdfiHwL)!VKaqDytJ)Od}h&Vsuw) z15|a`ni2;tqrNHm$4CNPRYBLIPnf2eSfw0UXzfk9fHI0HhRY*l%$ z1f%f^OXCI!>t~jV*>50uH5;ZGt&a`%SZA!rLDr0W3@B0&&T8f~g<(3vgb)+maCRty zairNO11tn)e%^>|aH*%LytLY3X^PTR`V^GUP!opBnzRVJPZ(t6_tkOXmQ!@o(O79{ zI+uhrUOHU{Jk)w=x+j_Rf}Q9)BLUsl#3{%OY6sBoCRFLd%RuraVt&elsKs7n&Nc`oe6NoyADy44YrgyQn6;s5k}*J6Tlo?HmmYs_xywalyq z>t3WR(U;a(XlG+Ns=%c+?A`G*VZHXY)|4&P=br6i7LcLY!`@<{b#7opQH-#37BMZN zWhl9p&g`CCxM|1MJGY>^K62}6)QVd=e-y9Nh*8hT&2kF>z^nk9{c163dD@=DlmgYR zN$}p{W3Sh;IE%}eRa(Sl3GJI&5H^lrZ|o&$F;Ymi%LnG9h=ijLX^~X&6iNXpG-*n# zM#B5ZiI}}0!sKZd(vE@&3Oh4fn8rF~>7TNdF)hINPI2Kw=LBuQTu;XKs4b-dvm=Ej z*s4zDQe4%Wxi}oTI(aY$klpd^-ZRyoV%Q)Hf{yk|HE+|ptVi3S;{qur0ERDgRhuqv zF+`Kwj7i91N9}j?UKUp_WC0~Yr}N?Cigi%f^|KJWcous)19*_H8V-q&pg%5?H)pe& zYKDZJ%)*vpP;$qq<7oLjz4km0i$xWn)mZJWTbZ1gV4JOEe;Rwz<7Y(U(Om|8D*?m3yO_R} zs11~M%;6`rFoFzcIENhG^nwpHXlR=00@K4Pk`^4NPt31G0BA*MQSm--k+FCqkU&QK zu%8x2BQm=1_Jc);JR&f8vP(oY`H{Xg=yobcLy0U)14bA^CG-1in|%^ZYh`PKaf`TE zZ*A3-j<(%`nc2xR87f<<*{a75-=Hi&Iyzs9BW61+HBHGCn4r3maaXYXPFapN#;QA+ zwBLKtJXh-98ST-6@JPrVqZr|OwYQ$6i^XxoG(<#Ix?TCX9u1_3`*xDC#G*(81YUGO zeKZD@RSc{$UxdXPW)~)OLL{;!KTF#hIzdRq2vnz?fnBkS-UrR)PWmI9I_!%E#=~G^ z79`w}A&P5}C5oEE3`ZQl*#FZW-mc4BVQm=ADTqV+X+i0BZ{w!kTM2G*YsM9V$ zqIc})ZQ+PF9~2+o9baOd#Hh%Uqg*j#ACwJPJ>VYCUDR;c)CA(p;CKW}Eo&D|{l@N8 zQrw~@WsL?)FaP7R358*Wwpd_ndF(b*hzM748Hb~?yGt0rg?m-j!vMjsU=yeUTDMJI znvz3oPB_X3?niq7Vo{2uJl4H4}iZWWo%GKtrj;mW@RCb&Y zNZ~nM*pleny22jR!ZD^V&P62JxEVuH!qVARJ9>k#&_tWtBvPno7&T6+?6zU-n2|bL zQWZ2@+YQy_Q00^mK|`b~g3)TpRM*Nh@eF*cvH%fcQ8w?xS`BRk2h3$v0fZa7Ep9%L zQO?{^_+RWOSvR@0&2yQaZMb(&>Ky9Pu}_ZNmD}JAS7mr9a$xAAo1Qf{sv!_9g>Bl! z%FN%UdgR6lMw`@FP@wg=5Js#y{fv1RHk~kG;#Go*sdNaQWgcgy%MPPTuWOAcy9i0Z zc`YPkV|T2&*kOXxx|?jBNjJ@D*`hr@n{nFWOvA=rFA8Ig~fKwz3=815SB3uYzwBw5|`{F9B3PJL%t%ogKdlziSMq1%C zHFp&DM`s8f4Cya+QGC%k3lDBtBokVJgWl5IA#>}dz^4^&;SPi;LA>=_;mcWbvhD=K z!ql~dpQ6dRc2ylWB}>Q`;+ds|#*-O$r8wGYe#Myi5yq;N$iat}3F|dfjGETA&B>}u z+GfEtlSRM7W)tt`@WctEXt^36U#j%$I5xio1vtqi95B#afuLc~pd5`5UNKUw4+~!0 z;xaf>*qs~RUpxWC9~ha0BVAG06Wwm!oG4{b43Wk;g88z%*Q^cesF>h0z<10|v&n|= znpsk}t3hgx4#UqBC{k{6Nk`0eUaPqbCorz}$ur7PmWa-z$1pY3+Vz1U=s`W)YT#85 za~j5?GeotnX6ri}pNw4%=P-w(gm^b5gayrJL9^^8Lrm|W61-vPXW&yTXR(t&C4~W>zkXv2fh~EYM%RV8r#JW$7)PYwGB|JMnf(pXKA}PT4t?M?g~Pq(1GhrkIbqe)FT^Fzqh^tvBc8u*g`Yzy9%frq4=sgc7a<=5f@2v0g&$Z z@g#!Rd2%&EGw>Pu0U3!(ixGlk(NpyRZj}Q?h8_&`Z{yDpIx=EOo3W@C@e(rAg0Oy< zOmt`Ky%-B#6dA1!Dq&`47=@YMRhX7y z6-r25yp9Of`n69SkHm>Hent$h-@DIIyhJJB<86w{Eh5CMgFvShsGLY@!giVwtP6!m z!xKOuXSl`?4?Pi_%i$^JPh1VNOthK}Ge*?EZcHSV{V3cgoev~YeHT;F7c73()*}cJ-fkJC;X#;Tvv;mY;9nl}y+lsab zu}TvrlN}8L5{J1vWYk_libCj+mUFZTF~gpT&=j$Q!3R(FCi(>E+RS`yt4EjGaI>`U z5cf2CruCEGHQOHZKIKMFWv6?6!ioqdQR4D9vF2H;MT0@$lA~v&0qo@1XsXJUC{zII zXr@YIijQPSfwpXE$NpOE_)*yzW1$;$V~}!1LMnxBjr~)^LQ0pgb%*tFApMX}=v~Le zgz&0*(lWe7TNH7-S%1mLxa2xxEw(Z%kPEXIY(m_>(csI(M0jI~{T4OuqwAvdI%H%l z!4O~+)k+dc5h!Ll=|J!duB$|yg7UQLh%L=sa0o{Br8FiO$G)R3*M(Xfn=FSsAap-N z+zF~w9Awn$U4de$nE}~FACni=cy>GktuC@8SPnu>^=?_5nm>vNXr3D!QZJ&bD2SBQ zkkelHL+$6VNqiRZvW;-EiaWfW^=VkPsrK$YXo}zynr2KEQ!!I-ae)^EdxGB0ot(PG zF15LI=y|#5Rox2OosG4R=G=jxTN_VL!R??iJ|Oq_jiLG>)D6MLPwuI49ORTebFkIr zZe|VJ?>3|+`+ppXbMLymYc6CGy&qtkfUoPcPCAr<9u}KwL!4QPD>%ZzXZK`X;1+?= z2Y9LCH^+)A@%`vgpQ1hP!>eA^rpFrzX)Wd32K5N44F#Hj48ekMtx`F!?&&Kg?@i1D zP57Y|y^9od9SJLJMG@e8yJzQATP*{S-~oPu#Ybt?J$a8MNL4wQVp{K|y4dsNEGvpw z*LOln^U}D)6a*R@4NT(%cF?5hsz z7V-d*^raY8g!P8^s))5iNv4Xh4Z)*C9F(F9&ZDD(uOKfvR1aq2)M5lhwZ&0$>QiKs zG#cfwEIkQ<3rQZB(-f`i5SHm5cVy3&dVy46RnkAJiv!f_FQ`d8VDN3$45ALM&j9ipZ z4rby>{EOvlY;uH)#kFj`dM?jS}nDuIB@L$9&nx z0%7|2a_G0@3zo4kE22G|vzP_pU{M1{v?nBY;^K6?qz@iJ?K0Mp{~&Q=d-Yf@o}|b? z;CE3__{ilrDu$~k+0GDU8$t3A;*~fW)If6rgHZD!9>Bx}`Abso)qQ>EDA&nJMIedR zL_x--6!OKb>Wg`Pgng5dPv1cg$_Htpp&_(OzrvjRxX7j8E+G|klBZR||tXn`k zdn4$zE=DLbmew3xt)Usc^QtSa;B0!G)Nq!CS)Le5Ircv+iQ*Qz#U*oAbv*kNCiqaE zMDbc`Feeub5;y@dB4QA%rP$;%H?ntjCwf(BRW!M^yjXY+@GIsDZnWXc%PA|cj+`QF zNJ9x+gAJhqb4V5^9OPZypyrNV-rYFQTzLH3QkQQM!|!_ZU4E~-PN=mEKo5sQnj5Xy0t5$^2KYK6&9W{`GPyEw}|lcT=c$AHDe z2|7c>v>nkhQAhO;i@|2z_jXrhQHyv+F@(Vnmfb$6fXAioD;xWkKl5z8P}EK(%m!$0 zQ=qumTEA&UxfK@ns|#Dra~IF;95IZIWFjeu+fU(>Z_HDwi<^V}q&d%H$Bw;yVrrsq zc$JggOBcb=qt4z>qbN?dLu|;*JfR_SPc;Wo1$KE-EGHsXK;A1(Dbua0Kns>bm@GbJ zyW7{4+2-y>M#KVRO@WaQ7EQR3weRUWLg)PDE(o#J?PJs|U{-O_GmuhgAg}b2yk}C7 z_{t7PImPE;UZA9LYbvD{qa+>)yp;A0aQH_q1%Mf0WZ3`HvuPK)kn5@NAos^)C zOH&@5T@@XKnSEyEd9;!mRX(wx@6S;dNXePTOgkBt{>AZ%+GIy|5 z->s>Wo)hlSW$F^Pw6{!qi^@@p98jETR*~4})rY$$PheC{nXx&ObK%&*gPRHTOxJvn z=bfCzQ#wsc{CmN`oS`uf*1;bql9yGc&h@02D z2zRIgRi@xNC09lVh}{>1Vqz&{r57Vo1%P5F!~>)#chS;LM^Q7{aSEsxmBT4iQGW7gzR^j_Ok*|V!QoH*jnP7f(4FZQ%;J?c}10qH9|#ju3+(!PvwktVMPE+)8t$$QEOdhy>>@e z4V~RbIU}%A5yqJ+`W_(dx5|n5I&~bKHGA!mwqjrL)JH>zo~g5P@ZoQn%%H8wA*syn zdh=%6Vsu&SX?TrxtSD%cKQq;-i=t0>enjkwsDL&C1?Xz*^JU#m`$a=oH4A;`_gz(Q zEPX0+v)dPMUAS2>gBNe*5YWmJp1xPU|MrT06pw7;33RFI($K_NK%ME7A_Cz1j6XVF z%<}bD6aswF`R&oZck=R9mM1$t`E3o< zi5ED(%43{Z1y0Gs+urWLrMG+5a6c`OKR90Sjt)?w3&@kb_K1=mqs%rI0RHgB2Oe*6 zMIcc;2en#p4IWA#bMpAN@Y0vZxno6S-4BynUii_2hoAn4z1#6oS0B<>UyzDX(Toyr zsm&28=s7v>vj=~0|LB9&1Nw|0_>_PF_Ix2Hk@{&}YB--ug|RQ1%ewm-pZo_n!1g z!=FmNMB(M+cX*lQyNB#|^y?~59!>Zjg@>H{jP57Tm-y{IdmR-ouJEVAKZ%lx=bC(? zYEHN7fp7eJdR5(TkEP=GBp<)e02dx|_tOsfOoe~dm+*bUyDR(f&TrxaMoxUS$o(n$ zAn&p8YAc>>r7iMcD{Z+S&oFYOf7b(eV5zRC;r_mqys^})-;5gYMytMB{skYftPg)K zzCRLWdL%!^zv1zxHsS@B^@Tqg-if^ZWcb?Aw(uiUA3jNa(Q5g8sr>L~yPv$>y9e4n zP!pbWybrhs7$y4yV#$Iwc1Oh2I!TNy2ZSm+QMu_QH+&RG+P2DW*IX$qa+=q#nD>SE z7P%8a%c$kjxxB5rFmKnzXnnLb_H3KWi9%NuAXw)7&o0?7Fz(aL!TI)+@_yxx!-S$4 zfDYpvfylAX26)0oB-}^clgAgPwhQ>4!3Rz0oG;VGaLTI6%1+J5!LmqpQjDGAu+V!W zpN)X6;`Zw1!oiFakJt%r7Hv1bQdI6njTpg)=RVQ4&!WwFZPU)aqiPx+2J1h$XH&lx zuGv@Bs@h*J5WaPOK!UjS?Ha4M2JgJ%(wzsIJ2GGty#H3AY;USx*`aN=IUV&`eGzK1 zQJKO}rsTN@+8gzo8VQ;;wgr=&--ZVkrX@KYC&Nmx-hCD)wknq7D)n~QtAsr`?Sn{| zsStxsM9n>K&A*Fg0AZ z@POT|+{+uWGG{d-vWg4oiaaDvi2Fm|foStu7)$JrZ55?1xf$K{rHcNSYOpvq6ET)9 zG`WXhOCoT`gXG%F$lRd%%)=92&%^^JsHnm}Pz-L>0Od z7A)RLs5m*x(Mirjh(cw==`(IQ-5AZV`)Frqa!(k2S5$E*)EgDNTt)8=y=EP{5^On7 zv(Zh-!fg`{V_ITLY6?9$T_E>HnxwZNY`4m6hi1!VYGq>ZO$;e~=-D{R*p9K-7-`B( zl%o6nVn~*xMMv%cGaAP^^W&;l12=Pf71h}%vXj0d#L$oz+|a>^BZI<(YSA|L$h(7H z&YnJvLFlf@7^K0iN*N`FTy-6X5rc1MXVXc))9wVOf}t=hUoS-44m4siz1C5-QIXz& zZ;-B9pH-UsEe(3zA~k!z5|;-|rdT3Bl3IaYRAVO8H} z&*BkjHJYa+&PE)AYjO69aMx!BPGg*;%^XPBZ8I?Lxb=j5r2c&BSfv? zUm|P}+=g%^5y6AZHMU8_NQn?1MCvfON+vS_J$TEjpUW^ND%M3Q8`bfaU?2}>*qnU( zB^Od6f%LVC$R`mxhQ<>BguqHQs7S;)AvOs0Ml=FP%&-{5kX7nS`_$N476x!Y zD3J20F5=bD)EGKospywVtD@eB@j~2{Xgr~l=q9Bxp47;9%I8&ty`pd${~nhN&QNS% z4w)&OE|8Nc|4RxIA^B8Dgs23YVR$_cdR7n(}PV^ZjEAO^TZj~HHBMZdrt`WUN>22(6^ z6v!M^@RmZz9L=T;jDIwYa;O3*kc7Dam*`8K1zx#H%E0v9=Oqxc5bXp!(2r)2ll=V7 z!f{bb(kcL>K&IxSWk_i=ZKD}cJA=buMw7r*|0pQ(kc^Tewhw@jxnOK!4*ewqjW0u| zY&3=v7%pDD#|R%I$vBXV>0>l#JcGk%lmFc(7Z+dE-m$So6uCLa_&D68Q=@M0BU#4u zGu#uq(0;O;==90f#PPG9IP%)wfm88P`ixGYjdf8|+kH>@J6>U?2>U;f$hf1 zsNCyVxawV&#OjVT+I3`ZcUa(cMot4KzX=&_wD*nGurAqt4&*kUyAoOS9G+LX3Hmq$ zbJbfMn1DVzT22cY0~yYAkr>f)x1qskS;%cD4iFRUp1e_-`&%dXTrZyu86NnwyL86m zW!`v)%<*zIj*f2JV><(J-nPW;QL}H;_`U58 z?9$q!VRK6Kp!J^=C--zSVLOk-&Y5M_PEOZ{*6GdVN*gH@i}R?)sIX&c7qUbB0f(e0 zaebZeNG?8<$>CI|wy-^#+#^9gq91EI1i-S9zyLKsoZQT?H};_Y*mdcpxjweQ9?s~( z4;?yBaV8Jt!;!QH)b$$l96VI}+Pn317wZwN)gy5XulSQ1PI0~Vj+6Fs;l$>F<*<5j z-vSU7zOS+0Se=D8Oc*^8b4BSWm_#W#qr2QZ| zah4le0m<%ZJ9?Ff!y8efze0ZLHX^EZv_JcYo`Vsfe~SN1gkR-C+HdVZHy$1hX=IT9 zs|BvN)s7UcfTYS@OuURsR;A8Jj=%9K#c~^z_)G4OqzIaSRJ%^GTFoKsV_Rer zUqK{=rAd=PP9*u}h%9TApv>LvFTFZ1#j00+2{l@VC=9>@+JN8%I&;9VY_B5G-IIHD zX&&YZbH|B1fjWZ=ui)fbEl4NANZ{%!87F@>o3!~yxgycXo?V?IN#4ljEuNhQmG_E}~l^%A`JT`yl1 zn6b^37nZJAwfPPu+sKGHKoVyotT&P%I2sn8i4xu7|qBCM{E3 z+*(}I>Uf}QkXx>JEK463J=xV2iWy9yZ70Tb2#HgQSO`wzrMTD^`AdB%#@@Cq=jNt` zT*smYRv(@>$QDeu%mr_&flt5O}~cAe`>Iky~ljzc?s$$BV=emu}2X|xlN|Bx>`mcm#@IKKlE*k zDYDz8>Q>NR#7hxaT48HXcg>lH(W2#>Iz(;N4UYI7(y-V;OfY7S?`@_;&A_4`F1P?_ zS3JU{B)WXldM9`8^uiW+UQ~7>2*6zKD~0)n09A+b&3Wr*6nAod0sHiHXeJXYIR>cz zV8yA;hp*7vabsyUi0#&X7kN_Ku68-pLmmAn8kV{m!ihcP<@|_Q^$~pZOwj<7-l7Pw zcg)N(sjT;%Xp;Mhu!h^t065ReuImmgJavkXQ)#HwcHS~`C+mH7G35zcw@q~_sx4{r z8N%j9&b>7?e~o-2bef{_TO5dF_ayZEj+rB8de?#pN6u*bo7*_~idXN?-A;~d8_sI; zo@?TG6e>4)7jQDEC&A#Aw#Qc9o(TLsk5EVjW|nrKiXb`_3_xW_&RY8u9aTzhV)5(& zYPqML*{6R0RG~^)foSdXhQ~=3LqDTd&tG*?d}8X`(M6KJZ|m^|=yC)KnyC0FbpTuM z!clmE0j|K&?7)gnI|_aYyORb9v3IuJhzLL@I8OHYh6Qs{22o6-B7x2v)fHI^UMFv~ zepQg{->X=ovZ}r5R-vMHpC za$TNaOI0AJ^4MXCSja?W{eIhGG}yn2F(bF9%BwW+xuq30hpgmDYNIP=Fr+(qwAq1G zc`{WoYg^SfdAfH|&aSzWv2THTX-0VK+!@;nuwrtAo2967@}!Xdna3Py;&|gj++uhf z;Zarlipq{{@At_N3bbn^Hkv9hth;x=FNL7E@Eap`P^us{>`!iiHR^|WLaDS*DLjB# zIN6``6-r;2p0cS?B#0!t|IWHqGA8xA`s`HpqS~k$OdCk`d4=mW!+50MT2uwGzM31( zHSaTFirj_iWm6k$^B^PJTk7nKC5R$C3QUPruWZD}PbsGF@iTqSmzKUDO%fCCNNN$D z8dQT%Vq}<}=<5E1dwU^nn&uEQiye*n->+58`q^QxOV8L+ZFx97n)nbJZ> z@#$<)(n!FD4KdEGCX{}1AecFN3nFC3#M-bu{lY1>v`?m=b>Y<=6 zt14||111FF(Yb}N`2oL*z@BmkIj|0+%Dfk>^WrDY&C$hf!z;Inr@d2Sp|Wp5l$Rb| zp3(CbTRIjl*NsVFV&=gx%4Kc5m^9L4-X}E>v97@9GfzG9i`|fi>zxS`o zsiK2=&a68VXgqi4)a^IlZVwKM?0lonIwnnoLSNA_ZRkbGcm=bTOd|5Eo9jKLv{+Zb zyrnT=lw=ykM6*9WiAs(g)@MDUHcKMxq3g4$E9cAZIN#24NYOCZ48Zz@ z!l9XN&+U*nh|JR;{^R!{v&428D|OjI>z=Z~Uw#dVCYIikpmIAG@?A@1mAn5IHd%S= zR=bpO^0xT}DQHMlxqA*d=vT1Ce!u0q`n5`ik)i6&xF=-yjcity=}1jew)-gg9#3hk zq{XV;GG^b!mUi7)yUKioQgp9h+-nxK%kKNj*^7bp0AV?=YL#RdIrUHT{d2an|Dq+& zlJ)9lyRc^G7DC#f{_2J~S@65>nJ4V<%P+oBjrWp9+kXA&4>`ZA{UsIV=0mHhLxmm6 zS^f>BDjHRE$LOX}v!d-K4;s~K!0=#E=Av??5LB+@43JRZqOJ{9H9rE&k7PrQi{2J> zGiuq;(uN8?l)s_xrkn?yp(v429}BiXN$-sAm=Z$0es8L_r3$C*P3Y6PGitD z(*}CPms)%6SGAU@y$BsWUH-5^!pwvA1CA2hW2KU518t*xH0dN64Vq`&w7$><%6ZWF zKke(l$U`?#QUlUL#mLn5)G>@-03ib-W)**G#>qoz{8H8W^b5s3|5nS5XexuuFEJ6^ zIuv#FkD+=LGg55{_@$bH@I9uU_ArK2$}bd1QRJbg%+6Oz$#o3T`v)KvCT^ye#5ctl&G=}a6f?@u_kBk*bT1cyC3CXayU{0eJW+!S7 z@PfU{e2?vC7)gr#U@>DLa~K6NL^L(}UQJ>GDw;lW zfR3j4ZWP~?s`tS|;wR zq@nsEfB1b^XQW$tJ}E6%Xqn`z!SCF0)chhnlXXpz2X7U5$>4Q@#w+ged7#ln72k`v zmj?~iH$K%IC_~YK6X~m`h}pe$+v{Kd`mJ|Lmf#cgpis!iUcKGvV=K#XIzfAng1wP651%K0b{KGxjzgA|I0AyPaTs_&FOc+&&!b(>*-*DZ* z{qza}KCzZcyKaH&jv{unOo>^2VjuK7e{7((D%HVdI^}VVoU2q@tO^?L?(Fk;A48Vi zK6`3=`1B4+w2Goa%vuVfww_~d&lWs+BsjDbrO+GpOmKc4u%LOvfa=Y%6-K0tSq^RK zV>jzN&X%`KAS0f!4gyXxZueN~^W6|;P4-Gz4W5brYmN#&t962zQd2*EThkRMwdxtG zU_c>ZPlt2I3*&6thA+@Z|z`x2x54lZ;9N>F{6a`!fY%g!O}0) zxjmajpXd`khO6zKvn9sGRvF1r@p4Hsaj^9`L4zFwBRUhh>{wddmm_U&-@j*5j`aT8ZUZSGw@wSTG%X@~b)A`U}{ve7Xh*x1}& zO_l@QoC+oB_wfnrLCMfbS*S5%IX0rs^bn(M)flOFwg8e_!7Mq@ao5&n3&##g9w))t z=HAVOiG@^e_7L_s2t{NbEg#c+(MBWs3HY{^q0P*_E*sW zs&v6W&k)mtD8kYt71^zA&(i}vmsMvVR)q`9>Yxa`2v#(r2PFqTaTMYh3k)l@Txp#a zu4Jvo=kmOWaVg#5)G!@zt+TJPMWM@=lp*gkH4W^K+ zxuX$7_;{5Ckuy!17*N?_39;ogh2$`zsb*PMa}Mw3%R|_?=)^$NdpvC-W`|-PhfFBM zG*xh`K30^IYf7NpF)ntKfm%byy77uz44@_u(Fm3xf&oghEF}UvgKGFDfC)6OLe8Eb z?c|MW=s_HMcT<}-^DB9v`_7Smu|4MtE8;&%r2RoN69Ix+_ZXx>=x1%tO#j+zY6 zK7>-U=}0LtSD;{(b73ndpR>x*knR)zM1)EDWU4A%jDg?30wYX^Q8THQF{_vkIIv9FbxE+paw!>Q#NA_}7 z(#B09+K15h5ZLBy5e%j<&9;)9MG@!ip6qy@0f$`QN>}^VW!tf3i|lD~b>-Gu@)cVe zje+jQ>Re|-0vMzj@J1D0ifSeKDWPxa(^A0p*DN>TQg>yypiB#=k&WS41+~K~b@!xE zPYfTsBQ`bGV1{}W?74aV_OhRM^rP!ym{yh89np$GJ7@4;2#tu2nZn%&wNT+Nl z5#`$DEDb$Y7=51D1LAd2ogT~0YW-G|T=k+&L(jpz$;y?vG-FfevJWZJV1U^&Z`ro5 zpPx#^PhtRO9ATbhNV}(T`K-v*MGS*fg;5l7has-WlL>nXJB6}l{y3Kz zs&zVtj&%3CO9#u%0jq4B74Zi=)NP+m@wl`4&Qa-LZK6`vg3cd>MwhkP%70U$d=3(Z`Gh!++$dAGca%Vhag9kR!L<=ce=@jL88HUz-?08WOB0?R zuG$(M7gxx8j)NzHe2Kv&&sUE-)(yfPdVqAdJ=cjYu2P#_ynSc)jb7KQo64;e+?WOr zQkfrX)ZlS%6%)pis<@@IpZEA=obS^+&UaLb4@=Bk<7F00G1}UCX zwBj~VJ37ZH1ckUcJHjx5zT)M?@olUJCJbHw85-mrPeCZIR4QYOR`d(`d zh)UzgIMR(gHmJMcH!RF4=UvtUNV8#m3gs0oNT)*}@(lSOD%uCi`mM8J(KX0czOOuf zBj${>D3mK3cG>5Ljeh7uirxFU%EO{vnjYyW_*QfkUv%H@qP_af@o-XJC;8n2NZ@qy zy6+Vy^~IDFcJXKwwZ_sz?9P$D_3u$P9Gb?Kmf0}(1qa&r!jv<1-*v2gRHtB*aEl=3 z8$^k>7Gi&vyh|L_fFvsfe}lU>M_FA0bPl;)FdjZ3ckEZ?M&#C04`Za!1n}59PgKW~OPqxVSWrgfT%76YZ#{bN zw;uicw;ufs{rlLvzV+zOf9ug-edg{@JahMlp1J$pXYT&_XYRiLnY(}SnY%yw?1T4z z=hHt^UCtkSC#g*q;3KpTd45$tp|(KUZ{?BnRtWR?rD$!W%_%w*`C5iv0388eS`AKB zPgJD<3KUcZWJA$_DuFMd!wOYJDIt|0@roZ&L!kFT??8TZ3Pmv_HA=rSD>W}D9jGyM zSifQ@>OyIP;)9k`8gHYn$oZgu2{jox!{L|yk}XtY)_{ceC#wLQx3rK~6j(+kyN0ff z-}#^BF;KL#DqrfDP|?R-NW5Y|LO~Wql}4(;WUJte0IyK`(B}5X_#TQL@}Nkfc@!0@ zC_p}4w2+~$;ujh~o|tz=6(|Nd07>#HT&M4AY}I@Qlds0^$;voXYeLx{G@{7HE$u9{ z7fDX(;~)pIgJ3I7HF>U@GIfiSPiNhCu3aB*o&gplPO;~Z%j3e@8js3^C!s*v~3ph!dM#oMEAO-tO@O5^*^bS)w6sH?R{ow-hWoVb-t zR0@vM8?>SBQKgS~Tefa}GcuB`mmj*kcle4!S6$iLHg##w&8vs5B9R$5@MNegPW2h5 z$)a%iNN`HUaJS*v%NePhw0BRw3GL1GsTMldI4-6z7T79qQkcxx=EI zZ#o&2wvB-j615N{$)IxgWZ#yxd&8^Fp@CvzN^st0P$%Smgk8@`xedcZ@)aJz=b2zZ z8+SB*ZT!f~E`3hYf9?g4zQ4JhRR*CO-v+7oaQL0s5>tTr{mD}tl%9NKmrah^?cE^9 zcVD_Q{LmZr=&jAc->AK#r^rb1mP$vIPWW&$WT@R&cDSftH*t57PF3&R+D+ zxvyr7-q0k}Vc3nq-8rh8+kGx(rRBztE+E`QzI%L=dD4irC`+7TktA!NN6aICNme;w zzhnM6EP=#%hyUjX;caDyLj*}|e8k0vP189_fNK^9{z2CRUyQwxqGf)rpL+JLyOR{DUgPG&4e$_&a9)$43B0)`&`v&beH#s;2bqIC! zwk;O#)B-hKNnj6Mmsz%+gF|LD?u-V;T4=Twi;9-6M$L;ExcpZ3Ik@>b>Bor~e4Nk1 zS;$B|&Tf)N&CxT7y!~>|%CZ}WoAJy3Bk)HlST-7Tub zrgE32SF z=}pp(w9vA6>Ub^Z%9r@UQ3ZmHsQdJPWC|yW+H+tycb$t)YeD}NO1L?NB zXUYHC=KW`B-|Gg_&u!8V@qW`ldPS4|JnzSw^cP7}MIEn42hzVo`o+!ruaR!c``v-` zzlikE(1`zyo~voX9Q`P04F{d@697LoV)}ixpTh}1?2#K9 z9!~h-gda}$;h}&Z_J|LS4k!Fb!jB~UNWzaK{7Ax&B>YIik0ktP!jC5WXu^*s{Aj|D zCj4l^k0$(B!jC2VSi+Ab{8++|CHz>zk0tzygkO>HD-wQ1!mmj96$!s0;a4R5O5lYL zD}m=Zyb^exBP)UDIl7WJ@m!V9@qDiKb3Eb46Mj75#}j@$;m4_8<&0B*JOkcX+oV~!eqJIJA$^&j!@@$~hKB-vIN^sAet0P0hZBByDBy<^ek9>X z5`HA%M-qM{;YSjFB;iLAel+1n6Mi(|M-zTD;YSmGG~q`Rek|d~5`HY<#}a-l;l~nw zEaArzenrBsNca^AzarsRB>akmUy<-D5`HD{EE?u{O+MES4Kw~Lfsf~fL&J>wO5o#p z$t9c;|7GS~^kX?t2l!b3%OkP=^*-QZ{VN^tvHq7wWBo4&$^jqie|aRwUn-`d2#OWBo5Pk6QoBDWBo4=$NFC$iS@7d0UzsM>3|t9c;Up=+{_0;;+Q|njrDf*#jz{mPmI^bjdD;@B${*?~+ z3GinG{F?y(cn(jn5_pbGfYCfhC%~V0uF9t{PQS1Ab1naprquzzHsRMM{Mv+H3;qgj zE%+DDfWLs$DDW5XFVEo%yb_m3F7S$49=!l~e&@L=pTcydSNpj>;n$NA{Q87npYZDw zetp8Pr+&e&PxwjT$EbgjUwIBs@-NSkN#J>oP68j#Rr%Bk)9fZ-E&*6Q%sBjC>j_!CcN6ug^YM&fzl&l1!G^$Tu&!mp?PfD8CZ;K!(c68Lz6!N5-fAI}Si#(}Z+M=D z=V^GJh96D%(S#pO_|b&tz2SKpo~Pk?8h$L{#}a-l;l~o5_lDakm=e^;18lI=&c^ZBt@WYJ%O5o#p4~GDypBfrw{CPh-%=q&h8D{)>jtw*ZJjaI_ z|M7$$9}4(!>KFWY!t>tnJPps&@H`EF*N`}@<)OQvA3TTef`0HExeIE+bM!9$jpwR- zilfl)tNnZu_*K9^2|Ul?CxPcV@+9y)N1r5DJXhsYoQQs3?dQH>adgW=`{3Mo4)22l z<2kYqPKxK~zF~1ZdalZ+I2ff@$8&^X6+9dt&*6P=Zahc!!GZA{-8UjmNzYaJ6z8M# z>UfTc0|9}%Vz(1bjBjDfKgkPKRYr#LkuT6N~8=j}(c^aOl;V%Gwl=?3KKA!h*xKaA4p;7AR z{qQLD^Bftaex75a)X#H#l={~v{Q87nPyK>lpYXglJWs>(G(1nkPXa$i{gc4Q^PZtG z;3t6}qki5Gk5NC*kumD$IW|W9JjchVe|y4jPx$TBFZk^V&wIo3G(1nk^ECWE;8#%p zKH%ed&(I3s_W{3x`guRRg8F%mte}3LV=Jhi=lBZhpH2ALgrB8;!Otc@H`FA z)9`nJ#Vgr=?gAU*3CG6%aTohXJTDwt$^LN{`$s%48CuD{a~J!Mo=*b53iv01k0+cS z@J|9C&kKiE0skcM@w{YcmEkV)a~~Y%xc0x{HTi@?)cyx27|#ob#FhQ@*4 zH+)$<;Z#=(eq>EP*AA@~{0JPPpBE0T7W~LX`MhLkwctlCi{}^|kl@GG8^l%+I^PpOxU>UEohV?-^PN{@exrtOWmfKfDtB<2kYt{Np*c68z&i zz7qU<68KfXKM8z1?-^PJ{FA`10-pE7tAOV@vI=;fW2=DYIlc<`eZ$j3+YG}3K zN2Z5Y3w~sJWVPT&rpHzbeq?%lwcy95hb9C+b}XOw3{41r?5Uv%!H-Q3PY8Z&dSpWI zW7A_3f*+e6pAh``^w1i?j~~nDJws~*KmOFv8o`fG53dpY`1Hsc!H-Xmtr7hA^!OUV zubUoPEBJNC@_EnDTEVY-YG|$C*G&(v75uvCk+p(fH$Api@av|>*9!jd^w2uNA3m1P zdxq8t{_s;n>jZyzdU&1S4^NM*6a3-nv2}t!JUzY+_##jKTf{q(@RT&0>(J{5(yi{` z1-$>B=KY1FVHPX=3kT9)q3~87{RHXrn*2|ZKEFx-bJF}@%lpQ_`)`t#{JOOFUz2X@ zUrl9_P?h(Wl5S=3*O6{@1#Fjn>6=Nvpn+GqZSO6lpVz!UF#!LTf%HX4*48)qUqgEP zK)Tgc?B{*k{#ys~E8XV53o*>fro5jVNdGkHmo@J{M7kZ{50l=}yjQx_7bwmDbv*wm ziB=b*bX(p>2J$~2Mb0Z4_!p4g)ujJ~blaaFB+dV|zGEcX@jrvOrR~q1yuYN${}Ti7 z?<9?6tCsg((k(uGfb>h7_a7g4|M`LRW29T%)$ft!|62a<52XK=bc=7-(*8F8pE6$i z8+fH#{Q8#z>BmX;n*3iGc>e_HR#&BTTi(YI^S1N#Dbk>9ZU3hS(k}wJTKE@}Zt>$H z(rtauu>RZrZ2_5)c-8WiZr8)qzl(o3Y<{=m-n zrOz8k!*2JL-y2B3cp(3WNVoX<>!f>4`=1$j|8EA;e@+^SVI8mUkZ#BOF9*_plkk9? z{Sje(Ie&-v`ziiD!{5{V{RjSD4k0Gg&^e}a54e2v6@to&O=(BqD_^MU>zjEb@7N5RM-gbT-C*97U((U`- zB7Ftr7W%Dpi(kJ@dSCNi>2^Q*9nu#z@4rTRTa*5M((U~J!9e=_f%Wxr(k(u}fb^@H z@|14d|9sNz{9ibbe&Im+B`n5P*S={WeJSa7y>2Ga?w6O5-qN&3>CH`g59xNi50Gx> zXPWdYoBT?*@2?=;?uQ3Sx9d@9{;%_;^p7;@-=VOUfA~YvE&csxq}%@g5$U$Q-z44A z+h<85VXyT)2Q^fSAI~NI>gN4g(rtacf&3SfZu_Hj+n$$@rb=Y;i2heRZ{_)`##c`~ zXU%zQne>9AuY>{77>taJjPf^Do(KR(_(L`3=Zcd54die8u#dFrDDS7Uypn$7K>DhI z{3td0_OL}7)0X#7NVhnD4{5cxl>e@Q^u_D}!fEOFqz#b7{yhGk2dDKS{x0Tk6Mx%~ z`RwKIAMrQC-&@H2Vg4TC?~nQWE}Y3W(09*YGxXiw zc|+g*{&lq-8>zQlJAcXE(x(54^z)l`|264XH0gg!n&n)}`*#EBe?I`fntos02GXkr(x*tjzNt@Xt&Q8}kM{QTj?bOo`?FK$GnSBL{`9U* ziw+$k7QbUL5LHoD!+5P_(9@F+1aM@2>CCQ6&mX#$yi?$X#^be=d6ehNAnE*6(@T8U z-dx@*t-YsBYy3<4?fO2D7Vp%)U)!WVNPXJN%X_6^e})u{5-&qPYu-f1ZmB+J{0Y;)LyM) z&Cwh9`w9M*^+yIt@|Vf5OonALER$iG49jF#Cc`ormdUV8hGjA=lVO<*%Vgkhn7>i} zR`K^-{$9%88~FPP{+7!a^{A_*LvN$rmX~=a>2`1VQ_^kzzom`s-uri?HAaPQeU$v| zde{of(2@hkPc3Es9X?*~Y??Z1+AyVoe)mZ$Vo zQy!XtzVhBky5%jDZp*)dbX%U%EwA`v@D?qdzi}WvL%Q9IkCN7UEd84uNdM!3^j`MD zw!ZH_N4jm_0p4HSl#eE)ul;W#-OlI9f%m6Ki=LGBDc$D3gLFHer%AW;`W>X(`MryD zuPIOIHvhW@((fa^t;w%++n=8z%@ox3DczQLCHvVW&3mQW{p&i?ZU5d)-gf-nL%N+W zrCWU8MdjbmZ)JRx?hT|{{N2s_4b6L{+woKyG7!wpij|eqIsbVVe9!Y=aN!Hry=eXS zzW5~e7=Xz^ZC4h z&xL$m$Y&j&7x7uo=X?3Qn9oc2T*T+4e0qE?<}=CXWqe-F=M{V|;j@9yMn0SPypqqW z_-y90h0he9t$Z%!vyIPoKCkBUeSBWS=Q2L8aZ_UfsX#C^T>#f_!kC!swsAm4EeK49q|1KTcu)p#RS5@Zb+x9L-1TBtH3LNuK?T3|ds@{^{}^eVA4`6vm4E0R-~Zdad;jsb&i~N0 zPha}E+yB?`_gwM&8$WsTU#$D+;cvX|(L4Xc*t;(O-IxE`(QiNhXAb?z_kD5c->>

+bjn|@>dJ1_c$ANuC&A3O7ZtoX?TzxRqy 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/UU4n+FHW2/j+belMMCmlkYzpmb3JtYXRtaW1hZ2Uvc3ZnK3htbGRzaXplGLQAAACPanVtYgAAAChqdW1kY2JvcgARABCAAACqADibcQNjMnBhLmhhc2guZGF0YQAAAABfY2JvcqRqZXhjbHVzaW9uc4GiZXN0YXJ0GFNmbGVuZ3RoGcJyY2FsZ2ZzaGEyNTZkaGFzaFggD2kwh0Xyg1TEWJxY535YLU/P1FOJ/hR1tv4/m3pTDApjcGFkQAAAA4hqdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAADWWNib3Kmamluc3RhbmNlSUR4LXVybjp1dWlkOjUyZWZlMTA0LTlmNjMtNGE0Ny04NjEwLTBmYzU3OTZiYjcyMnRjbGFpbV9nZW5lcmF0b3JfaW5mb6NkbmFtZXgbRW5jeXBoZXIgQ0ROIFByb3ZlbmFuY2UvMS4wZ3ZlcnNpb25jMS4wa3NwZWNWZXJzaW9uZTIuNC4waXNpZ25hdHVyZXhNc2VsZiNqdW1iZj0vYzJwYS91cm46YzJwYTozOWY2OTZjZC1lZDQwLTRjOWEtYjA5MC1jZDFhNGQ0ZTUyOGEvYzJwYS5zaWduYXR1cmVyY3JlYXRlZF9hc3NlcnRpb25zhaNjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFgghpKA0wW1j3Itz6C9P5VHO5QAc0JKktu56EfI33aI63BjYWxnZnNoYTI1NqNjdXJseC5zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9vcmcuZW5jeXBoZXIuc3RhdHVzZGhhc2hYIGrNHEP2yWQbZmHTbx+oiiew3VciSJGJAPWDSsVDeMicY2FsZ2ZzaGEyNTajY3VybHgyc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvY29tLmVuY3lwaGVyLnByb3ZlbmFuY2VkaGFzaFgg5wY94FNsaiKnqVgNbE5arlIgL1B/ZBRL7QCPyRq760JjYWxnZnNoYTI1NqNjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFgg0mZIYnhlJ3bxG0OKZxjETtEfKESULhQIKO4orvKNQXljYWxnZnNoYTI1NqNjdXJseClzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmhhc2guZGF0YWRoYXNoWCAcfvgHstKZD8PealCtWiNHqxf/LabBa6MrZshI7tUx/mNhbGdmc2hhMjU2Y2FsZ2ZzaGEyNTZoZGM6dGl0bGV4QDBmNjkzMDg3NDVmMjgzNTRjNDU4OWM1OGU3N2U1ODJkNGZjZmQ0NTM4OWZlMTQ3NWI2ZmUzZjliN2E1MzBjMGEAAIiOanVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAAIheY2JvctKEWQflogE4IhghglkD7TCCA+kwggNuoAMCAQICFBl/GL93F35s2XBg9wfGygQmz1VqMAoGCCqGSM49BAMDMIGRMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzEXMBUGA1UECgwORW5jeXBoZXIgQ29ycC4xFDASBgNVBAsMC0NBIERpdmlzaW9uMSYwJAYDVQQDDB1FbmN5cGhlciBDMlBBIElzc3VpbmcgQ0EgMjAyNjAeFw0yNjExMDIwMTQxMjlaFw0yNzExMDMwMTQxMjlaMD8xCzAJBgNVBAYTAlVTMR0wGwYDVQQKDBRFbmN5cGhlciBDb3Jwb3JhdGlvbjERMA8GA1UEAwwIRW5jeXBoZXIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQ72e/ngX8g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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYYLQV3a2WJJTpFkeTfFEK5RcCUmufgo/QRsHdS56zPrZH42gPIt6eY8qnSoWVHWjDQfalF0mkPyDZimbBfo9OUYY95EpV7jAcF/4vOMoglhJWVffXiAQ1SI6KAiE6YjyqwA== \ No newline at end of file diff --git a/crates/provenance/encypher/src/fixtures/prepared/svg/prepare.json b/crates/provenance/encypher/src/fixtures/prepared/svg/prepare.json new file mode 100644 index 0000000000..ccf50e959c --- /dev/null +++ b/crates/provenance/encypher/src/fixtures/prepared/svg/prepare.json @@ -0,0 +1 @@ +{"protocol":"encypher-prepared-signing/2","plan_id":"18ec3a3b-1a2c-4c84-a43d-1e3da58a30ad","instance_id":"urn:uuid:52efe104-9f63-4a47-8610-0fc5796bb722","document_id":"doc_152b96ff8cfd2df92e7be175360181db","expires_at":"2026-11-02T02:41:31.073377Z","mime":"image/svg+xml","expected_manifest_size":37264,"effective_action":"c2pa.opened","geometry":{"disposition":"embedded","edits":[{"op":"insert_carrier","offset":83,"length":49778}],"carrier_span":[0,49778],"carrier_output_span":[83,49778],"expected_final_size":49958,"binding_recipe":{"type":"data_hash","algorithm":"sha256","exclusions":[{"offset":83,"length":49778}]},"carrier_recipe":{"type":"standard"},"planner_options":{"tiff_placement":"main_ifd_when_single"}},"policy_digest":"171bf3407b6bb613d78ec55255c2a908acf9f591c2e9aa6e96e4f15e67370cff"} \ No newline at end of file diff --git a/crates/provenance/encypher/src/fixtures/prepared/svg/recover.json b/crates/provenance/encypher/src/fixtures/prepared/svg/recover.json new file mode 100644 index 0000000000..6f6290ecd7 --- /dev/null +++ b/crates/provenance/encypher/src/fixtures/prepared/svg/recover.json @@ -0,0 +1 @@ +{"protocol":"encypher-prepared-signing/2","policy_digest":"171bf3407b6bb613d78ec55255c2a908acf9f591c2e9aa6e96e4f15e67370cff","prepare":{"protocol":"encypher-prepared-signing/2","plan_id":"18ec3a3b-1a2c-4c84-a43d-1e3da58a30ad","instance_id":"urn:uuid:52efe104-9f63-4a47-8610-0fc5796bb722","document_id":"doc_152b96ff8cfd2df92e7be175360181db","expires_at":"2026-11-02T02:41:31.073377Z","mime":"image/svg+xml","expected_manifest_size":37264,"effective_action":"c2pa.opened","geometry":{"edits":[{"op":"insert_carrier","length":49778,"offset":83}],"disposition":"embedded","carrier_span":[0,49778],"binding_recipe":{"type":"data_hash","algorithm":"sha256","exclusions":[{"length":49778,"offset":83}]},"carrier_recipe":{"type":"standard"},"planner_options":{"tiff_placement":"main_ifd_when_single"},"carrier_output_span":[83,49778],"expected_final_size":49958},"policy_digest":"171bf3407b6bb613d78ec55255c2a908acf9f591c2e9aa6e96e4f15e67370cff"},"finalize":{"protocol":"encypher-prepared-signing/2","plan_id":"18ec3a3b-1a2c-4c84-a43d-1e3da58a30ad","evidence_id":"ef0ab30a-2a24-49aa-b022-43b071b75084","instance_id":"urn:uuid:52efe104-9f63-4a47-8610-0fc5796bb722","document_id":"doc_152b96ff8cfd2df92e7be175360181db","asset_id":"doc_152b96ff8cfd2df92e7be175360181db","mime":"image/svg+xml","content_hash_kind":"hard_binding","binding_algorithm":"c2pa.hash.data","binding_commitment":"0f69308745f28354c4589c58e77e582d4fcfd45389fe1475b6fe3f9b7a530c0a","binding":{"type":"data_hash","digest":"0f69308745f28354c4589c58e77e582d4fcfd45389fe1475b6fe3f9b7a530c0a"},"manifest_store_b64":"AACRkGp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAAJFqanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTozOWY2OTZjZC1lZDQwLTRjOWEtYjA5MC1jZDFhNGQ0ZTUyOGEAAAAFBWp1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAAOpqdW1iAAAAKWp1bWRjYm9yABEAEIAAAKoAOJtxA2MycGEuYWN0aW9ucy52MgAAAAC5Y2JvcqJnYWN0aW9uc4GiZmFjdGlvbmtjMnBhLm9wZW5lZGpwYXJhbWV0ZXJzoWtpbmdyZWRpZW50c4GjY3VybHgtc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5pbmdyZWRpZW50LnYzY2FsZ2ZzaGEyNTZkaGFzaFgg0mZIYnhlJ3bxG0OKZxjETtEfKESULhQIKO4orvKNQXlyYWxsQWN0aW9uc0luY2x1ZGVk9QAAALdqdW1iAAAALWp1bWRjYm9yABEAEIAAAKoAOJtxA29yZy5lbmN5cGhlci5zdGF0dXMAAAAAgmNib3KidHN0YXR1c0xpc3RDcmVkZW50aWFseFBodHRwczovL3ZlcmlmeS5lbmN5cGhlci5jb20vc3RhdHVzL3YxL2xpc3RzL2JjZmUwMDE0LTk1ZWEtNGI2MS1iZGNhLTgzMDE5NWE0NGNkNW9zdGF0dXNMaXN0SW5kZXhhNwAAAP9qdW1iAAAAMWp1bWRjYm9yABEAEIAAAKoAOJtxA2NvbS5lbmN5cGhlci5wcm92ZW5hbmNlAAAAAMZjYm9ypW9vcmdhbml6YXRpb25faWR0b3JnX2Nkbl80ODNkYWY1MGQ2NDVpc2lnbmVyX2lkbWVuY3lwaGVyLmZyZWVrZG9jdW1lbnRfaWR4JGRvY18xNTJiOTZmZjhjZmQyZGY5MmU3YmUxNzUzNjAxODFkYmhpbWFnZV9pZHgkZG9jXzE1MmI5NmZmOGNmZDJkZjkyZTdiZTE3NTM2MDE4MWRiaXNpZ25lZF9hdHQyMDI2LTExLTAyVDAxOjQxOjMwWgAAAaVqdW1iAAAALGp1bWRjYm9yABEAEIAAAKoAOJtxA2MycGEuaW5ncmVkaWVudC52MwAAAAFxY2JvcqRscmVsYXRpb25zaGlwaHBhcmVudE9maGRjOnRpdGxleEAwZjY5MzA4NzQ1ZjI4MzU0YzQ1ODljNThlNzdlNTgyZDRmY2ZkNDUzODlmZTE0NzViNmZlM2Y5YjdhNTMwYzBhaWRjOmZvcm1hdG1pbWFnZS9zdmcreG1sZGRhdGGlY3VybHiSaHR0cHM6Ly9hc3luYy1wcmVwYXJlZC1maXh0dXJlLnB1Ymxpc2hlci5leGFtcGxlLmNvbS9fZW5jeXBoZXIvcHJlcGFyYXRpb24vc291cmNlLzBmNjkzMDg3NDVmMjgzNTRjNDU4OWM1OGU3N2U1ODJkNGZjZmQ0NTM4OWZlMTQ3NWI2ZmUzZjliN2E1MzBjMGFjYWxnZnNoYTI1NmRoYXNoWCAPaTCHRfKDVMRYnFjnflgtT8/UU4n+FHW2/j+belMMCmlkYzpmb3JtYXRtaW1hZ2Uvc3ZnK3htbGRzaXplGLQAAACPanVtYgAAAChqdW1kY2JvcgARABCAAACqADibcQNjMnBhLmhhc2guZGF0YQAAAABfY2JvcqRqZXhjbHVzaW9uc4GiZXN0YXJ0GFNmbGVuZ3RoGcJyY2FsZ2ZzaGEyNTZkaGFzaFggD2kwh0Xyg1TEWJxY535YLU/P1FOJ/hR1tv4/m3pTDApjcGFkQAAAA4hqdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAADWWNib3Kmamluc3RhbmNlSUR4LXVybjp1dWlkOjUyZWZlMTA0LTlmNjMtNGE0Ny04NjEwLTBmYzU3OTZiYjcyMnRjbGFpbV9nZW5lcmF0b3JfaW5mb6NkbmFtZXgbRW5jeXBoZXIgQ0ROIFByb3ZlbmFuY2UvMS4wZ3ZlcnNpb25jMS4wa3NwZWNWZXJzaW9uZTIuNC4waXNpZ25hdHVyZXhNc2VsZiNqdW1iZj0vYzJwYS91cm46YzJwYTozOWY2OTZjZC1lZDQwLTRjOWEtYjA5MC1jZDFhNGQ0ZTUyOGEvYzJwYS5zaWduYXR1cmVyY3JlYXRlZF9hc3NlcnRpb25zhaNjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFgghpKA0wW1j3Itz6C9P5VHO5QAc0JKktu56EfI33aI63BjYWxnZnNoYTI1NqNjdXJseC5zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9vcmcuZW5jeXBoZXIuc3RhdHVzZGhhc2hYIGrNHEP2yWQbZmHTbx+oiiew3VciSJGJAPWDSsVDeMicY2FsZ2ZzaGEyNTajY3VybHgyc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvY29tLmVuY3lwaGVyLnByb3ZlbmFuY2VkaGFzaFgg5wY94FNsaiKnqVgNbE5arlIgL1B/ZBRL7QCPyRq760JjYWxnZnNoYTI1NqNjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFgg0mZIYnhlJ3bxG0OKZxjETtEfKESULhQIKO4orvKNQXljYWxnZnNoYTI1NqNjdXJseClzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmhhc2guZGF0YWRoYXNoWCAcfvgHstKZD8PealCtWiNHqxf/LabBa6MrZshI7tUx/mNhbGdmc2hhMjU2Y2FsZ2ZzaGEyNTZoZGM6dGl0bGV4QDBmNjkzMDg3NDVmMjgzNTRjNDU4OWM1OGU3N2U1ODJkNGZjZmQ0NTM4OWZlMTQ3NWI2ZmUzZjliN2E1MzBjMGEAAIiOanVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAAIheY2JvctKEWQflogE4IhghglkD7TCCA+kwggNuoAMCAQICFBl/GL93F35s2XBg9wfGygQmz1VqMAoGCCqGSM49BAMDMIGRMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzEXMBUGA1UECgwORW5jeXBoZXIgQ29ycC4xFDASBgNVBAsMC0NBIERpdmlzaW9uMSYwJAYDVQQDDB1FbmN5cGhlciBDMlBBIElzc3VpbmcgQ0EgMjAyNjAeFw0yNjExMDIwMTQxMjlaFw0yNzExMDMwMTQxMjlaMD8xCzAJBgNVBAYTAlVTMR0wGwYDVQQKDBRFbmN5cGhlciBDb3Jwb3JhdGlvbjERMA8GA1UEAwwIRW5jeXBoZXIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQ72e/ngX8g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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYYLQV3a2WJJTpFkeTfFEK5RcCUmufgo/QRsHdS56zPrZH42gPIt6eY8qnSoWVHWjDQfalF0mkPyDZimbBfo9OUYY95EpV7jAcF/4vOMoglhJWVffXiAQ1SI6KAiE6YjyqwA==","manifest_sha256":"6115eb178d5eefaf985f93c858ab0cca0d9f2b39780206efca5bb39903d6b974","carrier_sha256":"2f203a04cff50e55f5f6a9ffcef6e4bb179abb7faf0a8aa1f43d3be59b3eeae6","expected_final_size":49958,"verification_url":"https://api.async-prepared-fixture.publisher.example.com/api/v1/verify/doc_152b96ff8cfd2df92e7be175360181db","watermark_applied":false,"reused":false,"policy_digest":"171bf3407b6bb613d78ec55255c2a908acf9f591c2e9aa6e96e4f15e67370cff"}} \ No newline at end of file diff --git a/crates/provenance/encypher/src/fixtures/prepared/svg/source.svg b/crates/provenance/encypher/src/fixtures/prepared/svg/source.svg new file mode 100644 index 0000000000..d2c0e95e01 --- /dev/null +++ b/crates/provenance/encypher/src/fixtures/prepared/svg/source.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/provenance/encypher/src/fixtures/prepared/webp/envelope.json b/crates/provenance/encypher/src/fixtures/prepared/webp/envelope.json new file mode 100644 index 0000000000..32f7f91f21 --- /dev/null +++ b/crates/provenance/encypher/src/fixtures/prepared/webp/envelope.json @@ -0,0 +1,89 @@ +{ + "deployment_id": "async-prepared-api-fixture", + "identity": "1d47e07ae1fc82f117dac16991e9b6b7c10af991fb7aa81d152e3b908f07eba7", + "item": { + "kind": "embedded", + "output_b64": "UklGRsaRAABXRUJQVlA4TCgAAAAvL8AHALkKRPQ/dlGKQvQ/oLZtFOhe2H9tagoCSVu87R+ESEB/vXjnQzJQQYqRAAAAAJGKanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNjMnBhAAAAkWRqdW1iAAAAR2p1bWRjMm1hABEAEIAAAKoAOJtxA3VybjpjMnBhOjIxMjJjZjAzLTBiNmEtNDU5OC1hNGQxLTBlZjVjMDRmNWM1ZgAAAAT/anVtYgAAAClqdW1kYzJhcwARABCAAACqADibcQNjMnBhLmFzc2VydGlvbnMAAAAA6mp1bWIAAAApanVtZGNib3IAEQAQgAAAqgA4m3EDYzJwYS5hY3Rpb25zLnYyAAAAALljYm9yomdhY3Rpb25zgaJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaNjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNjYWxnZnNoYTI1NmRoYXNoWCAWyxTntr6Kk9TWP2dcx1fPg3IwV1OTBoh+2v4IZGYXcnJhbGxBY3Rpb25zSW5jbHVkZWT1AAAAt2p1bWIAAAAtanVtZGNib3IAEQAQgAAAqgA4m3EDb3JnLmVuY3lwaGVyLnN0YXR1cwAAAACCY2JvcqJ0c3RhdHVzTGlzdENyZWRlbnRpYWx4UGh0dHBzOi8vdmVyaWZ5LmVuY3lwaGVyLmNvbS9zdGF0dXMvdjEvbGlzdHMvYmNmZTAwMTQtOTVlYS00YjYxLWJkY2EtODMwMTk1YTQ0Y2Q1b3N0YXR1c0xpc3RJbmRleGE1AAAA/2p1bWIAAAAxanVtZGNib3IAEQAQgAAAqgA4m3EDY29tLmVuY3lwaGVyLnByb3ZlbmFuY2UAAAAAxmNib3Klb29yZ2FuaXphdGlvbl9pZHRvcmdfY2RuXzQ4M2RhZjUwZDY0NWlzaWduZXJfaWRtZW5jeXBoZXIuZnJlZWtkb2N1bWVudF9pZHgkZG9jXzIxN2ZhMTRiODMzMzExMzFkZmRhZWIxZGQ3N2E4OGY0aGltYWdlX2lkeCRkb2NfMjE3ZmExNGI4MzMzMTEzMWRmZGFlYjFkZDc3YTg4ZjRpc2lnbmVkX2F0dDIwMjYtMTEtMDJUMDE6NDE6MzBaAAABn2p1bWIAAAAsanVtZGNib3IAEQAQgAAAqgA4m3EDYzJwYS5pbmdyZWRpZW50LnYzAAAAAWtjYm9ypGxyZWxhdGlvbnNoaXBocGFyZW50T2ZoZGM6dGl0bGV4QGFiYzI4NmQ2NWQ0YTgyM2JhYjA0NmQ0OWVjNWEzOTQ5ZDdiZDE2OGYyYmMyMGY1MjA5ZDI3NTg0YzZlNTVmMWJpZGM6Zm9ybWF0amltYWdlL3dlYnBkZGF0YaVjdXJseJJodHRwczovL2FzeW5jLXByZXBhcmVkLWZpeHR1cmUucHVibGlzaGVyLmV4YW1wbGUuY29tL19lbmN5cGhlci9wcmVwYXJhdGlvbi9zb3VyY2UvYWJjMjg2ZDY1ZDRhODIzYmFiMDQ2ZDQ5ZWM1YTM5NDlkN2JkMTY4ZjJiYzIwZjUyMDlkMjc1ODRjNmU1NWYxYmNhbGdmc2hhMjU2ZGhhc2hYIKvChtZdSoI7qwRtSexaOUnXvRaPK8IPUgnSdYTG5V8baWRjOmZvcm1hdGppbWFnZS93ZWJwZHNpemUYPAAAAI9qdW1iAAAAKGp1bWRjYm9yABEAEIAAAKoAOJtxA2MycGEuaGFzaC5kYXRhAAAAAF9jYm9ypGpleGNsdXNpb25zgaJlc3RhcnQYPGZsZW5ndGgZkZJjYWxnZnNoYTI1NmRoYXNoWCBnsGEwhciZguuI1n8svFbtUiWcztWLMc4uaOZKMtq0EGNwYWRAAAADiGp1bWIAAAAnanVtZGMyY2wAEQAQgAAAqgA4m3EDYzJwYS5jbGFpbS52MgAAAANZY2JvcqZqaW5zdGFuY2VJRHgtdXJuOnV1aWQ6OTJkYTBlMWYtZGY0NS00ZDcyLWFjMmEtMzk1MTNjNmIyM2M5dGNsYWltX2dlbmVyYXRvcl9pbmZvo2RuYW1leBtFbmN5cGhlciBDRE4gUHJvdmVuYW5jZS8xLjBndmVyc2lvbmMxLjBrc3BlY1ZlcnNpb25lMi40LjBpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjIxMjJjZjAzLTBiNmEtNDU5OC1hNGQxLTBlZjVjMDRmNWM1Zi9jMnBhLnNpZ25hdHVyZXJjcmVhdGVkX2Fzc2VydGlvbnOFo2N1cmx4KnNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuYWN0aW9ucy52MmRoYXNoWCCV2FlI4cTI4ABIGA/Ax8oRZLELRK2paoxu6igVPYVk3GNhbGdmc2hhMjU2o2N1cmx4LnNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL29yZy5lbmN5cGhlci5zdGF0dXNkaGFzaFggTIi4N16MBC8etbksqVikDgRWr7NZ2bjFv+YQF+VH95xjYWxnZnNoYTI1NqNjdXJseDJzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jb20uZW5jeXBoZXIucHJvdmVuYW5jZWRoYXNoWCB1f0fuprPoa7wFgtkHhqKNIb8zCd4V+p599MmmNPpgO2NhbGdmc2hhMjU2o2N1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCAWyxTntr6Kk9TWP2dcx1fPg3IwV1OTBoh+2v4IZGYXcmNhbGdmc2hhMjU2o2N1cmx4KXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaGFzaC5kYXRhZGhhc2hYIKaXmWVbI16UofojLUlRTPCCcq4VHb3jhInCIePc7L/XY2FsZ2ZzaGEyNTZjYWxnZnNoYTI1NmhkYzp0aXRsZXhAYWJjMjg2ZDY1ZDRhODIzYmFiMDQ2ZDQ5ZWM1YTM5NDlkN2JkMTY4ZjJiYzIwZjUyMDlkMjc1ODRjNmU1NWYxYgAAiI5qdW1iAAAAKGp1bWRjMmNzABEAEIAAAKoAOJtxA2MycGEuc2lnbmF0dXJlAAAAiF5jYm9y0oRZB+WiATgiGCGCWQPtMIID6TCCA26gAwIBAgIUGX8Yv3cXfmzZcGD3B8bKBCbPVWowCgYIKoZIzj0EAwMwgZExCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMRcwFQYDVQQKDA5FbmN5cGhlciBDb3JwLjEUMBIGA1UECwwLQ0EgRGl2aXNpb24xJjAkBgNVBAMMHUVuY3lwaGVyIEMyUEEgSXNzdWluZyBDQSAyMDI2MB4XDTI2MTEwMjAxNDEyOVoXDTI3MTEwMzAxNDEyOVowPzELMAkGA1UEBhMCVVMxHTAbBgNVBAoMFEVuY3lwaGVy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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9lhgWB6LT2m5Vx2qcAJLpSAu0BPDeHd6Kcl0R3HmdVr0dc266B3P0yey/xVuK0bpLUSFOAF3w909EIm2bsES3PmX2E6EQomKeDLP1t7xa6yzYDJJecxCB0GVKnklAI7r+lQy", + "planned": { + "digest": "67b0613085c89982eb88d67f2cbc56ed52259cced58b31ce2e68e64a32dab410", + "geometry": { + "binding_recipe": { + "algorithm": "sha256", + "exclusions": [ + { + "length": 37266, + "offset": 60 + } + ], + "type": "data_hash" + }, + "carrier_output_span": [ + 60, + 37266 + ], + "carrier_recipe": { + "type": "standard" + }, + "carrier_span": [ + 0, + 37266 + ], + "disposition": "embedded", + "edits": [ + { + "b64": "xpEAAA==", + "offset": 4, + "op": "patch" + }, + { + "length": 37266, + "offset": 60, + "op": "insert_carrier" + } + ], + "expected_final_size": 37326, + "planner_options": { + "tiff_placement": "main_ifd_when_single" + } + }, + "idempotency_key": "async-prepared-fixture:webp:14daf9fd8ccc45ae8d87d190129b9846", + "plan_id": "29fe90ad-757b-4321-93f0-128f4f61aa6d", + "release": { + "abi": 1, + "planner_id": "05079ae09f9fc9af0b97683ee52c3fea6e6be0c30c993eb6d30dac36ec811a39", + "release": 18, + "sha256": "eb0c58b5178500a1fd08e351ab2237582f5a9aedad71871f3580ea722ad10a6f", + "signature": "33Dab6p1n5yJpIbXypes+yr6xXalcFonsh5h9YaFzMo18zFMHlNDXzriZNOe5LHZQ03P7Ki0T4ZXT+QNS6okCw==" + }, + "request": { + "carrier_requirement": "embedded_required", + "contract_version": 2, + "expected_policy_digest": "171bf3407b6bb613d78ec55255c2a908acf9f591c2e9aa6e96e4f15e67370cff", + "page_url": "https://async-prepared-fixture.publisher.example.com/article", + "probe": { + "planner_id": "05079ae09f9fc9af0b97683ee52c3fea6e6be0c30c993eb6d30dac36ec811a39", + "sha256": "eb0c58b5178500a1fd08e351ab2237582f5a9aedad71871f3580ea722ad10a6f" + }, + "protocol": "encypher-prepared-signing/2", + "source": { + "byte_length": 60, + "mime": "image/webp", + "origin_host": "async-prepared-fixture.publisher.example.com", + "reference": "https://async-prepared-fixture.publisher.example.com/_encypher/preparation/source/abc286d65d4a823bab046d49ec5a3949d7bd168f2bc20f5209d27584c6e55f1b", + "reference_kind": "publisher_snapshot", + "sha256": "abc286d65d4a823bab046d49ec5a3949d7bd168f2bc20f5209d27584c6e55f1b" + }, + "structure": [ + { + "b64": "UklGRjQAAABXRUJQVlA4TCgAAAA=", + "offset": 0 + } + ] + } + }, + "source_b64": "UklGRjQAAABXRUJQVlA4TCgAAAAvL8AHALkKRPQ/dlGKQvQ/oLZtFOhe2H9tagoCSVu87R+ESEB/vXjn" + }, + "protocol": "encypher-preparation/1", + "retirement_epoch": 0, + "variant": "" +} diff --git a/crates/provenance/encypher/src/fixtures/prepared/webp/finalize.json b/crates/provenance/encypher/src/fixtures/prepared/webp/finalize.json new file mode 100644 index 0000000000..38fa675078 --- /dev/null +++ b/crates/provenance/encypher/src/fixtures/prepared/webp/finalize.json @@ -0,0 +1 @@ +{"protocol":"encypher-prepared-signing/2","plan_id":"29fe90ad-757b-4321-93f0-128f4f61aa6d","evidence_id":"73947fcf-88ba-4e3d-b696-fb14ee442448","instance_id":"urn:uuid:92da0e1f-df45-4d72-ac2a-39513c6b23c9","document_id":"doc_217fa14b83331131dfdaeb1dd77a88f4","asset_id":"doc_217fa14b83331131dfdaeb1dd77a88f4","mime":"image/webp","content_hash_kind":"hard_binding","binding_algorithm":"c2pa.hash.data","binding_commitment":"67b0613085c89982eb88d67f2cbc56ed52259cced58b31ce2e68e64a32dab410","binding":{"type":"data_hash","digest":"67b0613085c89982eb88d67f2cbc56ed52259cced58b31ce2e68e64a32dab410"},"manifest_store_b64":"AACRimp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAAJFkanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYToyMTIyY2YwMy0wYjZhLTQ1OTgtYTRkMS0wZWY1YzA0ZjVjNWYAAAAE/2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAAOpqdW1iAAAAKWp1bWRjYm9yABEAEIAAAKoAOJtxA2MycGEuYWN0aW9ucy52MgAAAAC5Y2JvcqJnYWN0aW9uc4GiZmFjdGlvbmtjMnBhLm9wZW5lZGpwYXJhbWV0ZXJzoWtpbmdyZWRpZW50c4GjY3VybHgtc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5pbmdyZWRpZW50LnYzY2FsZ2ZzaGEyNTZkaGFzaFggFssU57a+ipPU1j9nXMdXz4NyMFdTkwaIftr+CGRmF3JyYWxsQWN0aW9uc0luY2x1ZGVk9QAAALdqdW1iAAAALWp1bWRjYm9yABEAEIAAAKoAOJtxA29yZy5lbmN5cGhlci5zdGF0dXMAAAAAgmNib3KidHN0YXR1c0xpc3RDcmVkZW50aWFseFBodHRwczovL3ZlcmlmeS5lbmN5cGhlci5jb20vc3RhdHVzL3YxL2xpc3RzL2JjZmUwMDE0LTk1ZWEtNGI2MS1iZGNhLTgzMDE5NWE0NGNkNW9zdGF0dXNMaXN0SW5kZXhhNQAAAP9qdW1iAAAAMWp1bWRjYm9yABEAEIAAAKoAOJtxA2NvbS5lbmN5cGhlci5wcm92ZW5hbmNlAAAAAMZjYm9ypW9vcmdhbml6YXRpb25faWR0b3JnX2Nkbl80ODNkYWY1MGQ2NDVpc2lnbmVyX2lkbWVuY3lwaGVyLmZyZWVrZG9jdW1lbnRfaWR4JGRvY18yMTdmYTE0YjgzMzMxMTMxZGZkYWViMWRkNzdhODhmNGhpbWFnZV9pZHgkZG9jXzIxN2ZhMTRiODMzMzExMzFkZmRhZWIxZGQ3N2E4OGY0aXNpZ25lZF9hdHQyMDI2LTExLTAyVDAxOjQxOjMwWgAAAZ9qdW1iAAAALGp1bWRjYm9yABEAEIAAAKoAOJtxA2MycGEuaW5ncmVkaWVudC52MwAAAAFrY2JvcqRscmVsYXRpb25zaGlwaHBhcmVudE9maGRjOnRpdGxleEBhYmMyODZkNjVkNGE4MjNiYWIwNDZkNDllYzVhMzk0OWQ3YmQxNjhmMmJjMjBmNTIwOWQyNzU4NGM2ZTU1ZjFiaWRjOmZvcm1hdGppbWFnZS93ZWJwZGRhdGGlY3VybHiSaHR0cHM6Ly9hc3luYy1wcmVwYXJlZC1maXh0dXJlLnB1Ymxpc2hlci5leGFtcGxlLmNvbS9fZW5jeXBoZXIvcHJlcGFyYXRpb24vc291cmNlL2FiYzI4NmQ2NWQ0YTgyM2JhYjA0NmQ0OWVjNWEzOTQ5ZDdiZDE2OGYyYmMyMGY1MjA5ZDI3NTg0YzZlNTVmMWJjYWxnZnNoYTI1NmRoYXNoWCCrwobWXUqCO6sEbUnsWjlJ170WjyvCD1IJ0nWExuVfG2lkYzpmb3JtYXRqaW1hZ2Uvd2VicGRzaXplGDwAAACPanVtYgAAAChqdW1kY2JvcgARABCAAACqADibcQNjMnBhLmhhc2guZGF0YQAAAABfY2JvcqRqZXhjbHVzaW9uc4GiZXN0YXJ0GDxmbGVuZ3RoGZGSY2FsZ2ZzaGEyNTZkaGFzaFggZ7BhMIXImYLriNZ/LLxW7VIlnM7VizHOLmjmSjLatBBjcGFkQAAAA4hqdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAADWWNib3Kmamluc3RhbmNlSUR4LXVybjp1dWlkOjkyZGEwZTFmLWRmNDUtNGQ3Mi1hYzJhLTM5NTEzYzZiMjNjOXRjbGFpbV9nZW5lcmF0b3JfaW5mb6NkbmFtZXgbRW5jeXBoZXIgQ0ROIFByb3ZlbmFuY2UvMS4wZ3ZlcnNpb25jMS4wa3NwZWNWZXJzaW9uZTIuNC4waXNpZ25hdHVyZXhNc2VsZiNqdW1iZj0vYzJwYS91cm46YzJwYToyMTIyY2YwMy0wYjZhLTQ1OTgtYTRkMS0wZWY1YzA0ZjVjNWYvYzJwYS5zaWduYXR1cmVyY3JlYXRlZF9hc3NlcnRpb25zhaNjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggldhZSOHEyOAASBgPwMfKEWSxC0StqWqMbuooFT2FZNxjYWxnZnNoYTI1NqNjdXJseC5zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9vcmcuZW5jeXBoZXIuc3RhdHVzZGhhc2hYIEyIuDdejAQvHrW5LKlYpA4EVq+zWdm4xb/mEBflR/ecY2FsZ2ZzaGEyNTajY3VybHgyc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvY29tLmVuY3lwaGVyLnByb3ZlbmFuY2VkaGFzaFggdX9H7qaz6Gu8BYLZB4aijSG/MwneFfqeffTJpjT6YDtjYWxnZnNoYTI1NqNjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggFssU57a+ipPU1j9nXMdXz4NyMFdTkwaIftr+CGRmF3JjYWxnZnNoYTI1NqNjdXJseClzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmhhc2guZGF0YWRoYXNoWCCml5llWyNelKH6Iy1JUUzwgnKuFR2944SJwiHj3Oy/12NhbGdmc2hhMjU2Y2FsZ2ZzaGEyNTZoZGM6dGl0bGV4QGFiYzI4NmQ2NWQ0YTgyM2JhYjA0NmQ0OWVjNWEzOTQ5ZDdiZDE2OGYyYmMyMGY1MjA5ZDI3NTg0YzZlNTVmMWIAAIiOanVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAAIheY2JvctKEWQflogE4IhghglkD7TCCA+kwggNuoAMCAQICFBl/GL93F35s2XBg9wfGygQmz1VqMAoGCCqGSM49BAMDMIGRMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzEXMBUGA1UECgwORW5jeXBoZXIgQ29ycC4xFDASBgNVBAsMC0NBIERpdmlzaW9uMSYwJAYDVQQDDB1FbmN5cGhlciBDMlBBIElzc3VpbmcgQ0EgMjAyNjAeFw0yNjExMDIwMTQxMjlaFw0yNzExMDMwMTQxMjlaMD8xCzAJBgNVBAYTAlVTMR0wGwYDVQQKDBRFbmN5cGhlciBDb3Jwb3JhdGlvbjERMA8GA1UEAwwIRW5jeXBoZXIwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQ72e/ngX8g69guPOYH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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYYFgei09puVcdqnACS6UgLtATw3h3einJdEdx5nVa9HXNuugdz9Mnsv8VbitG6S1EhTgBd8PdPRCJtm7BEtz5l9hOhEKJingyz9be8Wuss2AySXnMQgdBlSp5JQCO6/pUMg==","manifest_sha256":"5b153eab95513224c7a3028aa49b478e4313b4aabf8069e8b91c100584547b9e","carrier_sha256":"d254e0a642933492db5b05dfb56e2fab848b56d6211962f014ade509f07f738c","expected_final_size":37326,"verification_url":"https://api.async-prepared-fixture.publisher.example.com/api/v1/verify/doc_217fa14b83331131dfdaeb1dd77a88f4","watermark_applied":false,"reused":false,"policy_digest":"171bf3407b6bb613d78ec55255c2a908acf9f591c2e9aa6e96e4f15e67370cff"} \ No newline at end of file diff --git a/crates/provenance/encypher/src/fixtures/prepared/webp/output.webp b/crates/provenance/encypher/src/fixtures/prepared/webp/output.webp new file mode 100644 index 0000000000000000000000000000000000000000..7c8e6b9101119570cf812f484c9fc12714c79f44 GIT binary patch literal 37326 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 literal 0 HcmV?d00001 diff --git a/crates/provenance/encypher/src/lib.rs b/crates/provenance/encypher/src/lib.rs index e5ec65909b..8ba772679b 100644 --- a/crates/provenance/encypher/src/lib.rs +++ b/crates/provenance/encypher/src/lib.rs @@ -61,6 +61,7 @@ mod data_uri; mod embed; mod embedded; mod media_api; +pub mod preparation; mod prepared_probe; mod processor; mod svg_equivalence; @@ -89,6 +90,7 @@ const STYLESHEET_MEDIA_TYPE: &str = "text/css"; #[derive(Debug, Clone, Deserialize, Validate)] #[serde(deny_unknown_fields)] #[validate(schema(function = "validate_hold_exceeds_timeout"))] +#[validate(schema(function = "validate_execution"))] pub struct EncypherConfig { /// Encypher API origin, for example `https://api.example.com`. Must be https. #[validate(custom(function = validate_api_base_url))] @@ -168,6 +170,16 @@ pub struct EncypherConfig { #[serde(default = "default_max_large_media_ms")] #[validate(range(min = 5_000, max = 600_000))] pub max_large_media_ms: u64, + /// How the module signs: `synchronous`, the default, where a reader's + /// delivery signs what it holds no result for, or `async_prepared`, + /// where the publisher's preparation worker signs and delivery only + /// looks up published results, see [`preparation`]. + #[serde(default)] + pub execution: preparation::Execution, + /// `[provenance.encypher.preparation]`, required by `async_prepared`. + #[serde(default)] + #[validate(nested)] + pub preparation: Option, } /// The prepared service's largest source, in bytes: the largest integer a @@ -280,17 +292,44 @@ fn validate_hold_exceeds_timeout(config: &EncypherConfig) -> Result<(), Validati } } +/// `async_prepared` needs its preparation table: it never falls back to +/// signing in delivery, so without a worker nothing would ever be signed. +fn validate_execution(config: &EncypherConfig) -> Result<(), ValidationError> { + if config.execution == preparation::Execution::AsyncPrepared && config.preparation.is_none() { + return Err(ValidationError::new( + "async_prepared_requires_preparation_table", + )); + } + Ok(()) +} + /// The worker token, named in `[provenance.encypher]` by its entry in the -/// secret store, which is in use whenever the module is selected. -const SECRET_SETTINGS: &[ModuleSecretSetting] = &[ModuleSecretSetting { - path: &["worker_token"], - in_use: always_in_use, -}]; +/// secret store, which is in use whenever the module is selected, and the +/// preparation scopes' secrets, in use in `async_prepared` mode. +const SECRET_SETTINGS: &[ModuleSecretSetting] = &[ + ModuleSecretSetting { + path: &["worker_token"], + in_use: always_in_use, + }, + ModuleSecretSetting { + path: &["preparation", "signal_secret"], + in_use: async_prepared_in_use, + }, + ModuleSecretSetting { + path: &["preparation", "publication_secret"], + in_use: async_prepared_in_use, + }, +]; fn always_in_use(_table: &serde_json::Map) -> bool { true } +fn async_prepared_in_use(table: &serde_json::Map) -> bool { + table.get("execution").and_then(serde_json::Value::as_str) == Some("async_prepared") + && table.contains_key("preparation") +} + /// The changes the module supplies, as `[[serve]]` entries name them: the /// page's article and embedded media, and a stylesheet's embedded media. const SUPPLIED_MIDDLEWARE: &[SuppliedMiddleware] = &[ @@ -309,6 +348,75 @@ pub fn builder() -> IntegrationBuilder { .with_module_name(MODULE) .with_secret_settings(SECRET_SETTINGS) .with_middleware(SUPPLIED_MIDDLEWARE) + // Removes every preparation snapshot capability from every request, + // and admits only one that verifies, see `preparation::edge`. + .with_request_preparer(preparation::edge::prepare_request) +} + +/// Refuses an `async_prepared` table whose public host is not the claimed +/// domain or its `www.` name, so every prepared address is on the claim. +fn require_claimed_public_host(config: &EncypherConfig) -> Result<(), Report> { + let Some(preparation) = config + .preparation + .as_ref() + .filter(|_| config.execution == preparation::Execution::AsyncPrepared) + else { + return Ok(()); + }; + let host = preparation.public_host.as_str(); + if host.strip_prefix("www.").unwrap_or(host) == config.domain { + return Ok(()); + } + Err(Report::new(TrustedServerError::Settings { + message: format!( + "[provenance.encypher.preparation] public_host `{host}` is not the claimed domain \ + `{}` or its `www.` name", + config.domain + ), + })) +} + +/// What the platform the module registers on can do for a reader's hint, +/// as its adapter declared before serving. +#[derive(Debug, Clone, Copy)] +struct HintLifetimes { + /// Every request's services carry a real timer. + timer: bool, + /// The instance sends handed-over requests after the response. + after_response: bool, +} + +impl HintLifetimes { + fn declared() -> Self { + Self { + timer: trusted_server_core::platform::platform_offers_timer(), + after_response: trusted_server_core::platform::platform_offers_after_response_handoff(), + } + } +} + +/// Refuses reader hints on a platform that can neither bound a hint while +/// the reader waits nor send it after the response. Warming, publication +/// events and reconciliation still prepare content there. +fn require_reader_trigger_capability( + config: &EncypherConfig, + lifetimes: HintLifetimes, +) -> Result<(), Report> { + let wants = config.execution == preparation::Execution::AsyncPrepared + && config + .preparation + .as_ref() + .is_some_and(|preparation| preparation.reader_trigger); + if !wants || lifetimes.timer || lifetimes.after_response { + return Ok(()); + } + Err(Report::new(TrustedServerError::Settings { + message: "[provenance.encypher.preparation] reader_trigger needs a platform with a \ + timer or an after-response lifetime, and this one declares neither; set \ + reader_trigger = false and prepare through warming, publication events and \ + reconciliation" + .to_owned(), + })) } /// Refuses a document that selects the module and names its middleware in no @@ -372,6 +480,7 @@ fn config(settings: &Settings) -> Result, Report; +/// What the preparation decided for one document. +enum Admission { + /// Nothing to do. + Pass, + /// The page to sign or look up. + Hold(PageRequest), + /// A verified preparation snapshot read: nothing is signed or looked + /// up, and the answer reports what the origin's own headers said. + Snapshot(preparation::edge::SnapshotGrant), +} + +/// The snapshot grant the module's request preparer left on this +/// document's request, in the asynchronous mode. +fn snapshot_grant( + signer: &Signer, + context: &MiddlewareContext<'_>, +) -> Option { + if !signer.is_async() { + return None; + } + context + .document_state + .get::(INTEGRATION_ID) + .map(|grant| (*grant).clone()) +} + +/// The facts header a snapshot read of a document answers with: the +/// origin's status and privacy as core read them before its own rewrites. +fn snapshot_action( + signer: &Signer, + context: &MiddlewareContext<'_>, + grant: &preparation::edge::SnapshotGrant, +) -> MiddlewareAction { + let Some(preparation) = signer.preparation() else { + return MiddlewareAction::pass(); + }; + let headers = context.response_headers; + let directive = |name: &str| { + headers.is_some_and(|headers| { + headers + .get_all(http::header::CACHE_CONTROL) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|part| part.trim().eq_ignore_ascii_case(name)) + }) + }; + let manifest_link = headers.is_some_and(|headers| { + headers + .get_all(http::header::LINK) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| value.to_ascii_lowercase().contains("c2pa-manifest")) + }); + let origin = preparation::protocol::SnapshotOrigin { + status: context.status.as_u16(), + private: context.origin_private, + no_transform: directive("no-transform"), + manifest_link, + content_encoding: String::new(), + origin_host: context.origin_host.to_ascii_lowercase(), + // HTML identity binds the actual bounded projection, and this + // context does not expose core's configured media-origin identity. + // An empty digest cannot admit a large-source locator. + origin_identity_sha256: String::new(), + source_reference: None, + }; + // The facts above were read from the origin's own headers; the + // snapshot's transport is then marked internal so no cache keeps it + // for a reader. + let transport = ( + http::header::CACHE_CONTROL, + http::HeaderValue::from_static("private, no-store"), + ); + MiddlewareAction { + response_headers: preparation::edge::snapshot_facts(preparation, grant, &origin) + .into_iter() + .chain(std::iter::once(transport)) + .collect(), + ..MiddlewareAction::pass() + } +} impl Middleware for Encypher { fn middleware_id(&self) -> &'static str { MODULE } + fn finalizes_document(&self) -> bool { + true + } + fn handles_media_type(&self, media_type: &str) -> bool { media_type == HTML_MEDIA_TYPE } @@ -467,7 +668,10 @@ impl Middleware for Encypher { call: ModuleCall<'a>, context: &'a MiddlewareContext<'a>, ) -> Option> { - let admission = admit(&call, context, |page| self.signer.admit(page)); + let admission = match snapshot_grant(&self.signer, context) { + Some(grant) => Admission::Snapshot(grant), + None => admit(&call, context, |page| self.signer.admit(page)), + }; Some(Box::pin(async move { Ok(Box::new(admission) as Box) })) @@ -476,8 +680,12 @@ impl Middleware for Encypher { /// Holds the admitted page, with the security policies its response /// carries, which decide whether inline style media may be edited. fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { - let Some(page) = context.take_prepared::(MODULE).flatten() else { - return MiddlewareAction::pass(); + let page = match context.take_prepared::(MODULE) { + Some(Admission::Hold(page)) => page, + Some(Admission::Snapshot(grant)) => { + return snapshot_action(&self.signer, context, &grant); + } + Some(Admission::Pass) | None => return MiddlewareAction::pass(), }; let policies = context .response_headers @@ -508,6 +716,10 @@ impl Middleware for EncypherStylesheet { STYLESHEET_MIDDLEWARE } + fn finalizes_document(&self) -> bool { + true + } + fn handles_media_type(&self, media_type: &str) -> bool { media_type == STYLESHEET_MEDIA_TYPE } @@ -530,18 +742,22 @@ impl Middleware for EncypherStylesheet { call: ModuleCall<'a>, context: &'a MiddlewareContext<'a>, ) -> Option> { - let admission = admit(&call, context, |page| self.signer.admit_stylesheet(page)); + let admission = match snapshot_grant(&self.signer, context) { + Some(grant) => Admission::Snapshot(grant), + None => admit(&call, context, |page| self.signer.admit_stylesheet(page)), + }; Some(Box::pin(async move { Ok(Box::new(admission) as Box) })) } fn create(&self, context: &MiddlewareContext<'_>) -> MiddlewareAction { - let Some(page) = context - .take_prepared::(STYLESHEET_MIDDLEWARE) - .flatten() - else { - return MiddlewareAction::pass(); + let page = match context.take_prepared::(STYLESHEET_MIDDLEWARE) { + Some(Admission::Hold(page)) => page, + Some(Admission::Snapshot(grant)) => { + return snapshot_action(&self.signer, context, &grant); + } + Some(Admission::Pass) | None => return MiddlewareAction::pass(), }; MiddlewareAction { stream: Some(Box::new(stylesheet_processor(&self.signer, page))), @@ -556,7 +772,9 @@ fn admit( context: &MiddlewareContext<'_>, gate: impl FnOnce(PageRequest) -> Result, ) -> Admission { - let request = ModuleRequest::from_module_context(call).ok()?; + let Ok(request) = ModuleRequest::from_module_context(call) else { + return Admission::Pass; + }; let removed_prefix = request.removed_prefix().unwrap_or_default(); let page = PageRequest { method: request.method().clone(), @@ -568,15 +786,16 @@ fn admit( request.host(), request.path() ), + query: request.query().to_owned(), removed_prefix: removed_prefix.to_owned(), response_status: context.status.as_u16(), origin_response_is_private: context.origin_private, }; match gate(page) { - Ok(page) => Some(page), + Ok(page) => Admission::Hold(page), Err(gate) => { log::debug!("encypher: document not held, gate={}", gate.code()); - None + Admission::Pass } } } diff --git a/crates/provenance/encypher/src/media_api.rs b/crates/provenance/encypher/src/media_api.rs index 2d8d7de9aa..3f35977529 100644 --- a/crates/provenance/encypher/src/media_api.rs +++ b/crates/provenance/encypher/src/media_api.rs @@ -206,7 +206,8 @@ pub(crate) fn sha256_hex(bytes: &[u8]) -> String { hex_lower(&Sha256::digest(bytes)) } -fn hex_lower(bytes: &[u8]) -> String { +/// Lowercase hexadecimal encoding of `bytes`, without hashing them. +pub(crate) fn hex_lower(bytes: &[u8]) -> String { const DIGITS: &[u8; 16] = b"0123456789abcdef"; let mut out = String::with_capacity(bytes.len() * 2); for byte in bytes { diff --git a/crates/provenance/encypher/src/preparation/edge.rs b/crates/provenance/encypher/src/preparation/edge.rs new file mode 100644 index 0000000000..00cecfe78c --- /dev/null +++ b/crates/provenance/encypher/src/preparation/edge.rs @@ -0,0 +1,1397 @@ +//! The delivery instance's side of the preparation protocol: its internal +//! snapshot, result and marker routes, and the public source route. +//! +//! Every control route is authenticated with the publication scope's +//! secret, compared in constant time, and fixed to the one configured +//! deployment and its routes. None accepts a hostname, fetch headers, a +//! source credential or a signer. +//! +//! A snapshot is read through the same reader pipeline every reader takes: +//! the route asks the instance's own public address for the configured +//! target, carrying a short-lived capability for exactly that path. The +//! module's request preparer verifies and removes it before anything can +//! forward it, leaving an observation that changes nothing about how the +//! request is answered, so the same template, cache, origin and +//! transformations serve it. Only this module's own signing and lookup are +//! suppressed for it, and its answer reports what the origin's own headers +//! said, authenticated for the worker's nonce, apart from its private +//! transport headers. +//! +//! A result publication never trusts what the worker says it signed. The +//! route recomputes the identity from the item, recovers the exact +//! completed receipt from the authenticated API without signing, checks it +//! against the actual source bytes and, for embedded media, builds the +//! output itself and requires the worker's to be exactly it. Only then is +//! the record stored, under its immutable identity, and never over a +//! retirement marker. + +use std::cell::RefCell; +use std::time::Duration; + +use async_trait::async_trait; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::error::EdgeError; +use error_stack::Report; +use http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use serde_json::json; +use sha2::Digest as _; + +use trusted_server_core::error::TrustedServerError; +use trusted_server_core::integrations::{ + IntegrationEndpoint, IntegrationProxy, IntegrationRequestState, + ensure_integration_backend_with_timeout, +}; +use trusted_server_core::media_signing::SnapshotFacts; +use trusted_server_core::middleware::ResponseHeader; +use trusted_server_core::module_context::ModuleCall; +use trusted_server_core::platform::{KvError, PlatformHttpRequest, RuntimeServices}; +use trusted_server_core::settings::Settings; + +use super::pipeline::{ + self, ApiAnswer, ApiRequest, ApiTransport, Failure, FailureClass, ProbeArtifactCache, + SourceReader, +}; +use super::protocol::{ + CAPABILITY_HEADER, Carrier, EnvelopeItem, FACTS_HEADER, LargeLocator, MARKERS_PREFIX, + MarkerEnvelope, MediaIdentity, PREPARATION_PROTOCOL, PUBLIC_SOURCE_PREFIX, PublishedRecord, + PublishedResult, RESULTS_PREFIX, ResultEnvelope, RetirementMarker, SNAPSHOT_META_HEADER, + SNAPSHOT_PATH, SnapshotMeta, SnapshotMode, SnapshotOrigin, SnapshotRequest, TOKEN_HEADER, + Target, TextIdentity, WORKER_SOURCE_PREFIX, facts_header, is_digest, marker_key, result_key, + snapshot_capability, token_matches, verify_facts_header, verify_snapshot_capability, +}; +use super::{Execution, PreparationConfig}; +use crate::media_api::{canonical_media_type, hex_lower, sha256_hex}; +use crate::processor::Signer; +use crate::{EncypherConfig, INTEGRATION_ID, MODULE}; + +/// The largest result envelope: an embedded item's source and output, +/// base64, with its plan. +const MAX_ENVELOPE_BYTES: usize = 24 * 1024 * 1024; +/// The largest one range a snapshot reads. +const MAX_RANGE_BYTES: u64 = 8 * 1024 * 1024; +/// The aligned block a pinned source answers the probe's small structure +/// reads from. +const PINNED_BLOCK_BYTES: u64 = 64 * 1024; +/// The largest worker answer for a public source. +const MAX_PUBLIC_SOURCE_BYTES: usize = 8 * 1024 * 1024; +/// How long one loopback snapshot read may wait for its first byte. +const LOOPBACK_TIMEOUT: Duration = Duration::from_secs(30); +/// The cache key prefix of verified probe artifacts, the same the large +/// lane keeps them under. +const PROBE_KEY_PREFIX: &str = "encypher:probe:v1"; + +/// What the module's request preparer leaves on a verified snapshot read: +/// the nonce its facts are authenticated for. Left as an observation, so +/// the read takes every reader's path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SnapshotGrant { + pub(crate) nonce: String, +} + +fn unix_now() -> u64 { + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) +} + +/// The asynchronous mode's settings, where the deployment runs it. +fn async_config(settings: &Settings) -> Option<(EncypherConfig, PreparationConfig)> { + let config = settings + .module_config::(MODULE) + .ok() + .flatten()?; + if config.execution != Execution::AsyncPrepared { + return None; + } + let preparation = config.preparation.clone()?; + Some((config, preparation)) +} + +/// The module's request preparer: removes every snapshot capability from +/// every request, whether or not the module runs, so none ever reaches an +/// origin, and leaves a [`SnapshotGrant`] observation only for one that +/// verifies for exactly this path and query under the publication secret. +/// A client cannot set the grant: it exists only as request state this +/// preparer writes. +/// +/// # Errors +/// +/// Never; a capability that does not verify is dropped. +pub(crate) fn prepare_request( + settings: &Settings, + request: &mut Request, +) -> Result<(), Report> { + let value = { + let mut values = request.headers().get_all(CAPABILITY_HEADER).iter(); + match (values.next(), values.next()) { + (Some(value), None) => Some(value.clone()), + _ => None, + } + }; + request.headers_mut().remove(CAPABILITY_HEADER); + let Some(value) = value else { + return Ok(()); + }; + let Ok(value) = value.to_str() else { + return Ok(()); + }; + let Some((_, preparation)) = async_config(settings) else { + return Ok(()); + }; + let prefix = request + .extensions() + .get::() + .map(|prefix| prefix.as_str().to_owned()) + .unwrap_or_default(); + let path_and_query = match request.uri().query() { + Some(query) => format!("{prefix}{}?{query}", request.uri().path()), + None => format!("{prefix}{}", request.uri().path()), + }; + if let Some(nonce) = verify_snapshot_capability( + preparation.publication_secret.expose(), + &path_and_query, + value, + unix_now(), + ) { + IntegrationRequestState::observe(request, INTEGRATION_ID, SnapshotGrant { nonce }); + } + Ok(()) +} + +/// The facts header a snapshot answer carries, authenticated for its +/// grant's nonce. +pub(crate) fn snapshot_facts( + preparation: &PreparationConfig, + grant: &SnapshotGrant, + origin: &SnapshotOrigin, +) -> Option { + let value = facts_header( + preparation.publication_secret.expose(), + &grant.nonce, + origin, + ); + Some(( + http::HeaderName::from_static(FACTS_HEADER), + HeaderValue::from_str(&value).ok()?, + )) +} + +/// The origin facts core reports for a media snapshot, as the protocol +/// carries them. +pub(crate) fn media_snapshot_origin(facts: &SnapshotFacts) -> SnapshotOrigin { + SnapshotOrigin { + status: facts.status.as_u16(), + private: facts.private, + no_transform: facts.no_transform, + manifest_link: facts.manifest_link, + content_encoding: facts.content_encoding.clone(), + origin_host: facts.origin_host.clone(), + origin_identity_sha256: facts.origin_identity_sha256.clone(), + source_reference: facts.source_reference.clone(), + } +} + +/// The Encypher API, as the delivery instance's platform client reaches +/// it, for receipt recovery and probe artifacts only. +pub(crate) struct EdgeApi<'a> { + pub(crate) config: &'a EncypherConfig, + pub(crate) services: &'a RuntimeServices, +} + +impl ApiTransport for EdgeApi<'_> { + async fn exchange(&self, request: ApiRequest) -> Result { + let backend = ensure_integration_backend_with_timeout( + self.services, + &self.config.api_base_url, + INTEGRATION_ID, + Duration::from_millis(self.config.timeout_ms), + ) + .map_err(|_| Failure::new(FailureClass::Transient, "backend"))?; + let mut builder = Request::builder().method(request.method).uri(format!( + "{}{}", + self.config.api_base_url.trim_end_matches('/'), + request.path_and_query + )); + if request.credentialed { + let mut token = HeaderValue::from_str(self.config.worker_token.expose().trim()) + .map_err(|_| Failure::new(FailureClass::Transient, "request_build"))?; + token.set_sensitive(true); + builder = builder.header("x-encypher-worker-token", token); + } + if let Some(key) = &request.idempotency_key { + builder = builder.header("idempotency-key", key.as_str()); + } + let body = match request.body { + Some(body) => { + builder = builder.header(header::CONTENT_TYPE, "application/json"); + EdgeBody::from(body) + } + None => EdgeBody::empty(), + }; + let http_request = builder + .body(body) + .map_err(|_| Failure::new(FailureClass::Transient, "request_build"))?; + let limit = request.max_response_bytes.saturating_add(1); + let answer = self + .services + .http_client() + .send( + PlatformHttpRequest::new(http_request, backend) + .with_cache_bypass() + .with_max_response_body_bytes(limit), + ) + .await + .map_err(|_| Failure::new(FailureClass::Transient, "http_error"))?; + let status = answer.response.status().as_u16(); + let body = answer + .response + .into_body() + .into_bytes_bounded(limit) + .await + .map_err(|_| Failure::new(FailureClass::Transient, "response_too_large"))?; + Ok(ApiAnswer { status, body }) + } +} + +/// Verified probe artifacts kept in the runtime KV store. +pub(crate) struct KvProbeCache<'a>(pub(crate) &'a RuntimeServices); + +impl ProbeArtifactCache for KvProbeCache<'_> { + async fn get(&self, sha256: &str) -> Option { + self.0 + .kv_store() + .get_bytes(&format!("{PROBE_KEY_PREFIX}:{sha256}")) + .await + .ok() + .flatten() + } + + async fn put(&self, sha256: &str, artifact: Bytes) -> Result<(), Failure> { + // The edge keeps artifacts only to avoid fetching them again; the + // load goes on from the verified bytes in hand when the write fails. + if self + .0 + .kv_store() + .put_bytes(&format!("{PROBE_KEY_PREFIX}:{sha256}"), artifact) + .await + .is_err() + { + log::debug!("encypher: probe release cache write failed"); + } + Ok(()) + } +} + +/// The module's preparation routes on the delivery instance. +pub(crate) struct PreparationRoutes { + signer: std::sync::Arc, +} + +impl PreparationRoutes { + /// The routes of `signer`, holding releases to the production key. + pub(crate) fn new(signer: std::sync::Arc) -> Self { + Self { signer } + } +} + +impl std::fmt::Debug for PreparationRoutes { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PreparationRoutes") + .finish_non_exhaustive() + } +} + +fn json_response(status: StatusCode, value: &serde_json::Value) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/json") + .header(header::CACHE_CONTROL, "private, no-store") + .body(EdgeBody::from(value.to_string())) + .expect("should build a JSON response") +} + +fn refusal(status: StatusCode, code: &str) -> Response { + json_response(status, &json!({"error": {"code": code}})) +} + +fn failure_response(failure: &Failure) -> Response { + let status = match failure.class { + FailureClass::Transient => StatusCode::SERVICE_UNAVAILABLE, + FailureClass::Missing => StatusCode::GONE, + FailureClass::Privacy => StatusCode::FORBIDDEN, + FailureClass::PolicyChanged => StatusCode::CONFLICT, + FailureClass::Integrity | FailureClass::Unsupported | FailureClass::ExistingProvenance => { + StatusCode::UNPROCESSABLE_ENTITY + } + }; + log::info!( + "encypher: preparation publication refused code={}", + failure.code + ); + refusal(status, &failure.code) +} + +async fn read_body(request: Request, limit: usize) -> Option { + request + .into_body() + .into_bytes_bounded(limit.saturating_add(1)) + .await + .ok() + .filter(|body| body.len() <= limit) +} + +impl PreparationRoutes { + fn config(&self) -> Option<(&EncypherConfig, &PreparationConfig)> { + let config = self.signer.config(); + Some((config, self.signer.preparation()?)) + } + + async fn route( + &self, + request: Request, + services: &RuntimeServices, + ) -> Result, Report> { + let Some((config, preparation)) = self.config() else { + return Ok(refusal(StatusCode::NOT_FOUND, "not_found")); + }; + let path = request.uri().path().to_owned(); + if request.method() == Method::GET { + if let Some(sha256) = path.strip_prefix(PUBLIC_SOURCE_PREFIX) { + return Ok(self.public_source(services, preparation, sha256).await); + } + return Ok(refusal(StatusCode::NOT_FOUND, "not_found")); + } + let presented = request + .headers() + .get(TOKEN_HEADER) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if !token_matches(preparation.publication_secret.expose(), presented) { + return Ok(refusal(StatusCode::UNAUTHORIZED, "unauthorized")); + } + if request.method() == Method::POST && path == SNAPSHOT_PATH { + return Ok(self.snapshot(services, config, preparation, request).await); + } + if request.method() == Method::PUT { + if let Some(identity) = path.strip_prefix(RESULTS_PREFIX).map(str::to_owned) { + return Ok(self + .publish(services, config, preparation, &identity, request) + .await); + } + if let Some(identity) = path.strip_prefix(MARKERS_PREFIX).map(str::to_owned) { + return Ok(self.retire(services, preparation, &identity, request).await); + } + } + Ok(refusal(StatusCode::NOT_FOUND, "not_found")) + } + + /// Reads one configured target through the reader pipeline. + async fn snapshot( + &self, + services: &RuntimeServices, + config: &EncypherConfig, + preparation: &PreparationConfig, + request: Request, + ) -> Response { + let Some(body) = read_body(request, 4096).await else { + return refusal(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large"); + }; + let Ok(asked) = serde_json::from_slice::(&body) else { + return refusal(StatusCode::BAD_REQUEST, "malformed_request"); + }; + if asked.protocol != PREPARATION_PROTOCOL + || asked.deployment_id != preparation.deployment_id + { + return refusal(StatusCode::FORBIDDEN, "wrong_deployment"); + } + if !asked.target.is_well_formed() + || !preparation.admits_path(&asked.target.path) + || preparation.variants.admit(&asked.target.variant).as_deref() + != Some(asked.target.variant.as_str()) + { + return refusal(StatusCode::FORBIDDEN, "target_not_configured"); + } + if asked.nonce.len() != 32 || !asked.nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return refusal(StatusCode::BAD_REQUEST, "nonce_invalid"); + } + let (limit, range) = match &asked.read { + SnapshotMode::Whole => (config.max_document_bytes.max(config.max_media_bytes), None), + SnapshotMode::Head => (0, None), + SnapshotMode::Range { + first, + last, + if_match, + } => { + let Some(length) = last + .checked_sub(*first) + .and_then(|span| span.checked_add(1)) + else { + return refusal(StatusCode::BAD_REQUEST, "range_invalid"); + }; + if length > MAX_RANGE_BYTES { + return refusal(StatusCode::BAD_REQUEST, "range_too_large"); + } + ( + usize::try_from(length).unwrap_or(usize::MAX), + Some((*first, *last, if_match.clone())), + ) + } + }; + let path_and_query = asked.target.path_and_query(); + let capability = snapshot_capability( + preparation.publication_secret.expose(), + &path_and_query, + &asked.nonce, + unix_now() + super::protocol::CAPABILITY_LIFETIME_SECONDS, + ); + let answer = match loopback_read( + services, + preparation, + &path_and_query, + &capability, + range.as_ref(), + limit, + ) + .await + { + Ok(answer) => answer, + Err(code) => return refusal(StatusCode::BAD_GATEWAY, code), + }; + let (parts, body) = answer.into_parts(); + let Some(origin) = parts + .headers + .get(FACTS_HEADER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + verify_facts_header(preparation.publication_secret.expose(), &asked.nonce, value) + }) + else { + // The module did not observe the read: the target is not one a + // [[serve]] entry names it for, or the answer is not this read's. + return refusal(StatusCode::UNPROCESSABLE_ENTITY, "snapshot_unobserved"); + }; + if origin.private { + return refusal(StatusCode::FORBIDDEN, "source_private"); + } + if origin.manifest_link { + return refusal( + StatusCode::UNPROCESSABLE_ENTITY, + "source_has_provenance_link", + ); + } + if origin.no_transform { + return refusal(StatusCode::UNPROCESSABLE_ENTITY, "no_transform"); + } + if !origin.content_encoding.is_empty() { + return refusal(StatusCode::UNPROCESSABLE_ENTITY, "content_encoded"); + } + let expected_status = if range.is_some() { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }; + if parts.status != expected_status && !(range.is_some() && parts.status == StatusCode::OK) { + return refusal(StatusCode::UNPROCESSABLE_ENTITY, "source_status"); + } + let etag = single_strong_etag(&parts.headers); + if let Some((_, _, if_match)) = &range + && etag.as_deref() != Some(if_match.as_str()) + { + return refusal(StatusCode::PRECONDITION_FAILED, "source_version_changed"); + } + let length = if let Some((first, last, _)) = &range { + let Some(total) = pinned_range_length(parts.status, &parts.headers, *first, *last) + else { + return refusal(StatusCode::UNPROCESSABLE_ENTITY, "source_range_invalid"); + }; + Some(total) + } else { + parts + .headers + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse().ok()) + }; + let meta = SnapshotMeta { + protocol: PREPARATION_PROTOCOL.to_owned(), + page_url: format!("https://{}{}", self.signer.public_host(), asked.target.path), + content_type: parts + .headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(), + etag, + length, + origin, + }; + // The read's headers arrive before its body, so nothing above has + // read a byte of it. A head snapshot drops the body unread; a whole + // one declared past the bound is refused unread; every other body is + // read only up to the bound. A failed read stays a failed read and is + // never reported as an oversized source. + let bytes = if limit == 0 { + Bytes::new() + } else if range.is_none() + && length.is_some_and(|length| length > u64::try_from(limit).unwrap_or(u64::MAX)) + { + return refusal(StatusCode::PAYLOAD_TOO_LARGE, "snapshot_too_large"); + } else { + match body.into_bytes_bounded(limit).await { + Ok(bytes) if bytes.len() <= limit => bytes, + Ok(_) | Err(EdgeError::BadRequest { .. }) => { + return refusal(StatusCode::PAYLOAD_TOO_LARGE, "snapshot_too_large"); + } + Err(_) => return refusal(StatusCode::BAD_GATEWAY, "loopback_failed"), + } + }; + if let Some((first, last, _)) = &range + && u64::try_from(bytes.len()).ok() != Some(last - first + 1) + { + return refusal(StatusCode::BAD_GATEWAY, "range_short"); + } + let encoded = STANDARD.encode(serde_json::to_vec(&meta).unwrap_or_default()); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CACHE_CONTROL, "private, no-store") + .header(SNAPSHOT_META_HEADER, encoded) + .body(EdgeBody::Once(bytes)) + .expect("should build a snapshot response") + } + + /// Verifies one result through the authenticated API and stores it + /// under its immutable identity. + async fn publish( + &self, + services: &RuntimeServices, + config: &EncypherConfig, + preparation: &PreparationConfig, + identity: &str, + request: Request, + ) -> Response { + if !is_digest(identity) { + return refusal(StatusCode::BAD_REQUEST, "identity_invalid"); + } + let Some(body) = read_body(request, MAX_ENVELOPE_BYTES).await else { + return refusal(StatusCode::PAYLOAD_TOO_LARGE, "result_too_large"); + }; + let Ok(envelope) = serde_json::from_slice::(&body) else { + return refusal(StatusCode::BAD_REQUEST, "malformed_request"); + }; + if envelope.protocol != PREPARATION_PROTOCOL + || envelope.deployment_id != preparation.deployment_id + || envelope.identity != identity + { + return refusal(StatusCode::FORBIDDEN, "wrong_deployment"); + } + if preparation.variants.admit(&envelope.variant).as_deref() + != Some(envelope.variant.as_str()) + { + return refusal(StatusCode::FORBIDDEN, "variant_not_admitted"); + } + let kv = services.kv_store(); + match kv.get_bytes(&marker_key(identity)).await { + Ok(None) => {} + Ok(Some(_)) => return refusal(StatusCode::CONFLICT, "retired"), + Err(_) => return refusal(StatusCode::SERVICE_UNAVAILABLE, "marker_unavailable"), + } + let result = match self + .verify_envelope(services, config, preparation, &envelope) + .await + { + Ok(result) => result, + Err(failure) => return failure_response(&failure), + }; + // A marker written while the receipt was being checked still wins. + // Readers refuse a retired identity whatever its result says, so + // this only keeps a known-retired result from being written at all. + match kv.get_bytes(&marker_key(identity)).await { + Ok(None) => {} + Ok(Some(_)) => return refusal(StatusCode::CONFLICT, "retired"), + Err(_) => return refusal(StatusCode::SERVICE_UNAVAILABLE, "marker_unavailable"), + } + let record = PublishedRecord { + protocol: PREPARATION_PROTOCOL.to_owned(), + identity: identity.to_owned(), + retirement_epoch: envelope.retirement_epoch, + result, + }; + let Ok(encoded) = serde_json::to_vec(&record) else { + return refusal(StatusCode::INTERNAL_SERVER_ERROR, "encode"); + }; + match kv + .put_bytes(&result_key(identity), Bytes::from(encoded)) + .await + { + Ok(()) => { + log::info!("encypher: preparation result published"); + json_response( + StatusCode::OK, + &json!({"protocol": PREPARATION_PROTOCOL, "published": identity}), + ) + } + Err(KvError::Unavailable) => refusal(StatusCode::SERVICE_UNAVAILABLE, "kv_unavailable"), + Err(_) => refusal(StatusCode::SERVICE_UNAVAILABLE, "kv_error"), + } + } + + /// The record an envelope supports, recomputing its identity and + /// checking the API's own receipt. + async fn verify_envelope( + &self, + services: &RuntimeServices, + config: &EncypherConfig, + preparation: &PreparationConfig, + envelope: &ResultEnvelope, + ) -> Result { + let api = EdgeApi { config, services }; + let context = self.signer.credential_context(); + let refused = |code: &str| Failure::new(FailureClass::Integrity, code); + match &envelope.item { + EnvelopeItem::Text { + request, + idempotency_key, + } => { + self.require_page_url(preparation, &request.page_url)?; + let identity = TextIdentity { + credential_context: context, + policy_digest: &request.expected_policy_digest, + page_url: &request.page_url, + variant: &envelope.variant, + text: &request.text, + document_title: request.document_title.as_deref(), + boundary_selector: &request.boundary_selector, + } + .digest(); + if identity != envelope.identity { + return Err(refused("identity_mismatch")); + } + let signed = pipeline::recover_text(&api, request, idempotency_key).await?; + Ok(PublishedResult::Text { + plan: signed.plan, + manifest_url: signed.manifest_url, + scalars: request.text.chars().count(), + }) + } + EnvelopeItem::Media { + planned, + source_b64, + } => { + let request = &planned.request; + self.require_page_url(preparation, &request.page_url)?; + if request.carrier() != Carrier::External { + return Err(refused("carrier_mismatch")); + } + let identity = MediaIdentity { + credential_context: context, + policy_digest: &request.expected_policy_digest, + page_url: &request.page_url, + variant: &envelope.variant, + carrier: Carrier::External, + mime: &canonical_media_type(&request.source.mime), + source_sha256: &request.source.sha256, + } + .digest(); + if identity != envelope.identity { + return Err(refused("identity_mismatch")); + } + let source = STANDARD + .decode(source_b64) + .map_err(|_| refused("source_invalid"))?; + if sha256_hex(&source) != request.source.sha256 + || u64::try_from(source.len()).ok() != Some(request.source.byte_length) + { + return Err(refused("source_digest_mismatch")); + } + let recovered = + pipeline::recover_media(&api, request, &planned.idempotency_key).await?; + let probe = + pipeline::load_release(&api, &KvProbeCache(services), &planned.release).await?; + // Provenance the source carries is kept, whatever the + // worker's disclosure left out. + pipeline::inspect_external_source(&probe, request, source.as_slice()).await?; + pipeline::verify_external_receipt(&probe, planned, &recovered).await + } + EnvelopeItem::Embedded { + planned, + source_b64, + output_b64, + } => { + let request = &planned.request; + self.require_page_url(preparation, &request.page_url)?; + if request.carrier() != Carrier::Embedded { + return Err(refused("carrier_mismatch")); + } + let identity = MediaIdentity { + credential_context: context, + policy_digest: &request.expected_policy_digest, + page_url: &request.page_url, + variant: &envelope.variant, + carrier: Carrier::Embedded, + mime: &request.source.mime, + source_sha256: &request.source.sha256, + } + .digest(); + if identity != envelope.identity { + return Err(refused("identity_mismatch")); + } + let source = STANDARD + .decode(source_b64) + .map_err(|_| refused("source_invalid"))?; + let output = STANDARD + .decode(output_b64) + .map_err(|_| refused("output_invalid"))?; + if source.len() > config.max_media_bytes + || output.len() > config.max_media_bytes.saturating_mul(2) + { + return Err(refused("output_too_large")); + } + // The bytes the worker sent are the source only where they + // are exactly the ones the identity binds; checked before the + // API is asked anything. + if sha256_hex(&source) != request.source.sha256 + || u64::try_from(source.len()).ok() != Some(request.source.byte_length) + { + return Err(refused("source_digest_mismatch")); + } + let recovered = + pipeline::recover_media(&api, request, &planned.idempotency_key).await?; + let probe = + pipeline::load_release(&api, &KvProbeCache(services), &planned.release).await?; + pipeline::verify_embedded_receipt(&probe, planned, &recovered, &source, &output) + .await + } + EnvelopeItem::Large { + planned, + etag, + origin_identity_sha256, + } => { + let request = &planned.request; + self.require_page_url(preparation, &request.page_url)?; + // The worker's digest is only what it says it read; every + // pinned read below must report the same configured source + // in its own authenticated facts. + if !is_digest(origin_identity_sha256) { + return Err(refused("origin_identity_invalid")); + } + let identity = LargeLocator { + credential_context: context, + policy_digest: &request.expected_policy_digest, + reader_url: &request.page_url, + variant: &envelope.variant, + origin_host: &request.source.origin_host, + origin_identity_sha256, + mime: &request.source.mime, + etag, + length: request.source.byte_length, + } + .digest(); + if identity != envelope.identity { + return Err(refused("identity_mismatch")); + } + let path = url::Url::parse(&request.page_url) + .map(|url| url.path().to_owned()) + .map_err(|_| refused("page_url_invalid"))?; + let target = Target { + path, + variant: envelope.variant.clone(), + }; + if !target.is_well_formed() + || !preparation.admits_path(&target.path) + || preparation.variants.admit(&target.variant).as_deref() + != Some(target.variant.as_str()) + { + return Err(refused("target_invalid")); + } + if request.source.byte_length > config.max_large_media_bytes { + return Err(Failure::new(FailureClass::Unsupported, "media_too_large")); + } + let path_and_query = target.path_and_query(); + let version = PinnedVersion::new( + services, + preparation, + request, + &path_and_query, + etag, + origin_identity_sha256, + )?; + // Provenance the pinned version carries is kept, whatever the + // worker's disclosure left out. It is inspected first, through + // the same pinned reads, so such a source costs no full read. + let probe = + pipeline::load_release(&api, &KvProbeCache(services), &planned.release).await?; + pipeline::inspect_external_source(&probe, request, &PinnedSource::new(&version)) + .await?; + // The worker's digest is never taken on trust: every byte of + // the version this tag names is read again here, in pinned + // ranges through the reader pipeline, and hashed. A changed + // version or a different digest refuses the result. This is + // the control path; readers never hash. + let digest = version.digest().await?; + if digest != request.source.sha256 || planned.digest != request.source.sha256 { + return Err(refused("source_digest_mismatch")); + } + let recovered = + pipeline::recover_media(&api, request, &planned.idempotency_key).await?; + match pipeline::verify_external_receipt(&probe, planned, &recovered).await? { + PublishedResult::External { + source_sha256, + headers, + } => Ok(PublishedResult::Large { + sha256: source_sha256, + length: request.source.byte_length, + headers, + }), + _ => Err(refused("receipt_invalid")), + } + } + } + } + + /// Refuses a page address outside this deployment's public host and + /// configured routes. + fn require_page_url( + &self, + preparation: &PreparationConfig, + page_url: &str, + ) -> Result<(), Failure> { + let refused = || Failure::new(FailureClass::Integrity, "page_url_outside_deployment"); + let parsed = url::Url::parse(page_url).map_err(|_| refused())?; + let host = parsed.host_str().ok_or_else(refused)?; + if parsed.scheme() != "https" + || !self.signer.claims_host(host) + || parsed.query().is_some() + || parsed.fragment().is_some() + || !preparation.admits_path(parsed.path()) + { + return Err(refused()); + } + Ok(()) + } + + /// Writes an identity's retirement marker and drops its result. The + /// marker is a key of its own, which no result publication touches. + async fn retire( + &self, + services: &RuntimeServices, + preparation: &PreparationConfig, + identity: &str, + request: Request, + ) -> Response { + if !is_digest(identity) { + return refusal(StatusCode::BAD_REQUEST, "identity_invalid"); + } + let Some(body) = read_body(request, 4096).await else { + return refusal(StatusCode::PAYLOAD_TOO_LARGE, "request_too_large"); + }; + let Ok(envelope) = serde_json::from_slice::(&body) else { + return refusal(StatusCode::BAD_REQUEST, "malformed_request"); + }; + if envelope.protocol != PREPARATION_PROTOCOL + || envelope.deployment_id != preparation.deployment_id + || envelope.identity != identity + { + return refusal(StatusCode::FORBIDDEN, "wrong_deployment"); + } + let kv = services.kv_store(); + let existing = match kv.get_bytes(&marker_key(identity)).await { + Ok(existing) => existing + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .map_or(0, |marker| marker.epoch), + Err(_) => return refusal(StatusCode::SERVICE_UNAVAILABLE, "marker_unavailable"), + }; + let marker = RetirementMarker { + epoch: existing.max(envelope.epoch), + }; + let Ok(encoded) = serde_json::to_vec(&marker) else { + return refusal(StatusCode::INTERNAL_SERVER_ERROR, "encode"); + }; + if kv + .put_bytes(&marker_key(identity), Bytes::from(encoded)) + .await + .is_err() + { + return refusal(StatusCode::SERVICE_UNAVAILABLE, "marker_write_failed"); + } + if kv.delete(&result_key(identity)).await.is_err() { + log::debug!("encypher: retired result could not be deleted; the marker refuses it"); + } + json_response( + StatusCode::OK, + &json!({"protocol": PREPARATION_PROTOCOL, "acknowledged": identity, "epoch": marker.epoch}), + ) + } + + /// Serves one explicitly public source snapshot by its digest, from the + /// worker, which holds it only after page and asset admission. Exact + /// stored bytes, never rendered or sniffed by a browser. + async fn public_source( + &self, + services: &RuntimeServices, + preparation: &PreparationConfig, + sha256: &str, + ) -> Response { + if !is_digest(sha256) { + return refusal(StatusCode::NOT_FOUND, "not_found"); + } + let Ok(backend) = ensure_integration_backend_with_timeout( + services, + &preparation.worker_url, + INTEGRATION_ID, + LOOPBACK_TIMEOUT, + ) else { + return refusal(StatusCode::SERVICE_UNAVAILABLE, "worker_unavailable"); + }; + let Ok(mut token) = HeaderValue::from_str(preparation.publication_secret.expose().trim()) + else { + return refusal(StatusCode::SERVICE_UNAVAILABLE, "worker_unavailable"); + }; + token.set_sensitive(true); + let Ok(request) = Request::builder() + .method(Method::GET) + .uri(format!( + "{}{WORKER_SOURCE_PREFIX}{sha256}", + preparation.worker_url.trim_end_matches('/') + )) + .header(TOKEN_HEADER, token) + .body(EdgeBody::empty()) + else { + return refusal(StatusCode::SERVICE_UNAVAILABLE, "worker_unavailable"); + }; + let limit = MAX_PUBLIC_SOURCE_BYTES.saturating_add(1); + let Ok(answer) = services + .http_client() + .send( + PlatformHttpRequest::new(request, backend) + .with_cache_bypass() + .with_max_response_body_bytes(limit), + ) + .await + else { + return refusal(StatusCode::SERVICE_UNAVAILABLE, "worker_unavailable"); + }; + if answer.response.status() != StatusCode::OK { + return refusal(StatusCode::NOT_FOUND, "not_found"); + } + let content_type = answer + .response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(canonical_media_type) + .filter(|media_type| crate::data_uri::is_media_type(media_type)); + let Some(content_type) = content_type else { + return refusal(StatusCode::NOT_FOUND, "not_found"); + }; + let Ok(bytes) = answer.response.into_body().into_bytes_bounded(limit).await else { + return refusal(StatusCode::NOT_FOUND, "not_found"); + }; + // The worker answers only an admitted digest; the bytes must still + // be exactly the ones the address names. + if bytes.len() > MAX_PUBLIC_SOURCE_BYTES || sha256_hex(&bytes) != sha256 { + return refusal(StatusCode::NOT_FOUND, "not_found"); + } + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header("x-content-type-options", "nosniff") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{sha256}\""), + ) + .header( + "content-security-policy", + "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'", + ) + .header(header::CACHE_CONTROL, "public, max-age=300") + .body(EdgeBody::Once(bytes)) + .expect("should build a public source response") + } +} + +/// One loopback read of `path_and_query` through the instance's own +/// public address, carrying the capability and, for a range, `If-Match`. +/// +/// A client that streams answers with the head before any body byte, so +/// the caller judges the authenticated facts first and reads at most +/// `limit` bytes; no transport bound applies, which would refuse a large +/// declared length before its head arrived. A buffered client holds at most +/// `limit` bytes: a longer answer fails the read rather than being held. +async fn loopback_read( + services: &RuntimeServices, + preparation: &PreparationConfig, + path_and_query: &str, + capability: &str, + range: Option<&(u64, u64, String)>, + limit: usize, +) -> Result, &'static str> { + let backend = ensure_integration_backend_with_timeout( + services, + &preparation.loopback_url, + INTEGRATION_ID, + LOOPBACK_TIMEOUT, + ) + .map_err(|_| "loopback_backend")?; + let mut builder = Request::builder() + .method(Method::GET) + .uri(format!( + "{}{path_and_query}", + preparation.loopback_url.trim_end_matches('/') + )) + .header(header::ACCEPT_ENCODING, "identity") + .header(CAPABILITY_HEADER, capability); + if let Some((first, last, if_match)) = range { + builder = builder + .header(header::RANGE, format!("bytes={first}-{last}")) + .header(header::IF_MATCH, if_match.as_str()); + } + let request = builder + .body(EdgeBody::empty()) + .map_err(|_| "request_build")?; + let client = services.http_client(); + let read = PlatformHttpRequest::new(request, backend).with_cache_bypass(); + let read = if client.supports_streaming_responses() { + read.with_stream_response() + } else { + read.with_max_response_body_bytes(limit) + }; + let answer = client.send(read).await.map_err(|_| "loopback_failed")?; + Ok(answer.response) +} + +/// Refuses a pinned read whose origin facts do not admit it as the source +/// `request` names: one reader's answer, an encoded body, provenance the +/// origin already carries, a body the origin forbade transforming, another +/// origin host, another configured source than `origin_identity_sha256` +/// or another media type. A read the module did not observe reports no +/// facts and is refused as well. +fn admit_pinned_read( + facts: Option, + content_type: Option<&str>, + request: &pipeline::PrepareRequest, + origin_identity_sha256: &str, +) -> Result<(), Failure> { + let Some(origin) = facts else { + return Err(Failure::new(FailureClass::Integrity, "snapshot_unobserved")); + }; + if origin.private { + return Err(Failure::new(FailureClass::Privacy, "source_private")); + } + if origin.manifest_link { + return Err(Failure::new( + FailureClass::ExistingProvenance, + "source_has_provenance_link", + )); + } + if origin.no_transform { + return Err(Failure::new(FailureClass::Unsupported, "no_transform")); + } + if !origin.content_encoding.is_empty() { + return Err(Failure::new(FailureClass::Unsupported, "content_encoded")); + } + if origin.origin_host != request.source.origin_host { + return Err(Failure::new( + FailureClass::Integrity, + "source_origin_mismatch", + )); + } + // The same host can serve another configured source, such as a remapped + // folder, under equal per-resource validators. + if !is_digest(&origin.origin_identity_sha256) + || origin.origin_identity_sha256 != origin_identity_sha256 + { + return Err(Failure::new( + FailureClass::Integrity, + "source_identity_mismatch", + )); + } + if content_type.map(canonical_media_type).as_deref() != Some(request.source.mime.as_str()) { + return Err(Failure::new( + FailureClass::Integrity, + "source_type_mismatch", + )); + } + Ok(()) +} + +/// The version `etag` names at `path`, read in pinned ranges through the +/// reader pipeline. Every answer must be exactly its range of exactly that +/// version and length, observed by the module as a snapshot read, and +/// admitted by its origin facts as `request`'s source from the configured +/// source `origin_identity_sha256` names. +struct PinnedVersion<'a> { + services: &'a RuntimeServices, + preparation: &'a PreparationConfig, + request: &'a pipeline::PrepareRequest, + path: &'a str, + etag: &'a str, + origin_identity_sha256: &'a str, +} + +impl<'a> PinnedVersion<'a> { + /// The version of `request`'s source, refused where it names no bytes. + fn new( + services: &'a RuntimeServices, + preparation: &'a PreparationConfig, + request: &'a pipeline::PrepareRequest, + path: &'a str, + etag: &'a str, + origin_identity_sha256: &'a str, + ) -> Result { + if request.source.byte_length == 0 { + return Err(Failure::new(FailureClass::Integrity, "source_empty")); + } + Ok(Self { + services, + preparation, + request, + path, + etag, + origin_identity_sha256, + }) + } + + fn length(&self) -> u64 { + self.request.source.byte_length + } + + /// The bytes `first..=last`, at most [`MAX_RANGE_BYTES`] of them. + async fn range(&self, first: u64, last: u64) -> Result { + let length = self.length(); + if first > last || last >= length || last - first >= MAX_RANGE_BYTES { + return Err(Failure::new(FailureClass::Integrity, "read_out_of_bounds")); + } + let (path, etag) = (self.path, self.etag); + let secret = self.preparation.publication_secret.expose(); + let nonce = + sha256_hex(format!("{path}\n{etag}\n{first}\n{last}\n{}", unix_now()).as_bytes()); + let nonce = &nonce[..32]; + let capability = snapshot_capability( + secret, + path, + nonce, + unix_now() + super::protocol::CAPABILITY_LIFETIME_SECONDS, + ); + let range = (first, last, etag.to_owned()); + let expected = usize::try_from(last - first + 1).unwrap_or(usize::MAX); + let answer = loopback_read( + self.services, + self.preparation, + path, + &capability, + Some(&range), + expected, + ) + .await + .map_err(|code| Failure::new(FailureClass::Transient, code))?; + let facts = answer + .headers() + .get(FACTS_HEADER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| verify_facts_header(secret, nonce, value)); + admit_pinned_read( + facts, + answer + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + self.request, + self.origin_identity_sha256, + )?; + if single_strong_etag(answer.headers()).as_deref() != Some(etag) { + return Err(Failure::new( + FailureClass::Integrity, + "source_version_changed", + )); + } + if pinned_range_length(answer.status(), answer.headers(), first, last) != Some(length) { + return Err(Failure::new( + FailureClass::Integrity, + "source_range_invalid", + )); + } + // Read only once the facts, version and range admit the answer, and + // never past the range: a longer body is refused as soon as it is, a + // failed read stays a transient failure. + let bytes = match answer.into_body().into_bytes_bounded(expected).await { + Ok(bytes) => bytes, + Err(EdgeError::BadRequest { .. }) => { + return Err(Failure::new( + FailureClass::Integrity, + "source_range_invalid", + )); + } + Err(_) => return Err(Failure::new(FailureClass::Transient, "loopback_failed")), + }; + if bytes.len() != expected { + return Err(Failure::new( + FailureClass::Integrity, + "source_range_invalid", + )); + } + Ok(bytes) + } + + /// SHA-256 of every byte of the version, read in ranges of at most + /// [`MAX_RANGE_BYTES`]. + async fn digest(&self) -> Result { + let length = self.length(); + let mut hasher = sha2::Sha256::new(); + let mut first = 0_u64; + while first < length { + let last = (first + MAX_RANGE_BYTES).min(length) - 1; + hasher.update(&self.range(first, last).await?); + first = last + 1; + } + Ok(hex_lower(&hasher.finalize())) + } +} + +/// A [`PinnedVersion`] as the probe reads it, at offsets. A read inside one +/// aligned block of [`PINNED_BLOCK_BYTES`] is answered from that block, +/// read once and kept until a read needs another, so a structure walk's +/// many small reads cost one range read per block rather than one each. +/// Any other read is read exactly, in ranges of at most [`MAX_RANGE_BYTES`]. +/// The probe's own read limits bound what it may ask for in all. +struct PinnedSource<'a> { + version: &'a PinnedVersion<'a>, + block: RefCell>, +} + +impl<'a> PinnedSource<'a> { + fn new(version: &'a PinnedVersion<'a>) -> Self { + Self { + version, + block: RefCell::new(None), + } + } + + /// The aligned block starting at `start`, read once. + async fn block(&self, start: u64) -> Result { + let kept = self + .block + .borrow() + .as_ref() + .filter(|(kept_start, _)| *kept_start == start) + .map(|(_, bytes)| bytes.clone()); + if let Some(bytes) = kept { + return Ok(bytes); + } + let last = (start + PINNED_BLOCK_BYTES).min(self.version.length()) - 1; + let bytes = self.version.range(start, last).await?; + *self.block.borrow_mut() = Some((start, bytes.clone())); + Ok(bytes) + } +} + +impl SourceReader for PinnedSource<'_> { + fn size(&self) -> u64 { + self.version.length() + } + + async fn read_exact_at(&self, offset: u64, destination: &mut [u8]) -> Result<(), Failure> { + let out_of_bounds = || Failure::new(FailureClass::Integrity, "read_out_of_bounds"); + let wanted = u64::try_from(destination.len()).map_err(|_| out_of_bounds())?; + let end = offset + .checked_add(wanted) + .filter(|end| *end <= self.size()) + .ok_or_else(out_of_bounds)?; + if wanted == 0 { + return Ok(()); + } + let start = offset - offset % PINNED_BLOCK_BYTES; + if end <= start + PINNED_BLOCK_BYTES { + let block = self.block(start).await?; + let at = usize::try_from(offset - start).map_err(|_| out_of_bounds())?; + destination.copy_from_slice(&block[at..at + destination.len()]); + return Ok(()); + } + let mut first = offset; + let part_length = usize::try_from(MAX_RANGE_BYTES).unwrap_or(usize::MAX); + for part in destination.chunks_mut(part_length) { + let last = first + u64::try_from(part.len()).map_err(|_| out_of_bounds())? - 1; + part.copy_from_slice(&self.version.range(first, last).await?); + first = last + 1; + } + Ok(()) + } +} + +/// The single strong entity tag of an answer, where it has exactly one. +fn single_strong_etag(headers: &http::HeaderMap) -> Option { + let mut tags = headers.get_all(header::ETAG).iter(); + let (Some(tag), None) = (tags.next(), tags.next()) else { + return None; + }; + let tag = tag.to_str().ok()?.trim(); + (tag.starts_with('"') && tag.ends_with('"') && tag.len() >= 3).then(|| tag.to_owned()) +} + +/// Accepts one exact partial range, or an origin's exact whole-body fallback. +fn pinned_range_length( + status: StatusCode, + headers: &http::HeaderMap, + first: u64, + last: u64, +) -> Option { + let decimal = |value: &str| -> Option { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + value.parse().ok() + }; + if status == StatusCode::PARTIAL_CONTENT { + let mut values = headers.get_all(header::CONTENT_RANGE).iter(); + let (Some(value), None) = (values.next(), values.next()) else { + return None; + }; + let value = value.to_str().ok()?.trim().strip_prefix("bytes ")?; + let (span, total) = value.split_once('/')?; + let (start, end) = span.split_once('-')?; + let total = decimal(total)?; + (decimal(start)? == first && decimal(end)? == last && first <= last && last < total) + .then_some(total) + } else if status == StatusCode::OK && first == 0 && !headers.contains_key(header::CONTENT_RANGE) + { + let mut values = headers.get_all(header::CONTENT_LENGTH).iter(); + let (Some(value), None) = (values.next(), values.next()) else { + return None; + }; + let total = decimal(value.to_str().ok()?.trim())?; + (last.checked_add(1) == Some(total)).then_some(total) + } else { + None + } +} + +#[async_trait(?Send)] +impl IntegrationProxy for PreparationRoutes { + fn integration_name(&self) -> &'static str { + INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![ + IntegrationEndpoint::post(SNAPSHOT_PATH), + IntegrationEndpoint::put(format!("{RESULTS_PREFIX}*")), + IntegrationEndpoint::put(format!("{MARKERS_PREFIX}*")), + IntegrationEndpoint::get(format!("{PUBLIC_SOURCE_PREFIX}*")), + ] + } + + async fn handle( + &self, + call: ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/provenance/encypher/src/preparation/edge/tests.rs b/crates/provenance/encypher/src/preparation/edge/tests.rs new file mode 100644 index 0000000000..85850bd2a6 --- /dev/null +++ b/crates/provenance/encypher/src/preparation/edge/tests.rs @@ -0,0 +1,2447 @@ +//! The delivery instance's preparation routes, driven through their real +//! router with a scripted transport only at the network boundary: the +//! loopback reader pipeline, the Encypher API and the preparation worker. +//! +//! Every refusal here is one a forged or stale publication must meet before +//! anything is stored, and most must meet it before the API is asked +//! anything. The real probe cases run the fresh probe build the parent +//! provides, signed with a test release key, against receipts the candidate +//! API produced; see [`real_probe_receipts_publish_only_the_exact_output`]. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use edgezero_core::key_value_store::KvPage; +use futures::executor::block_on; +use http::HeaderMap; +use serde_json::Value; +use trusted_server_core::html_processor::test_support::page_settings; +use trusted_server_core::platform::test_support::build_services_with_http_client; +use trusted_server_core::platform::{ + PlatformError, PlatformHttpClient, PlatformKvStore, PlatformPendingRequest, PlatformResponse, + PlatformSelectResult, +}; + +use super::*; +use crate::preparation::pipeline::{ + PREPARED_PROTOCOL, PlannedMedia, PrepareRequest, PreparedSourceRef, ProbeRelease, + ProbeSelection, StructureChunk, TextCacheScope, TextSignRequest, +}; + +const TOKEN: &str = "cwk_test_worker_token_value"; +const POLICY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OTHER_POLICY: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const PUBLICATION: &str = "publication-secret-for-tests"; +const SIGNAL: &str = "signal-secret-for-tests"; +const HOST: &str = "publisher.example.com"; +const PLAN_ID: &str = "0f8fad5b-d9cb-469f-a165-70867728950e"; +const NONCE: &str = "0123456789abcdef0123456789abcdef"; +const MARKER: &str = "\u{E0100}\u{E0101}\u{FE01}"; +const SOURCE: &[u8] = b"\x89PNG\r\n\x1a\n-source-pixels"; +const OTHER_SOURCE: &[u8] = b"\x89PNG\r\n\x1a\n-other-pixels!"; +const ETAG: &str = "\"v1\""; +/// Core's configured-origin identity of the media route a large source is +/// published from, and of the same route after an operator remapped it to +/// another folder of the same origin host. +const CONFIGURED_SOURCE: &str = "https://publisher.example.com/library-a\0/media/"; +const REMAPPED_SOURCE: &str = "https://publisher.example.com/library-b\0/media/"; + +// --- doubles at the network and storage boundaries ------------------------ + +/// One request the scripted transport received. +#[derive(Debug, Clone)] +struct Seen { + method: Method, + uri: String, + headers: HeaderMap, + body: Bytes, +} + +impl Seen { + fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|value| value.to_str().ok()) + } +} + +type Responder = Box Response + Send + Sync>; + +/// A transport that answers each request from what it asked and records +/// every request. +struct ScriptedHttp { + responder: Responder, + seen: Mutex>, +} + +impl ScriptedHttp { + fn new(responder: impl Fn(&Seen) -> Response + Send + Sync + 'static) -> Arc { + Arc::new(Self { + responder: Box::new(responder), + seen: Mutex::new(Vec::new()), + }) + } + + /// A transport that refuses everything, for routes that must refuse + /// before reaching any network. + fn refusing() -> Arc { + Self::new(|_| answer(500, Vec::new(), &[])) + } + + fn seen(&self) -> Vec { + self.seen.lock().expect("should lock requests").clone() + } + + fn api_calls(&self) -> Vec { + self.seen() + .into_iter() + .filter(|seen| seen.uri.starts_with("https://api.example.com")) + .collect() + } +} + +#[async_trait(?Send)] +impl PlatformHttpClient for ScriptedHttp { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let (parts, body) = request.request.into_parts(); + let body = body + .into_bytes_bounded(64 * 1024 * 1024) + .await + .expect("should read the request body"); + let seen = Seen { + method: parts.method, + uri: parts.uri.to_string(), + headers: parts.headers, + body, + }; + let response = (self.responder)(&seen); + self.seen.lock().expect("should lock requests").push(seen); + Ok(PlatformResponse::new(response)) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::HttpClient)) + } +} + +/// An HTTP answer of `status` with `body` and `headers`. +fn answer(status: u16, body: Vec, headers: &[(&str, String)]) -> Response { + let mut builder = Response::builder().status(status); + for (name, value) in headers { + builder = builder.header(*name, value.as_str()); + } + builder + .body(EdgeBody::from(body)) + .expect("should build an answer") +} + +/// A runtime KV store held in memory, whose reads of keys starting with +/// `failing_prefix` fail. +#[derive(Default)] +struct MemoryKv { + entries: Mutex>, + failing_prefix: Option<&'static str>, +} + +impl MemoryKv { + fn failing(prefix: &'static str) -> Self { + Self { + failing_prefix: Some(prefix), + ..Self::default() + } + } + + fn get(&self, key: &str) -> Option { + self.entries.lock().expect("should lock").get(key).cloned() + } + + fn put(&self, key: &str, value: &impl serde::Serialize) { + self.entries.lock().expect("should lock").insert( + key.to_owned(), + Bytes::from(serde_json::to_vec(value).expect("should encode")), + ); + } +} + +#[async_trait(?Send)] +impl PlatformKvStore for MemoryKv { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + if self + .failing_prefix + .is_some_and(|prefix| key.starts_with(prefix)) + { + return Err(KvError::Unavailable); + } + Ok(self.get(key)) + } + + async fn put_bytes(&self, key: &str, value: Bytes) -> Result<(), KvError> { + self.entries + .lock() + .expect("should lock") + .insert(key.to_owned(), value); + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + key: &str, + value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + self.put_bytes(key, value).await + } + + async fn delete(&self, key: &str) -> Result<(), KvError> { + self.entries.lock().expect("should lock").remove(key); + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } +} + +// --- the deployment under test -------------------------------------------- + +fn config_value(domain: &str, public_host: &str, routes: &[&str]) -> Value { + json!({ + "api_base_url": "https://api.example.com", + "domain": domain, + "worker_token": TOKEN, + "max_large_media_bytes": 1_073_741_824_u64, + "execution": "async_prepared", + "preparation": { + "worker_url": "https://prep.publisher.example.com", + "loopback_url": format!("https://{public_host}"), + "deployment_id": "test", + "public_host": public_host, + "signal_secret": SIGNAL, + "publication_secret": PUBLICATION, + "routes": routes, + "variants": [{"name": "edition", "values": ["uk", "us"]}], + }, + }) +} + +fn routes_for(config: Value) -> PreparationRoutes { + let config: EncypherConfig = serde_json::from_value(config).expect("should parse config"); + PreparationRoutes::new(Arc::new(Signer::new(config, 5_000_000))) +} + +fn routes() -> PreparationRoutes { + routes_for(config_value(HOST, HOST, &["/news/", "/media/"])) +} + +fn services(http: &Arc, kv: &Arc) -> RuntimeServices { + build_services_with_http_client(Arc::clone(http) as Arc) + .with_kv_store(Arc::clone(kv) as Arc) +} + +fn call( + routes: &PreparationRoutes, + services: &RuntimeServices, + method: Method, + path: &str, + token: Option<&str>, + body: Vec, +) -> Response { + let mut builder = Request::builder() + .method(method) + .uri(format!("https://{HOST}{path}")); + if let Some(token) = token { + builder = builder.header(TOKEN_HEADER, token); + } + let request = builder + .body(EdgeBody::from(body)) + .expect("should build the request"); + block_on(routes.route(request, services)).expect("should answer") +} + +fn publish( + routes: &PreparationRoutes, + services: &RuntimeServices, + token: &str, + path_identity: &str, + envelope: &ResultEnvelope, +) -> Response { + call( + routes, + services, + Method::PUT, + &format!("{RESULTS_PREFIX}{path_identity}"), + Some(token), + serde_json::to_vec(envelope).expect("should encode the envelope"), + ) +} + +fn body_of(response: Response) -> Bytes { + block_on(response.into_body().into_bytes_bounded(64 * 1024 * 1024)) + .expect("should read the body") +} + +/// The refusal code of `response`, after checking its status. +fn refused_with(response: Response, status: StatusCode) -> String { + assert_eq!(response.status(), status, "should answer {status}"); + let body: Value = serde_json::from_slice(&body_of(response)).expect("should answer JSON"); + body["error"]["code"] + .as_str() + .expect("should name a code") + .to_owned() +} + +// --- envelopes ------------------------------------------------------------ + +fn page_url(path: &str) -> String { + format!("https://{HOST}{path}") +} + +fn media_request(carrier: Carrier, source: &[u8], mime: &str, path: &str) -> PrepareRequest { + PrepareRequest { + contract_version: 2, + carrier_requirement: match carrier { + Carrier::External => "external_allowed", + Carrier::Embedded => "embedded_required", + } + .to_owned(), + page_url: page_url(path), + expected_policy_digest: POLICY.to_owned(), + protocol: PREPARED_PROTOCOL.to_owned(), + probe: ProbeSelection { + sha256: "e".repeat(64), + planner_id: "d".repeat(64), + }, + source: PreparedSourceRef { + reference: page_url(path), + origin_host: HOST.to_owned(), + mime: mime.to_owned(), + sha256: sha256_hex(source), + byte_length: u64::try_from(source.len()).expect("should fit"), + reference_kind: "origin".to_owned(), + }, + structure: vec![StructureChunk { + offset: 0, + b64: "AAAA".to_owned(), + }], + } +} + +fn planned(request: PrepareRequest) -> PlannedMedia { + PlannedMedia { + digest: request.source.sha256.clone(), + request, + release: ProbeRelease { + abi: 1, + planner_id: "d".repeat(64), + release: 3, + sha256: "e".repeat(64), + signature: "AAAA".to_owned(), + }, + plan_id: PLAN_ID.to_owned(), + geometry: json!({"disposition": "external"}), + idempotency_key: "f".repeat(64), + } +} + +fn media_identity(routes: &PreparationRoutes, request: &PrepareRequest, variant: &str) -> String { + let carrier = request.carrier(); + let mime = match carrier { + Carrier::External => canonical_media_type(&request.source.mime), + Carrier::Embedded => request.source.mime.clone(), + }; + MediaIdentity { + credential_context: routes.signer.credential_context(), + policy_digest: &request.expected_policy_digest, + page_url: &request.page_url, + variant, + carrier, + mime: &mime, + source_sha256: &request.source.sha256, + } + .digest() +} + +fn envelope(identity: &str, item: EnvelopeItem) -> ResultEnvelope { + ResultEnvelope { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: "test".to_owned(), + identity: identity.to_owned(), + retirement_epoch: 0, + variant: String::new(), + item, + } +} + +fn media_envelope(routes: &PreparationRoutes) -> (String, ResultEnvelope) { + let request = media_request(Carrier::External, SOURCE, "image/png", "/media/a.png"); + let identity = media_identity(routes, &request, ""); + let item = EnvelopeItem::Media { + planned: planned(request), + source_b64: STANDARD.encode(SOURCE), + }; + (identity.clone(), envelope(&identity, item)) +} + +fn text_request(text: &str) -> TextSignRequest { + TextSignRequest { + text: text.to_owned(), + page_url: page_url("/news/a"), + document_title: Some("Harbor".to_owned()), + boundary_selector: "article".to_owned(), + expected_policy_digest: POLICY.to_owned(), + cache_scope: TextCacheScope::ExactRequest, + } +} + +fn text_envelope(routes: &PreparationRoutes, request: TextSignRequest) -> (String, ResultEnvelope) { + let identity = TextIdentity { + credential_context: routes.signer.credential_context(), + policy_digest: &request.expected_policy_digest, + page_url: &request.page_url, + variant: "", + text: &request.text, + document_title: request.document_title.as_deref(), + boundary_selector: &request.boundary_selector, + } + .digest(); + let item = EnvelopeItem::Text { + request, + idempotency_key: "9".repeat(64), + }; + (identity.clone(), envelope(&identity, item)) +} + +fn sign_answer(policy: &str) -> Vec { + serde_json::to_vec(&json!({ + "success": true, + "policy_digest": policy, + "embedding_plan": { + "index_unit": "codepoint", + "operations": [{"insert_after_index": 5, "marker": MARKER}], + }, + "manifest_url": "https://api.example.com/manifests/doc-1", + "verification_url": "https://api.example.com/verify/doc-1", + "document_id": "doc-1", + })) + .expect("should encode the sign answer") +} + +const ARTICLE_TEXT: &str = "Harbor council votes on the ferry schedule for the coming season."; + +// --- authentication, deployment and identity ------------------------------ + +#[test] +fn publication_needs_the_publication_scope_this_deployment_and_its_own_identity() { + let routes = routes(); + let http = ScriptedHttp::refusing(); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let (identity, genuine) = media_envelope(&routes); + + let signal_scope = publish(&routes, &services, SIGNAL, &identity, &genuine); + assert_eq!( + refused_with(signal_scope, StatusCode::UNAUTHORIZED), + "unauthorized", + "the reader-signal secret should not publish" + ); + + let mut other_deployment = genuine.clone(); + other_deployment.deployment_id = "other".to_owned(); + assert_eq!( + refused_with( + publish( + &routes, + &services, + PUBLICATION, + &identity, + &other_deployment + ), + StatusCode::FORBIDDEN + ), + "wrong_deployment" + ); + + let elsewhere = "1".repeat(64); + assert_eq!( + refused_with( + publish(&routes, &services, PUBLICATION, &elsewhere, &genuine), + StatusCode::FORBIDDEN + ), + "wrong_deployment", + "an envelope should publish only under the identity it names" + ); + + let mut unlisted = genuine.clone(); + unlisted.variant = "edition=fr".to_owned(); + assert_eq!( + refused_with( + publish(&routes, &services, PUBLICATION, &identity, &unlisted), + StatusCode::FORBIDDEN + ), + "variant_not_admitted" + ); + + assert!(http.seen().is_empty(), "nothing should reach any network"); + assert!( + kv.get(&result_key(&identity)).is_none(), + "nothing should be stored" + ); +} + +#[test] +fn a_retired_or_unreadable_marker_refuses_publication_before_the_api_is_asked() { + let routes = routes(); + let (identity, genuine) = media_envelope(&routes); + + let http = ScriptedHttp::refusing(); + let kv = Arc::new(MemoryKv::default()); + kv.put(&marker_key(&identity), &RetirementMarker { epoch: 2 }); + let retired = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &genuine, + ); + assert_eq!(refused_with(retired, StatusCode::CONFLICT), "retired"); + assert!(kv.get(&result_key(&identity)).is_none()); + + let unreadable = Arc::new(MemoryKv::failing("encypher:prep:v1:retired:")); + let refused = publish( + &routes, + &services(&http, &unreadable), + PUBLICATION, + &identity, + &genuine, + ); + assert_eq!( + refused_with(refused, StatusCode::SERVICE_UNAVAILABLE), + "marker_unavailable", + "a marker that cannot be read should refuse, never publish" + ); + assert!(unreadable.get(&result_key(&identity)).is_none()); + assert!(http.seen().is_empty(), "the API should never be asked"); +} + +#[test] +fn worker_supplied_identities_sources_and_carriers_are_never_taken_on_trust() { + let routes = routes(); + let http = ScriptedHttp::refusing(); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + + // An identity for one source over a request naming another. + let request = media_request(Carrier::External, SOURCE, "image/png", "/media/a.png"); + let other = media_request(Carrier::External, OTHER_SOURCE, "image/png", "/media/a.png"); + let wrong_identity = media_identity(&routes, &other, ""); + let substituted = envelope( + &wrong_identity, + EnvelopeItem::Media { + planned: planned(request.clone()), + source_b64: STANDARD.encode(SOURCE), + }, + ); + assert_eq!( + refused_with( + publish( + &routes, + &services, + PUBLICATION, + &wrong_identity, + &substituted + ), + StatusCode::UNPROCESSABLE_ENTITY + ), + "identity_mismatch" + ); + + // Self-consistent identity and request, but bytes one byte off. + let identity = media_identity(&routes, &request, ""); + let mut tampered = SOURCE.to_vec(); + *tampered.last_mut().expect("should have bytes") ^= 1; + let one_byte = envelope( + &identity, + EnvelopeItem::Media { + planned: planned(request.clone()), + source_b64: STANDARD.encode(&tampered), + }, + ); + assert_eq!( + refused_with( + publish(&routes, &services, PUBLICATION, &identity, &one_byte), + StatusCode::UNPROCESSABLE_ENTITY + ), + "source_digest_mismatch" + ); + + // An embedded item whose source bytes are not the ones it names. + let embedded = media_request(Carrier::Embedded, SOURCE, "image/png", "/news/a"); + let embedded_identity = media_identity(&routes, &embedded, ""); + let embedded_forgery = envelope( + &embedded_identity, + EnvelopeItem::Embedded { + planned: planned(embedded.clone()), + source_b64: STANDARD.encode(OTHER_SOURCE), + output_b64: STANDARD.encode(OTHER_SOURCE), + }, + ); + assert_eq!( + refused_with( + publish( + &routes, + &services, + PUBLICATION, + &embedded_identity, + &embedded_forgery + ), + StatusCode::UNPROCESSABLE_ENTITY + ), + "source_digest_mismatch" + ); + + // An external plan presented as embedded, and the reverse. + let as_embedded = envelope( + &identity, + EnvelopeItem::Embedded { + planned: planned(request.clone()), + source_b64: STANDARD.encode(SOURCE), + output_b64: STANDARD.encode(SOURCE), + }, + ); + assert_eq!( + refused_with( + publish(&routes, &services, PUBLICATION, &identity, &as_embedded), + StatusCode::UNPROCESSABLE_ENTITY + ), + "carrier_mismatch" + ); + let as_external = envelope( + &embedded_identity, + EnvelopeItem::Media { + planned: planned(embedded), + source_b64: STANDARD.encode(SOURCE), + }, + ); + assert_eq!( + refused_with( + publish( + &routes, + &services, + PUBLICATION, + &embedded_identity, + &as_external + ), + StatusCode::UNPROCESSABLE_ENTITY + ), + "carrier_mismatch" + ); + + // Addresses outside the claimed host or the configured routes. + for path_or_host in [ + "https://evil.example.net/media/a.png", + "https://publisher.example.com/private/a.png", + ] { + let mut outside = request.clone(); + outside.page_url = path_or_host.to_owned(); + let outside_identity = media_identity(&routes, &outside, ""); + let item = envelope( + &outside_identity, + EnvelopeItem::Media { + planned: planned(outside), + source_b64: STANDARD.encode(SOURCE), + }, + ); + assert_eq!( + refused_with( + publish(&routes, &services, PUBLICATION, &outside_identity, &item), + StatusCode::UNPROCESSABLE_ENTITY + ), + "page_url_outside_deployment", + "{path_or_host} should be refused" + ); + } + + assert!( + http.seen().is_empty(), + "every forgery should be refused before the API is asked" + ); + assert!( + kv.entries.lock().expect("should lock").is_empty(), + "nothing should be stored" + ); +} + +// --- text: recovered, never signed ---------------------------------------- + +#[test] +fn text_publication_refuses_missing_or_unknown_cache_scope_before_recovery() { + let routes = routes(); + let http = ScriptedHttp::refusing(); + let kv = Arc::new(MemoryKv::default()); + let (identity, genuine) = text_envelope(&routes, text_request(ARTICLE_TEXT)); + for scope in [None, Some(json!("legacy")), Some(Value::Null)] { + let mut body = serde_json::to_value(&genuine).expect("should encode an envelope"); + let request = body["item"]["request"] + .as_object_mut() + .expect("should carry a request"); + if let Some(scope) = scope { + request.insert("cache_scope".to_owned(), scope); + } else { + request.remove("cache_scope"); + } + let response = call( + &routes, + &services(&http, &kv), + Method::PUT, + &format!("{RESULTS_PREFIX}{identity}"), + Some(PUBLICATION), + serde_json::to_vec(&body).expect("should encode an unsupported request"), + ); + assert_eq!( + refused_with(response, StatusCode::BAD_REQUEST), + "malformed_request" + ); + } + assert!( + http.seen().is_empty(), + "unsupported scope must never reach recovery" + ); + assert!(kv.entries.lock().expect("should lock").is_empty()); +} + +#[test] +fn text_publication_refuses_old_cache_identity_and_substituted_title_or_url() { + let routes = routes(); + let http = ScriptedHttp::refusing(); + let kv = Arc::new(MemoryKv::default()); + let request = text_request(ARTICLE_TEXT); + let (identity, genuine) = text_envelope(&routes, request.clone()); + let text_hash = crate::media_api::sha256_hex(request.text.as_bytes()); + let old_identity = super::super::protocol::domain_digest( + "encypher-preparation/1:text", + &[ + routes.signer.credential_context().as_bytes(), + request.expected_policy_digest.as_bytes(), + request.page_url.as_bytes(), + b"", + super::super::protocol::TEXT_PROJECTION_VERSION.as_bytes(), + text_hash.as_bytes(), + b"1", + b"Harbor", + request.boundary_selector.as_bytes(), + ], + ); + let mut old = genuine.clone(); + old.identity = old_identity.clone(); + assert_eq!( + refused_with( + publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &old_identity, + &old + ), + StatusCode::UNPROCESSABLE_ENTITY, + ), + "identity_mismatch", + "even an exact-scoped envelope must not publish under the former cache domain" + ); + for changed_request in [ + TextSignRequest { + document_title: Some("Revised harbor title".to_owned()), + ..request.clone() + }, + TextSignRequest { + page_url: page_url("/news/mirror"), + ..request + }, + ] { + let (_, mut changed) = text_envelope(&routes, changed_request); + changed.identity = identity.clone(); + assert_eq!( + refused_with( + publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &changed + ), + StatusCode::UNPROCESSABLE_ENTITY, + ), + "identity_mismatch", + "title and URL cannot change under an existing prepared identity" + ); + } + assert!( + http.seen().is_empty(), + "stale identities must never reach recovery" + ); + assert!(kv.entries.lock().expect("should lock").is_empty()); +} +#[test] +fn a_text_result_is_recovered_never_signed_and_published_under_its_identity() { + let routes = routes(); + let http = ScriptedHttp::new(|seen| { + if seen.uri == "https://api.example.com/api/v1/public/cdn/sign/recover" { + answer(200, sign_answer(POLICY), &[]) + } else { + answer(500, Vec::new(), &[]) + } + }); + let kv = Arc::new(MemoryKv::default()); + let request = text_request(ARTICLE_TEXT); + let (identity, genuine) = text_envelope(&routes, request.clone()); + + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &genuine, + ); + + assert_eq!(response.status(), StatusCode::OK, "should publish"); + let calls = http.seen(); + assert_eq!(calls.len(), 1, "should make exactly one API call"); + let call = &calls[0]; + assert_eq!(call.method, Method::POST); + assert_eq!( + call.uri, "https://api.example.com/api/v1/public/cdn/sign/recover", + "should recover, never call the sign route" + ); + assert_eq!( + call.header("idempotency-key"), + Some("9".repeat(64).as_str()) + ); + assert_eq!(call.header("x-encypher-worker-token"), Some(TOKEN)); + let recovered: Value = serde_json::from_slice(&call.body).expect("should carry JSON"); + assert_eq!(recovered["cache_scope"], "exact_request"); + assert_eq!( + serde_json::from_slice::(&call.body).expect("should send the request"), + request, + "should recover exactly the frozen request" + ); + let record: PublishedRecord = serde_json::from_slice( + &kv.get(&result_key(&identity)) + .expect("should store the record"), + ) + .expect("should store a record"); + assert_eq!(record.identity, identity); + assert!( + matches!(record.result, PublishedResult::Text { scalars, .. } if scalars == ARTICLE_TEXT.chars().count()), + "should store the recovered plan for this exact text" + ); + + // A worker that changes the text under the same identity is refused. + let (_, mut changed) = text_envelope( + &routes, + text_request("A different article entirely, long enough."), + ); + changed.identity = identity.clone(); + assert_eq!( + refused_with( + publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &changed + ), + StatusCode::UNPROCESSABLE_ENTITY + ), + "identity_mismatch" + ); +} + +#[test] +fn a_text_result_the_api_no_longer_holds_or_holds_under_another_policy_is_refused() { + let routes = routes(); + let (identity, genuine) = text_envelope(&routes, text_request(ARTICLE_TEXT)); + for (status, body, expected_status, code) in [ + (404, Vec::new(), StatusCode::GONE, "result_not_retained"), + ( + 200, + sign_answer(OTHER_POLICY), + StatusCode::CONFLICT, + "policy_digest_mismatch", + ), + ] { + let http = ScriptedHttp::new(move |_| answer(status, body.clone(), &[])); + let kv = Arc::new(MemoryKv::default()); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &genuine, + ); + assert_eq!(refused_with(response, expected_status), code); + assert!( + kv.get(&result_key(&identity)).is_none(), + "{code}: nothing stored" + ); + assert!( + http.seen() + .iter() + .all(|seen| !seen.uri.ends_with("/cdn/sign")), + "{code}: should never call the sign route" + ); + } +} + +// --- retirement markers --------------------------------------------------- + +fn retire( + routes: &PreparationRoutes, + services: &RuntimeServices, + token: &str, + identity: &str, + epoch: u64, +) -> Response { + let marker = MarkerEnvelope { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: "test".to_owned(), + identity: identity.to_owned(), + epoch, + }; + call( + routes, + services, + Method::PUT, + &format!("{MARKERS_PREFIX}{identity}"), + Some(token), + serde_json::to_vec(&marker).expect("should encode the marker"), + ) +} + +fn stored_marker(kv: &MemoryKv, identity: &str) -> RetirementMarker { + serde_json::from_slice( + &kv.get(&marker_key(identity)) + .expect("should store a marker"), + ) + .expect("should store a marker") +} + +#[test] +fn retirement_markers_are_monotonic_and_refuse_late_results() { + let routes = routes(); + let http = ScriptedHttp::new(|_| answer(200, sign_answer(POLICY), &[])); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let (identity, genuine) = text_envelope(&routes, text_request(ARTICLE_TEXT)); + assert_eq!( + publish(&routes, &services, PUBLICATION, &identity, &genuine).status(), + StatusCode::OK + ); + assert!(kv.get(&result_key(&identity)).is_some()); + + assert_eq!( + refused_with( + retire(&routes, &services, SIGNAL, &identity, 3), + StatusCode::UNAUTHORIZED + ), + "unauthorized", + "the reader-signal secret should not retire" + ); + let acknowledged = retire(&routes, &services, PUBLICATION, &identity, 3); + assert_eq!(acknowledged.status(), StatusCode::OK); + let body: Value = serde_json::from_slice(&body_of(acknowledged)).expect("should answer JSON"); + assert_eq!(body["acknowledged"], json!(identity)); + assert_eq!(body["epoch"], json!(3)); + assert_eq!(stored_marker(&kv, &identity).epoch, 3); + assert!( + kv.get(&result_key(&identity)).is_none(), + "should drop the retired result" + ); + + let older = retire(&routes, &services, PUBLICATION, &identity, 1); + assert_eq!(older.status(), StatusCode::OK); + assert_eq!( + stored_marker(&kv, &identity).epoch, + 3, + "a delayed older marker should never lower the epoch" + ); + + let calls_before = http.seen().len(); + let late = publish(&routes, &services, PUBLICATION, &identity, &genuine); + assert_eq!(refused_with(late, StatusCode::CONFLICT), "retired"); + assert_eq!( + http.seen().len(), + calls_before, + "a late result should be refused without asking the API" + ); + assert!(kv.get(&result_key(&identity)).is_none()); + assert_eq!( + stored_marker(&kv, &identity).epoch, + 3, + "the marker should stand" + ); +} + +// --- large media: the full source is read again --------------------------- + +/// What the loopback reader pipeline answers for a large source. +#[derive(Clone)] +struct Origin { + bytes: Arc>, + etag: &'static str, + facts: Option, + facts_secret: &'static str, + content_type: &'static str, +} + +impl Origin { + fn honest(bytes: Vec) -> Self { + Self { + bytes: Arc::new(bytes), + etag: ETAG, + facts: Some(SnapshotOrigin { + status: 206, + origin_host: HOST.to_owned(), + origin_identity_sha256: sha256_hex(CONFIGURED_SOURCE.as_bytes()), + ..SnapshotOrigin::default() + }), + facts_secret: PUBLICATION, + content_type: "image/png", + } + } + + /// Answers a pinned range read the way the reader pipeline does, with + /// facts authenticated for the capability's nonce; anything else, the + /// API's recovery included, finds nothing. + fn responder(self) -> impl Fn(&Seen) -> Response + Send + Sync + 'static { + move |seen| { + let Some(capability) = seen.header(CAPABILITY_HEADER) else { + return answer(404, Vec::new(), &[]); + }; + let nonce = capability.split('.').nth(2).expect("should carry a nonce"); + let range = seen + .header("range") + .and_then(|value| value.strip_prefix("bytes=")) + .and_then(|value| value.split_once('-')) + .expect("should read a range"); + let first: usize = range.0.parse().expect("should parse"); + let last: usize = range.1.parse().expect("should parse"); + let mut headers = vec![ + ( + "content-range", + format!("bytes {first}-{last}/{}", self.bytes.len()), + ), + ("etag", self.etag.to_owned()), + ("content-type", self.content_type.to_owned()), + ]; + if let Some(facts) = &self.facts { + headers.push((FACTS_HEADER, facts_header(self.facts_secret, nonce, facts))); + } + answer(206, self.bytes[first..=last].to_vec(), &headers) + } + } +} + +/// The production-signed release every real-probe fixture was planned with. +fn signed_release() -> ProbeRelease { + serde_json::from_slice(include_bytes!("../../fixtures/prepared/descriptor.json")) + .expect("should parse the production-signed descriptor") +} + +/// A store already holding the signed release's verified artifact, as one +/// that loaded it before does, so a publication needs no API call to run it. +fn probe_kv() -> Arc { + let kv = Arc::new(MemoryKv::default()); + kv.entries.lock().expect("should lock").insert( + format!("{PROBE_KEY_PREFIX}:{}", signed_release().sha256), + Bytes::from(fixture("probe.wasm")), + ); + kv +} + +/// Appends one PNG chunk of `kind` holding `data`, with its CRC. +fn png_chunk(png: &mut Vec, kind: &[u8; 4], data: &[u8]) { + png.extend_from_slice(&u32::try_from(data.len()).expect("should fit").to_be_bytes()); + png.extend_from_slice(kind); + png.extend_from_slice(data); + let mut crc = !0_u32; + for byte in kind.iter().chain(data) { + crc ^= u32::from(*byte); + for _ in 0..8 { + crc = if crc & 1 == 1 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + png.extend_from_slice(&(!crc).to_be_bytes()); +} + +/// The retained real PNG with `chunks` inserted before its `IEND`. +fn png_with(chunks: &[(&[u8; 4], &[u8])]) -> Vec { + let base: &[u8] = include_bytes!("../../fixtures/prepared/external-png/source.png"); + let (body, end) = base.split_at(base.len() - 12); + assert_eq!(&end[4..8], b"IEND", "the fixture should end with IEND"); + let mut png = body.to_vec(); + for (kind, data) in chunks { + png_chunk(&mut png, kind, data); + } + png.extend_from_slice(end); + png +} + +/// A PNG of exactly `length` bytes: the real fixture with one private +/// chunk of sevens before its `IEND`, whose last data byte is `last`. +fn large_png(length: usize, last: u8) -> Vec { + let base = png_with(&[]).len(); + let mut data = vec![7_u8; length - base - 12]; + *data.last_mut().expect("should have data") = last; + let png = png_with(&[(b"prVt", data.as_slice())]); + assert_eq!(png.len(), length, "should be exactly the asked length"); + png +} + +/// An XMP packet naming a remote manifest with `dcterms:provenance`. +const PROVENANCE_XMP: &[u8] = br#""#; + +/// The uncompressed `iTXt` data carrying `packet` as the PNG's XMP. +fn xmp_itxt(packet: &[u8]) -> Vec { + let mut data = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec(); + data.extend_from_slice(packet); + data +} + +fn large_envelope(routes: &PreparationRoutes, planned_source: &[u8]) -> (String, ResultEnvelope) { + large_envelope_bound( + routes, + planned_source, + &sha256_hex(CONFIGURED_SOURCE.as_bytes()), + ) +} + +/// A large envelope whose worker says it read the configured source +/// `origin_identity_sha256` names, under an identity bound to it. +fn large_envelope_bound( + routes: &PreparationRoutes, + planned_source: &[u8], + origin_identity_sha256: &str, +) -> (String, ResultEnvelope) { + let release = signed_release(); + let mut request = media_request( + Carrier::External, + planned_source, + "image/png", + "/media/ferry.png", + ); + request.probe = ProbeSelection { + sha256: release.sha256.clone(), + planner_id: release.planner_id.clone(), + }; + let identity = LargeLocator { + credential_context: routes.signer.credential_context(), + policy_digest: POLICY, + reader_url: &request.page_url, + variant: "", + origin_host: HOST, + origin_identity_sha256, + mime: "image/png", + etag: ETAG, + length: request.source.byte_length, + } + .digest(); + let mut planned = planned(request); + planned.release = release; + let item = EnvelopeItem::Large { + planned, + etag: ETAG.to_owned(), + origin_identity_sha256: origin_identity_sha256.to_owned(), + }; + (identity.clone(), envelope(&identity, item)) +} + +/// The pinned reads `seen` made, in order, as their `Range` headers. +fn pinned_ranges(seen: &[Seen]) -> Vec { + seen.iter() + .filter(|seen| seen.header(CAPABILITY_HEADER).is_some()) + .map(|seen| seen.header("range").unwrap_or_default().to_owned()) + .collect() +} + +/// Whether a `bytes=first-last` range is at most one pinned block. +fn within_block(range: &str) -> bool { + range + .strip_prefix("bytes=") + .and_then(|range| range.split_once('-')) + .and_then(|(first, last)| Some(last.parse::().ok()? - first.parse::().ok()?)) + .is_some_and(|span| span < PINNED_BLOCK_BYTES) +} + +#[test] +fn large_publication_hashes_every_byte_of_the_pinned_version_before_the_api_is_asked() { + let routes = routes(); + let length = usize::try_from(MAX_RANGE_BYTES).expect("should fit") + 10; + let source = large_png(length, 0); + + // The worker's claimed digest is the source's, but the version the tag + // names differs near its end, beyond the first range. + let served = large_png(length, 1); + let http = ScriptedHttp::new(Origin::honest(served).responder()); + let kv = probe_kv(); + let (identity, forged) = large_envelope(&routes, &source); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &forged, + ); + assert_eq!( + refused_with(response, StatusCode::UNPROCESSABLE_ENTITY), + "source_digest_mismatch" + ); + assert!(http.api_calls().is_empty(), "the API should never be asked"); + assert!(kv.get(&result_key(&identity)).is_none()); + + // An honest version passes the full read, and only then is the API's + // receipt asked for; here it holds none. + let http = ScriptedHttp::new(Origin::honest(source.clone()).responder()); + let kv = probe_kv(); + let (identity, genuine) = large_envelope(&routes, &source); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &genuine, + ); + assert_eq!( + refused_with(response, StatusCode::GONE), + "result_not_retained" + ); + let seen = http.seen(); + let reads: Vec<&Seen> = seen + .iter() + .filter(|seen| seen.header(CAPABILITY_HEADER).is_some()) + .collect(); + let ranges = pinned_ranges(&seen); + let (inspection, hashing) = ranges.split_at(ranges.len() - 2); + assert_eq!( + hashing, + [ + format!("bytes=0-{}", MAX_RANGE_BYTES - 1), + format!("bytes={}-{}", MAX_RANGE_BYTES, length - 1), + ], + "should read every byte in pinned ranges" + ); + assert!( + !inspection.is_empty() && inspection.iter().all(|range| within_block(range)), + "the provenance inspection should read only blocks of the version, first" + ); + for read in &reads { + assert_eq!(read.uri, format!("https://{HOST}/media/ferry.png")); + assert_eq!( + read.header("if-match"), + Some(ETAG), + "should pin the version" + ); + assert_eq!(read.header("accept-encoding"), Some("identity")); + let capability = read.header(CAPABILITY_HEADER).expect("should carry it"); + assert!( + verify_snapshot_capability(PUBLICATION, "/media/ferry.png", capability, unix_now()) + .is_some(), + "should carry a capability for exactly this path" + ); + } + let last_read = seen + .iter() + .rposition(|seen| seen.header(CAPABILITY_HEADER).is_some()) + .expect("should read"); + let recover = seen + .iter() + .position(|seen| seen.uri.ends_with("/media/hash/recover")) + .expect("should ask for the receipt"); + assert!( + recover > last_read, + "the receipt should be asked for only after the read" + ); + assert!(kv.get(&result_key(&identity)).is_none()); +} + +#[test] +fn large_publication_reads_the_admitted_variant_not_the_query_free_resource() { + let routes = routes(); + let length = usize::try_from(MAX_RANGE_BYTES).expect("should fit a range") + 10; + let source_b = large_png(length, 7); + let source_a = large_png(length, 8); + assert_eq!(source_a.len(), source_b.len()); + assert_ne!(sha256_hex(&source_a), sha256_hex(&source_b)); + let base = Origin::honest(source_a).responder(); + let variant = Origin::honest(source_b.clone()).responder(); + let http = ScriptedHttp::new(move |seen| { + if seen.uri == format!("https://{HOST}/media/ferry.png?edition=uk") { + variant(seen) + } else { + base(seen) + } + }); + let kv = probe_kv(); + let (_, mut genuine) = large_envelope(&routes, &source_b); + genuine.variant = "edition=uk".to_owned(); + let EnvelopeItem::Large { + planned, + etag, + origin_identity_sha256, + } = &genuine.item + else { + panic!("should have a large publication"); + }; + genuine.identity = LargeLocator { + credential_context: routes.signer.credential_context(), + policy_digest: POLICY, + reader_url: &planned.request.page_url, + variant: &genuine.variant, + origin_host: HOST, + origin_identity_sha256, + mime: "image/png", + etag, + length: planned.request.source.byte_length, + } + .digest(); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &genuine.identity, + &genuine, + ); + assert_eq!( + refused_with(response, StatusCode::GONE), + "result_not_retained", + "B's digest should pass before the absent API receipt refuses publication" + ); + let seen = http.seen(); + let reads: Vec<&Seen> = seen + .iter() + .filter(|seen| seen.header(CAPABILITY_HEADER).is_some()) + .collect(); + let ranges = pinned_ranges(&seen); + assert_eq!( + ranges[ranges.len() - 2..], + [ + format!("bytes=0-{}", MAX_RANGE_BYTES - 1), + format!("bytes={}-{}", MAX_RANGE_BYTES, length - 1), + ], + "should hash all of B in pinned ranges" + ); + for read in &reads { + assert_eq!( + read.uri, + format!("https://{HOST}/media/ferry.png?edition=uk") + ); + assert_eq!(read.header("if-match"), Some(ETAG)); + let capability = read.header(CAPABILITY_HEADER).expect("should carry it"); + assert!( + verify_snapshot_capability( + PUBLICATION, + "/media/ferry.png?edition=uk", + capability, + unix_now(), + ) + .is_some(), + "the read capability should bind B's exact path and variant" + ); + assert!( + verify_snapshot_capability(PUBLICATION, "/media/ferry.png", capability, unix_now()) + .is_none(), + "B's capability should not authorize A" + ); + } + let recover = seen + .iter() + .position(|request| request.uri.ends_with("/media/hash/recover")) + .expect("should ask for the receipt after hashing B"); + let last_read = seen + .iter() + .rposition(|request| request.header(CAPABILITY_HEADER).is_some()) + .expect("should read B"); + assert!(recover > last_read); + assert!(kv.get(&result_key(&genuine.identity)).is_none()); +} + +#[test] +fn large_publication_refuses_reads_the_origin_facts_do_not_admit() { + let routes = routes(); + let source = vec![7_u8; 1024]; + let mut private = Origin::honest(source.clone()); + private.facts = Some(SnapshotOrigin { + private: true, + ..private.facts.clone().expect("should have facts") + }); + let mut unobserved = Origin::honest(source.clone()); + unobserved.facts = None; + let mut forged_facts = Origin::honest(source.clone()); + forged_facts.facts_secret = "an-origin-guessing-the-secret"; + let mut elsewhere = Origin::honest(source.clone()); + elsewhere.facts = Some(SnapshotOrigin { + origin_host: "cdn.other.example".to_owned(), + ..elsewhere.facts.clone().expect("should have facts") + }); + // The same host answers from another configured source, or reports none. + let mut remapped = Origin::honest(source.clone()); + remapped.facts = Some(SnapshotOrigin { + origin_identity_sha256: sha256_hex(REMAPPED_SOURCE.as_bytes()), + ..remapped.facts.clone().expect("should have facts") + }); + let mut unbound = Origin::honest(source.clone()); + unbound.facts = Some(SnapshotOrigin { + origin_identity_sha256: String::new(), + ..unbound.facts.clone().expect("should have facts") + }); + let mut encoded = Origin::honest(source.clone()); + encoded.facts = Some(SnapshotOrigin { + content_encoding: "gzip".to_owned(), + ..encoded.facts.clone().expect("should have facts") + }); + let mut provenance = Origin::honest(source.clone()); + provenance.facts = Some(SnapshotOrigin { + manifest_link: true, + ..provenance.facts.clone().expect("should have facts") + }); + let mut retagged = Origin::honest(source.clone()); + retagged.etag = "\"v2\""; + let mut retyped = Origin::honest(source.clone()); + retyped.content_type = "image/webp"; + + for (origin, status, code) in [ + (private, StatusCode::FORBIDDEN, "source_private"), + ( + unobserved, + StatusCode::UNPROCESSABLE_ENTITY, + "snapshot_unobserved", + ), + ( + forged_facts, + StatusCode::UNPROCESSABLE_ENTITY, + "snapshot_unobserved", + ), + ( + elsewhere, + StatusCode::UNPROCESSABLE_ENTITY, + "source_origin_mismatch", + ), + ( + remapped, + StatusCode::UNPROCESSABLE_ENTITY, + "source_identity_mismatch", + ), + ( + unbound, + StatusCode::UNPROCESSABLE_ENTITY, + "source_identity_mismatch", + ), + (encoded, StatusCode::UNPROCESSABLE_ENTITY, "content_encoded"), + ( + provenance, + StatusCode::UNPROCESSABLE_ENTITY, + "source_has_provenance_link", + ), + ( + retagged, + StatusCode::UNPROCESSABLE_ENTITY, + "source_version_changed", + ), + ( + retyped, + StatusCode::UNPROCESSABLE_ENTITY, + "source_type_mismatch", + ), + ] { + let http = ScriptedHttp::new(origin.responder()); + let kv = probe_kv(); + let (identity, genuine) = large_envelope(&routes, &source); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &genuine, + ); + assert_eq!(refused_with(response, status), code); + assert!( + http.api_calls().is_empty(), + "{code}: the API should never be asked" + ); + assert!( + kv.get(&result_key(&identity)).is_none(), + "{code}: nothing stored" + ); + } +} + +/// Publication binds the configured source, not only the host: a route an +/// operator remapped serves another resource under the same public URL, +/// host, entity tag, type and length. A result for source A is refused +/// against authenticated facts for source B, whether B answers every read +/// or only the reads after the first, before the API is asked or anything +/// is stored; the genuine source A still reaches receipt recovery. +#[test] +fn large_publication_refuses_a_version_another_configured_source_serves() { + let routes = routes(); + let length = usize::try_from(MAX_RANGE_BYTES).expect("should fit") + 10; + let source = large_png(length, 0); + let configured = sha256_hex(CONFIGURED_SOURCE.as_bytes()); + let remapped = sha256_hex(REMAPPED_SOURCE.as_bytes()); + let mut served_by_b = Origin::honest(source.clone()); + served_by_b.facts = Some(SnapshotOrigin { + origin_identity_sha256: remapped.clone(), + ..served_by_b.facts.clone().expect("should have facts") + }); + + // B answers every read: the first pinned read refuses A's result. + let http = ScriptedHttp::new(served_by_b.clone().responder()); + let kv = probe_kv(); + let (identity, genuine) = large_envelope(&routes, &source); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &genuine, + ); + assert_eq!( + refused_with(response, StatusCode::UNPROCESSABLE_ENTITY), + "source_identity_mismatch" + ); + assert_eq!( + pinned_ranges(&http.seen()).len(), + 1, + "should refuse at the first pinned read" + ); + assert!(http.api_calls().is_empty(), "the API should never be asked"); + assert!(kv.get(&result_key(&identity)).is_none(), "nothing stored"); + + // The remap lands after the first hashing range: every later read is + // checked again, so the rest of the version cannot come from B. + let switched = AtomicBool::new(false); + let first_hash_range = format!("bytes=0-{}", MAX_RANGE_BYTES - 1); + let from_a = Origin::honest(source.clone()).responder(); + let from_b = served_by_b.responder(); + let http = ScriptedHttp::new(move |seen| { + if switched.load(Ordering::SeqCst) { + return from_b(seen); + } + if seen.header("range") == Some(first_hash_range.as_str()) { + switched.store(true, Ordering::SeqCst); + } + from_a(seen) + }); + let kv = probe_kv(); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &genuine, + ); + assert_eq!( + refused_with(response, StatusCode::UNPROCESSABLE_ENTITY), + "source_identity_mismatch" + ); + assert_eq!( + pinned_ranges(&http.seen()).last().map(String::as_str), + Some(format!("bytes={}-{}", MAX_RANGE_BYTES, length - 1).as_str()), + "should refuse the hashing read B answered" + ); + assert!(http.api_calls().is_empty(), "the API should never be asked"); + assert!(kv.get(&result_key(&identity)).is_none(), "nothing stored"); + + // A worker that says it read B is refused where A answers. + let http = ScriptedHttp::new(Origin::honest(source.clone()).responder()); + let kv = probe_kv(); + let (claimed_b, envelope_b) = large_envelope_bound(&routes, &source, &remapped); + assert_ne!( + claimed_b, identity, + "B's result should have its own identity" + ); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &claimed_b, + &envelope_b, + ); + assert_eq!( + refused_with(response, StatusCode::UNPROCESSABLE_ENTITY), + "source_identity_mismatch" + ); + assert!(http.api_calls().is_empty(), "the API should never be asked"); + assert!(kv.get(&result_key(&claimed_b)).is_none(), "nothing stored"); + + // Genuine A passes every pinned read and the full hash, and only then + // asks for the receipt, which this API does not hold. + let http = ScriptedHttp::new(Origin::honest(source.clone()).responder()); + let kv = probe_kv(); + let (identity_a, genuine_a) = large_envelope_bound(&routes, &source, &configured); + assert_eq!(identity_a, identity); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity_a, + &genuine_a, + ); + assert_eq!( + refused_with(response, StatusCode::GONE), + "result_not_retained", + "A should pass every source check before the absent receipt refuses it" + ); + assert!( + http.seen() + .iter() + .any(|seen| seen.uri.ends_with("/media/hash/recover")), + "should ask for A's receipt" + ); +} + +/// The worker's configured-source digest must be one: an empty or +/// malformed value refuses the result before any source read. +#[test] +fn large_publication_refuses_a_malformed_configured_source_digest() { + let routes = routes(); + let source = vec![7_u8; 1024]; + let upper = sha256_hex(CONFIGURED_SOURCE.as_bytes()).to_ascii_uppercase(); + let short = "a".repeat(63); + let not_hex = "g".repeat(64); + for digest in ["", upper.as_str(), short.as_str(), not_hex.as_str()] { + let http = ScriptedHttp::new(Origin::honest(source.clone()).responder()); + let kv = probe_kv(); + let (identity, envelope) = large_envelope_bound(&routes, &source, digest); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &envelope, + ); + assert_eq!( + refused_with(response, StatusCode::UNPROCESSABLE_ENTITY), + "origin_identity_invalid", + "{digest:?}" + ); + assert!( + http.seen().is_empty(), + "{digest:?}: should neither read the source nor ask the API" + ); + assert!( + kv.get(&result_key(&identity)).is_none(), + "{digest:?}: nothing stored" + ); + } +} + +/// A large source whose XMP names a remote manifest keeps its provenance: +/// the publication inspects the pinned version itself, whatever the +/// worker's disclosure showed, and refuses before it reads the version in +/// full or asks the API anything. +#[test] +fn large_publication_keeps_the_provenance_the_pinned_version_carries() { + let routes = routes(); + let length = usize::try_from(MAX_RANGE_BYTES).expect("should fit") + 10; + let fill = length - png_with(&[]).len() - 12 - (12 + xmp_itxt(PROVENANCE_XMP).len()); + let xmp = xmp_itxt(PROVENANCE_XMP); + let filler = vec![7_u8; fill]; + let source = png_with(&[(b"iTXt", xmp.as_slice()), (b"prVt", filler.as_slice())]); + assert_eq!(source.len(), length); + let http = ScriptedHttp::new(Origin::honest(source.clone()).responder()); + let kv = probe_kv(); + let (identity, genuine) = large_envelope(&routes, &source); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &genuine, + ); + assert_eq!( + refused_with(response, StatusCode::UNPROCESSABLE_ENTITY), + "source_has_provenance_reference", + "the source's own provenance should be kept" + ); + assert!(http.api_calls().is_empty(), "the API should never be asked"); + assert!( + pinned_ranges(&http.seen()) + .iter() + .all(|range| within_block(range)), + "the version should never be read in full" + ); + assert!(kv.get(&result_key(&identity)).is_none(), "nothing stored"); +} + +// --- snapshots ------------------------------------------------------------ + +fn snapshot_request(path: &str, variant: &str, nonce: &str) -> Vec { + serde_json::to_vec(&SnapshotRequest { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: "test".to_owned(), + target: super::super::protocol::Target { + path: path.to_owned(), + variant: variant.to_owned(), + }, + nonce: nonce.to_owned(), + read: SnapshotMode::Whole, + }) + .expect("should encode the snapshot request") +} + +const PAGE: &str = "

"; + +fn page_origin(facts: Option, secret: &'static str) -> Arc { + ScriptedHttp::new(move |seen| { + let mut headers = vec![ + ("content-type", "text/html; charset=utf-8".to_owned()), + ("etag", "\"p1\"".to_owned()), + ("content-length", PAGE.len().to_string()), + ]; + if let (Some(facts), Some(capability)) = (&facts, seen.header(CAPABILITY_HEADER)) { + let nonce = capability.split('.').nth(2).expect("should carry a nonce"); + headers.push((FACTS_HEADER, facts_header(secret, nonce, facts))); + } + answer(200, PAGE.as_bytes().to_vec(), &headers) + }) +} + +fn public_page_facts() -> SnapshotOrigin { + SnapshotOrigin { + status: 200, + origin_host: HOST.to_owned(), + ..SnapshotOrigin::default() + } +} + +#[test] +fn a_snapshot_reads_one_configured_target_through_the_reader_pipeline() { + let routes = routes(); + let http = page_origin(Some(public_page_facts()), PUBLICATION); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + + let response = call( + &routes, + &services, + Method::POST, + SNAPSHOT_PATH, + Some(PUBLICATION), + snapshot_request("/news/a", "edition=uk", NONCE), + ); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, no-store"), + "the snapshot's own transport should be private" + ); + let meta = response + .headers() + .get(SNAPSHOT_META_HEADER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| STANDARD.decode(value).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .expect("should report the snapshot's metadata"); + assert_eq!(meta.page_url, "https://publisher.example.com/news/a"); + assert_eq!( + meta.origin, + public_page_facts(), + "should report the origin's own facts" + ); + assert_eq!(meta.etag.as_deref(), Some("\"p1\"")); + assert_eq!(body_of(response), PAGE.as_bytes()); + + let seen = http.seen(); + assert_eq!(seen.len(), 1, "should read once, through the loopback"); + let read = &seen[0]; + assert_eq!(read.uri, "https://publisher.example.com/news/a?edition=uk"); + assert_eq!(read.header("accept-encoding"), Some("identity")); + assert!(read.header("cookie").is_none() && read.header("authorization").is_none()); + assert_eq!( + verify_snapshot_capability( + PUBLICATION, + "/news/a?edition=uk", + read.header(CAPABILITY_HEADER) + .expect("should carry a capability"), + unix_now() + ) + .as_deref(), + Some(NONCE), + "the capability should name exactly this target and the worker's nonce" + ); +} + +#[test] +fn snapshots_admit_only_the_requested_range_or_an_exact_whole_response() { + let cases: &[(u16, u64, u64, &[&str], bool)] = &[ + (206, 8, 15, &["bytes 8-15/16"], true), + (206, 8, 15, &["bytes 0-7/16"], false), + (206, 8, 15, &["bytes 8-15/16", "bytes 8-15/16"], false), + (206, 8, 15, &["bytes 8-15/*"], false), + (200, 8, 15, &[], false), + (200, 0, 15, &[], true), + (206, 0, 15, &[], false), + ]; + for &(status, first, last, ranges, admitted) in cases { + let ranges: Vec = ranges.iter().map(|value| (*value).to_owned()).collect(); + let expected = b"abcdefghijklmnop"[first as usize..=last as usize].to_vec(); + let returned = expected.clone(); + let http = ScriptedHttp::new(move |seen| { + let capability = seen + .header(CAPABILITY_HEADER) + .expect("should authenticate the read"); + let nonce = capability + .split('.') + .nth(2) + .expect("should carry the nonce"); + let facts = SnapshotOrigin { + status, + origin_host: HOST.to_owned(), + ..SnapshotOrigin::default() + }; + let mut response = answer( + status, + returned.clone(), + &[ + ("etag", ETAG.to_owned()), + ("content-type", "video/mp4".to_owned()), + ("content-length", returned.len().to_string()), + (FACTS_HEADER, facts_header(PUBLICATION, nonce, &facts)), + ], + ); + for range in &ranges { + response.headers_mut().append( + header::CONTENT_RANGE, + HeaderValue::from_str(range).expect("should be a header value"), + ); + } + response + }); + let routes = routes(); + let kv = Arc::new(MemoryKv::default()); + let services = services(&http, &kv); + let request = SnapshotRequest { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: "test".to_owned(), + target: super::super::protocol::Target { + path: "/media/ferry.mp4".to_owned(), + variant: String::new(), + }, + nonce: NONCE.to_owned(), + read: SnapshotMode::Range { + first, + last, + if_match: ETAG.to_owned(), + }, + }; + let response = call( + &routes, + &services, + Method::POST, + SNAPSHOT_PATH, + Some(PUBLICATION), + serde_json::to_vec(&request).expect("should encode the range request"), + ); + if admitted { + assert_eq!(response.status(), StatusCode::OK); + let meta: SnapshotMeta = serde_json::from_slice( + &STANDARD + .decode(response.headers()[SNAPSHOT_META_HEADER].as_bytes()) + .expect("should encode metadata"), + ) + .expect("should report source metadata"); + assert_eq!(meta.length, Some(16)); + assert_eq!(body_of(response), expected); + } else { + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert!(response.headers().get(SNAPSHOT_META_HEADER).is_none()); + } + } +} + +#[test] +fn a_snapshot_refuses_unconfigured_targets_before_reading_and_unadmitted_answers_after() { + let routes = routes(); + for (path, variant, nonce, status, code) in [ + ( + "/private/a", + "", + NONCE, + StatusCode::FORBIDDEN, + "target_not_configured", + ), + ( + "/news/a", + "edition=fr", + NONCE, + StatusCode::FORBIDDEN, + "target_not_configured", + ), + ( + "/news/a", + "utm_source=x", + NONCE, + StatusCode::FORBIDDEN, + "target_not_configured", + ), + ( + "/news/../private", + "", + NONCE, + StatusCode::FORBIDDEN, + "target_not_configured", + ), + ( + "/news/a", + "", + "short", + StatusCode::BAD_REQUEST, + "nonce_invalid", + ), + ] { + let http = ScriptedHttp::refusing(); + let kv = Arc::new(MemoryKv::default()); + let response = call( + &routes, + &services(&http, &kv), + Method::POST, + SNAPSHOT_PATH, + Some(PUBLICATION), + snapshot_request(path, variant, nonce), + ); + assert_eq!(refused_with(response, status), code, "{path}?{variant}"); + assert!(http.seen().is_empty(), "{path}: nothing should be read"); + } + + let private = SnapshotOrigin { + private: true, + ..public_page_facts() + }; + let linked = SnapshotOrigin { + manifest_link: true, + ..public_page_facts() + }; + for (http, status, code) in [ + ( + page_origin(None, PUBLICATION), + StatusCode::UNPROCESSABLE_ENTITY, + "snapshot_unobserved", + ), + ( + page_origin(Some(public_page_facts()), "an-origin-guessing-the-secret"), + StatusCode::UNPROCESSABLE_ENTITY, + "snapshot_unobserved", + ), + ( + page_origin(Some(private), PUBLICATION), + StatusCode::FORBIDDEN, + "source_private", + ), + ( + page_origin(Some(linked), PUBLICATION), + StatusCode::UNPROCESSABLE_ENTITY, + "source_has_provenance_link", + ), + ] { + let kv = Arc::new(MemoryKv::default()); + let response = call( + &routes, + &services(&http, &kv), + Method::POST, + SNAPSHOT_PATH, + Some(PUBLICATION), + snapshot_request("/news/a", "", NONCE), + ); + assert_eq!(refused_with(response, status), code); + } + + let http = ScriptedHttp::refusing(); + let kv = Arc::new(MemoryKv::default()); + let signal = call( + &routes, + &services(&http, &kv), + Method::POST, + SNAPSHOT_PATH, + Some(SIGNAL), + snapshot_request("/news/a", "", NONCE), + ); + assert_eq!( + refused_with(signal, StatusCode::UNAUTHORIZED), + "unauthorized", + "the reader-signal secret should not read sources" + ); + assert!(http.seen().is_empty()); +} + +// --- public source snapshots ---------------------------------------------- + +#[test] +fn a_public_source_is_served_only_as_the_exact_bytes_its_digest_names() { + let routes = routes(); + let sha = sha256_hex(SOURCE); + let http = ScriptedHttp::new(|_| { + answer( + 200, + SOURCE.to_vec(), + &[("content-type", "image/png".to_owned())], + ) + }); + let kv = Arc::new(MemoryKv::default()); + let response = call( + &routes, + &services(&http, &kv), + Method::GET, + &format!("{PUBLIC_SOURCE_PREFIX}{sha}"), + None, + Vec::new(), + ); + assert_eq!(response.status(), StatusCode::OK); + let headers = response.headers().clone(); + assert_eq!( + headers + .get("x-content-type-options") + .map(HeaderValue::as_bytes), + Some(&b"nosniff"[..]) + ); + assert!( + headers + .get(header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("attachment")), + "should never be rendered inline" + ); + assert_eq!( + headers + .get("content-security-policy") + .and_then(|value| value.to_str().ok()), + Some("sandbox; default-src 'none'; base-uri 'none'; form-action 'none'") + ); + assert_eq!(body_of(response), SOURCE); + let seen = http.seen(); + assert_eq!( + seen[0].uri, + format!("https://prep.publisher.example.com{WORKER_SOURCE_PREFIX}{sha}") + ); + assert_eq!(seen[0].header(TOKEN_HEADER), Some(PUBLICATION)); + + let lying = ScriptedHttp::new(|_| { + answer( + 200, + OTHER_SOURCE.to_vec(), + &[("content-type", "image/png".to_owned())], + ) + }); + let response = call( + &routes, + &services(&lying, &kv), + Method::GET, + &format!("{PUBLIC_SOURCE_PREFIX}{sha}"), + None, + Vec::new(), + ); + assert_eq!( + refused_with(response, StatusCode::NOT_FOUND), + "not_found", + "bytes that are not the digest's should never be served" + ); + + let http = ScriptedHttp::refusing(); + let response = call( + &routes, + &services(&http, &kv), + Method::GET, + &format!("{PUBLIC_SOURCE_PREFIX}not-a-digest"), + None, + Vec::new(), + ); + assert_eq!(refused_with(response, StatusCode::NOT_FOUND), "not_found"); + assert!(http.seen().is_empty()); +} + +// --- the request preparer ------------------------------------------------- + +fn settings(execution: &str) -> Settings { + page_settings(&format!( + "[provenance]\nmodule = \"encypher\"\n\n[provenance.encypher]\n\ + api_base_url = \"https://api.example.com\"\ndomain = \"{HOST}\"\n\ + worker_token = \"{TOKEN}\"\nexecution = \"{execution}\"\n\n\ + [provenance.encypher.preparation]\n\ + worker_url = \"https://prep.publisher.example.com\"\n\ + loopback_url = \"https://{HOST}\"\ndeployment_id = \"test\"\n\ + public_host = \"{HOST}\"\nsignal_secret = \"{SIGNAL}\"\n\ + publication_secret = \"{PUBLICATION}\"\nroutes = [\"/news/\"]\n\n\ + [[serve]]\nmedia_type = \"text/html\"\nmiddleware = [\"{MODULE}\"]\n" + )) +} + +fn prepared(settings: &Settings, path_and_query: &str, capabilities: &[&str]) -> Request { + let mut builder = Request::builder() + .method(Method::GET) + .uri(format!("https://{HOST}{path_and_query}")); + for capability in capabilities { + builder = builder.header(CAPABILITY_HEADER, *capability); + } + let mut request = builder.body(EdgeBody::empty()).expect("should build"); + prepare_request(settings, &mut request).expect("should prepare"); + request +} + +fn grant(request: &Request) -> Option { + IntegrationRequestState::of(request) + .get::(INTEGRATION_ID) + .map(|grant| grant.nonce.clone()) +} + +#[test] +fn only_a_verified_capability_for_the_exact_target_leaves_a_snapshot_grant() { + let asynchronous = settings("async_prepared"); + let expires = unix_now() + 60; + let genuine = snapshot_capability(PUBLICATION, "/news/a?edition=uk", NONCE, expires); + let other_path = snapshot_capability(PUBLICATION, "/news/b", NONCE, expires); + let forged = snapshot_capability( + "a-reader-guessing-the-secret", + "/news/a?edition=uk", + NONCE, + expires, + ); + + let request = prepared(&asynchronous, "/news/a?edition=uk", &[&genuine]); + assert_eq!(grant(&request).as_deref(), Some(NONCE)); + assert!( + request.headers().get(CAPABILITY_HEADER).is_none(), + "the capability should never travel on" + ); + assert!( + IntegrationRequestState::of(&request).is_empty(), + "a grant is an observation and keeps the reader's shared path" + ); + + for (name, path, capabilities) in [ + ( + "another target", + "/news/a?edition=uk", + vec![other_path.as_str()], + ), + ( + "another query", + "/news/a?edition=us", + vec![genuine.as_str()], + ), + ( + "a forged secret", + "/news/a?edition=uk", + vec![forged.as_str()], + ), + ( + "two values", + "/news/a?edition=uk", + vec![genuine.as_str(), genuine.as_str()], + ), + ( + "a malformed value", + "/news/a?edition=uk", + vec!["v1.not-a-capability"], + ), + ] { + let request = prepared(&asynchronous, path, &capabilities); + assert!(grant(&request).is_none(), "{name} should leave no grant"); + assert!( + request.headers().get(CAPABILITY_HEADER).is_none(), + "{name}: the header should be removed" + ); + } + + let synchronous = prepared(&settings("synchronous"), "/news/a?edition=uk", &[&genuine]); + assert!( + grant(&synchronous).is_none(), + "the synchronous mode grants nothing" + ); + assert!( + synchronous.headers().get(CAPABILITY_HEADER).is_none(), + "the header is removed whether or not the asynchronous mode runs" + ); +} + +// --- real probe receipts -------------------------------------------------- + +fn fixture(relative: &str) -> Vec { + let bytes: &[u8] = match relative { + "probe.wasm" => include_bytes!("../../fixtures/prepared/probe.wasm"), + "jpeg/envelope.json" => include_bytes!("../../fixtures/prepared/jpeg/envelope.json"), + "jpeg/recover.json" => include_bytes!("../../fixtures/prepared/jpeg/recover.json"), + "png/envelope.json" => include_bytes!("../../fixtures/prepared/png/envelope.json"), + "png/recover.json" => include_bytes!("../../fixtures/prepared/png/recover.json"), + "webp/envelope.json" => include_bytes!("../../fixtures/prepared/webp/envelope.json"), + "webp/recover.json" => include_bytes!("../../fixtures/prepared/webp/recover.json"), + "svg/envelope.json" => include_bytes!("../../fixtures/prepared/svg/envelope.json"), + "svg/recover.json" => include_bytes!("../../fixtures/prepared/svg/recover.json"), + "external-png/envelope.json" => { + include_bytes!("../../fixtures/prepared/external-png/envelope.json") + } + "external-png/recover.json" => { + include_bytes!("../../fixtures/prepared/external-png/recover.json") + } + _ => panic!("unknown prepared fixture: {relative}"), + }; + bytes.to_vec() +} + +/// The routes of a deployment claiming the host `envelope` names, with its +/// identity and deployment rewritten to that deployment's own. +fn deployment_for(envelope: &mut ResultEnvelope) -> PreparationRoutes { + let planned = match &envelope.item { + EnvelopeItem::Media { planned, .. } | EnvelopeItem::Embedded { planned, .. } => planned, + _ => panic!("should be a bounded media case"), + }; + let page = url::Url::parse(&planned.request.page_url).expect("should be a URL"); + let host = page.host_str().expect("should name a host").to_owned(); + let domain = host.strip_prefix("www.").unwrap_or(&host).to_owned(); + let routes = routes_for(config_value(&domain, &host, &["/"])); + envelope.deployment_id = "test".to_owned(); + envelope.identity = media_identity(&routes, &planned.request, &envelope.variant); + routes +} + +/// A transport answering as the candidate API answered the worker. +fn candidate_api(recover: Vec, probe: Vec) -> Arc { + ScriptedHttp::new(move |seen| { + if seen.uri.ends_with("/api/v1/public/cdn/media/hash/recover") { + answer( + 200, + recover.clone(), + &[("content-type", "application/json".to_owned())], + ) + } else if seen.uri.contains("/api/v1/sign/media/probe/") { + answer(200, probe.clone(), &[]) + } else { + answer(500, Vec::new(), &[]) + } + }) +} + +fn case_envelope(case: &str) -> ResultEnvelope { + serde_json::from_slice(&fixture(&format!("{case}/envelope.json"))) + .expect("should be a result envelope") +} + +/// Publishes `envelope` against `case`'s recorded receipt, returning the +/// answer and whether a record was stored. +fn publish_against(case: &str, mut envelope: ResultEnvelope) -> (Response, bool) { + let routes = deployment_for(&mut envelope); + let http = candidate_api( + fixture(&format!("{case}/recover.json")), + fixture("probe.wasm"), + ); + let kv = Arc::new(MemoryKv::default()); + let identity = envelope.identity.clone(); + let response = publish( + &routes, + &services(&http, &kv), + PUBLICATION, + &identity, + &envelope, + ); + assert!( + http.seen() + .iter() + .all(|seen| !seen.uri.ends_with("/hash/finalize") + && !seen.uri.ends_with("/hash/prepare") + && !seen.uri.ends_with("/cdn/sign")), + "{case}: publication should never prepare, finalize or sign" + ); + let stored = kv.get(&result_key(&identity)).is_some(); + (response, stored) +} + +fn flip_last(b64: &str) -> String { + let mut bytes = STANDARD.decode(b64).expect("should be base64"); + *bytes.last_mut().expect("should have bytes") ^= 1; + STANDARD.encode(bytes) +} + +/// Rewrites an embedded envelope's source to `source`, keeping the request +/// self-consistent with it, as a forging worker would. +fn with_source(mut envelope: ResultEnvelope, source: &[u8]) -> ResultEnvelope { + if let EnvelopeItem::Embedded { + planned, + source_b64, + .. + } = &mut envelope.item + { + planned.request.source.sha256 = sha256_hex(source); + planned.request.source.byte_length = u64::try_from(source.len()).expect("should fit"); + *source_b64 = STANDARD.encode(source); + } + envelope +} + +fn embedded_parts(envelope: &ResultEnvelope) -> (Vec, String) { + match &envelope.item { + EnvelopeItem::Embedded { + source_b64, + output_b64, + .. + } => ( + STANDARD.decode(source_b64).expect("should be base64"), + output_b64.clone(), + ), + _ => panic!("should be an embedded case"), + } +} + +/// The real probe, run on candidate-API receipts for JPEG, PNG, WebP and +/// inline SVG: the genuine output publishes, and every forgery of output or +/// source, however self-consistent, is refused before anything is stored. +#[test] +fn real_probe_receipts_publish_only_the_exact_output() { + let cases = ["jpeg", "png", "webp", "svg"]; + for case in cases { + let genuine = case_envelope(case); + let (response, stored) = publish_against(case, genuine.clone()); + assert_eq!( + response.status(), + StatusCode::OK, + "{case}: should publish: {}", + String::from_utf8_lossy(&body_of(response)) + ); + assert!(stored, "{case}: should store the verified record"); + + // One byte of the output changed. + let mut tampered = genuine.clone(); + if let EnvelopeItem::Embedded { output_b64, .. } = &mut tampered.item { + *output_b64 = flip_last(output_b64); + } + let (response, stored) = publish_against(case, tampered); + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{case}: a one-byte output change should be refused" + ); + assert!(!stored, "{case}: nothing stored for a tampered output"); + + // Another case's genuine output substituted. + let donor = cases + .iter() + .find(|other| **other != case) + .expect("should have another case"); + let (_, donor_output) = embedded_parts(&case_envelope(donor)); + let mut substituted = genuine.clone(); + if let EnvelopeItem::Embedded { output_b64, .. } = &mut substituted.item { + *output_b64 = donor_output; + } + let (response, stored) = publish_against(case, substituted); + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{case}: a substituted output should be refused" + ); + assert!(!stored); + + // Changed pixels or markup with a self-consistent request: the + // receipt is genuine, the source is not the one it signed. + let (mut source, _) = embedded_parts(&genuine); + let middle = source.len() / 2; + source[middle] ^= 1; + let (response, stored) = publish_against(case, with_source(genuine.clone(), &source)); + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{case}: a changed source should be refused by the probe's checks" + ); + assert!(!stored); + } +} + +#[test] +fn real_probe_receipt_refuses_tampered_external_source() { + let genuine = case_envelope("external-png"); + let (response, stored) = publish_against("external-png", genuine.clone()); + assert_eq!( + response.status(), + StatusCode::OK, + "external: should publish: {}", + String::from_utf8_lossy(&body_of(response)) + ); + assert!(stored); + let mut tampered = genuine; + if let EnvelopeItem::Media { source_b64, .. } = &mut tampered.item { + *source_b64 = flip_last(source_b64); + } + let (response, stored) = publish_against("external-png", tampered); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert!(!stored); +} + +/// A bounded source whose XMP names a remote manifest keeps its +/// provenance: an external manifest delivered over it would never be read. +/// The receipt is the fixture's own, for the source without that packet. No +/// genuine receipt for a provenance-bearing source exists, so the refusal +/// must come from the source itself, before the receipt is checked. +#[test] +fn real_probe_external_publication_keeps_the_source_provenance() { + let xmp = xmp_itxt(PROVENANCE_XMP); + let source = png_with(&[(b"iTXt", xmp.as_slice())]); + let mut envelope = case_envelope("external-png"); + let EnvelopeItem::Media { + planned, + source_b64, + } = &mut envelope.item + else { + panic!("should be an external case"); + }; + planned.request.source.sha256 = sha256_hex(&source); + planned.request.source.byte_length = u64::try_from(source.len()).expect("should fit"); + planned.digest = planned.request.source.sha256.clone(); + *source_b64 = STANDARD.encode(&source); + let (response, stored) = publish_against("external-png", envelope); + assert_eq!( + refused_with(response, StatusCode::UNPROCESSABLE_ENTITY), + "source_has_provenance_reference", + "the source's own provenance should be kept" + ); + assert!(!stored, "nothing stored"); +} + +// --- validators ----------------------------------------------------------- + +#[test] +fn only_a_single_strong_validator_pins_a_source() { + let mut headers = http::HeaderMap::new(); + headers.insert(header::ETAG, HeaderValue::from_static("\"v1\"")); + assert_eq!(single_strong_etag(&headers).as_deref(), Some("\"v1\"")); + headers.append(header::ETAG, HeaderValue::from_static("\"v2\"")); + assert!( + single_strong_etag(&headers).is_none(), + "two tags name no single version" + ); + let mut weak = http::HeaderMap::new(); + weak.insert(header::ETAG, HeaderValue::from_static("W/\"v1\"")); + assert!( + single_strong_etag(&weak).is_none(), + "a weak tag pins nothing" + ); +} diff --git a/crates/provenance/encypher/src/preparation/items.rs b/crates/provenance/encypher/src/preparation/items.rs new file mode 100644 index 0000000000..c653e23a9f --- /dev/null +++ b/crates/provenance/encypher/src/preparation/items.rs @@ -0,0 +1,381 @@ +//! The items of one delivered document, with their preparation identities. +//! +//! The reader computes these from the bytes it is about to deliver and the +//! worker from the snapshot the delivery instance gave it, through the same +//! functions, so a stable document yields the same identities on both. +//! Text comes from the one article extraction every path uses; embedded +//! media from the one scan of `data:` URIs and inline SVG that decides +//! which ranges may be edited. + +use std::borrow::Cow; + +use super::pipeline::{TextCacheScope, TextSignRequest}; +use super::protocol::{Carrier, MediaIdentity, TextIdentity}; +use crate::data_uri::DataUri; +use crate::embedded; +use crate::media_api::sha256_hex; +use crate::processor::{Article, Reason, extract_article}; +use crate::svg_equivalence; +use crate::text; + +/// What a document is, which decides what in it is an item. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocumentKind { + /// An HTML page: its article's text and its embedded media. + Page, + /// A stylesheet: the media its `url()`s embed. + Stylesheet, +} + +/// What every identity of one document binds besides the item itself. +#[derive(Debug, Clone, Copy)] +pub struct ItemContext<'a> { + /// See [`super::protocol::credential_context`]. + pub credential_context: &'a str, + /// The policy the items are prepared under. + pub policy_digest: &'a str, + /// The query-free address the document is delivered on. + pub page_url: &'a str, + /// The admitted variant. + pub variant: &'a str, + /// The configured article selector override, empty for none. + pub article_selector: &'a str, + /// The least visible text an article is signed with. + pub min_text_length: usize, + /// The largest embedded media item. + pub max_media_bytes: usize, +} + +/// An article's text item. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TextItem { + /// Its identity. + pub identity: String, + /// The exact sign request it is prepared with. + pub request: TextSignRequest, + /// The scalars of its text. + pub scalars: usize, +} + +/// Where an embedded item sits in its document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Site { + /// A media `data:` URI, its exact byte range. + DataUri { + /// The first byte. + start: usize, + /// One past the last byte. + end: usize, + }, + /// An outermost inline ``, its exact byte range. + InlineSvg { + /// The first byte. + start: usize, + /// One past the last byte. + end: usize, + }, +} + +impl Site { + fn range(self) -> (usize, usize) { + match self { + Self::DataUri { start, end } | Self::InlineSvg { start, end } => (start, end), + } + } +} + +/// One embedded media item. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmbeddedItem { + /// Its identity. + pub identity: String, + /// Where it sits. + pub site: Site, + /// Its canonical media type. + pub mime: String, + /// Its exact source bytes: the decoded payload, or the standalone SVG + /// document proven equivalent to the browser's tree. + pub source: Vec, + /// SHA-256 of `source`. + pub sha256: String, +} + +/// The items of one document. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DocumentItems { + /// The article, where the page has one. + pub text: Option, + /// Why the page's article is not an item, where it is not. + pub text_refusal: Option<&'static str>, + /// Embedded media, in document order, never overlapping. + pub embedded: Vec, + /// Embedded media left as written, each with why. + pub refused: Vec<&'static str>, +} + +/// The text item of `article` in `html`. +#[must_use] +pub(crate) fn text_item(context: &ItemContext<'_>, html: &str, article: &Article) -> TextItem { + let title = text::title_from_range(html, article.title_range); + let identity = TextIdentity { + credential_context: context.credential_context, + policy_digest: context.policy_digest, + page_url: context.page_url, + variant: context.variant, + text: &article.text, + document_title: title.as_deref(), + boundary_selector: &article.boundary.selector, + } + .digest(); + TextItem { + identity, + request: TextSignRequest { + text: article.text.clone(), + page_url: context.page_url.to_owned(), + document_title: title, + boundary_selector: article.boundary.selector.clone(), + expected_policy_digest: context.policy_digest.to_owned(), + cache_scope: TextCacheScope::ExactRequest, + }, + scalars: article.scalars, + } +} + +/// The identity of an embedded item with `mime` and `sha256` in a document +/// of `context`. +#[must_use] +pub fn embedded_identity(context: &ItemContext<'_>, mime: &str, sha256: &str) -> String { + MediaIdentity { + credential_context: context.credential_context, + policy_digest: context.policy_digest, + page_url: context.page_url, + variant: context.variant, + carrier: Carrier::Embedded, + mime, + source_sha256: sha256, + } + .digest() +} + +/// The embedded media items of `document`. An inline SVG is one item, and +/// a `data:` URI inside an inline SVG item is carried by it rather than +/// prepared on its own, so no two items overlap and an SVG's identity is +/// that of the markup the browser parsed. +#[must_use] +pub fn embedded_items( + context: &ItemContext<'_>, + document: &str, + kind: DocumentKind, + policies: Option<&[String]>, +) -> (Vec, Vec<&'static str>) { + let scan = match kind { + DocumentKind::Page => embedded::scan_html(document, policies), + DocumentKind::Stylesheet => embedded::scan_stylesheet(document), + }; + let mut refused: Vec<&'static str> = scan.refused.iter().map(|reason| reason.code()).collect(); + let mut items = Vec::new(); + let mut svg_ranges = Vec::new(); + if kind == DocumentKind::Page { + for (start, end) in scan.svgs.iter().copied() { + let Some(fragment) = document.get(start..end) else { + continue; + }; + match svg_equivalence::signable_svg(fragment, context.max_media_bytes) { + Ok(svg) => { + let source = match svg { + Cow::Borrowed(text) => text.as_bytes().to_vec(), + Cow::Owned(text) => text.into_bytes(), + }; + let sha256 = sha256_hex(&source); + svg_ranges.push((start, end)); + items.push(EmbeddedItem { + identity: embedded_identity(context, "image/svg+xml", &sha256), + site: Site::InlineSvg { start, end }, + mime: "image/svg+xml".to_owned(), + source, + sha256, + }); + } + Err(reason) => refused.push(reason.code()), + } + } + } + for found in &scan.found { + if svg_ranges + .iter() + .any(|(start, end)| found.start < *end && *start < found.end) + { + refused.push("inside_inline_svg"); + continue; + } + let Some(uri) = document + .get(found.start..found.end) + .and_then(DataUri::parse) + else { + continue; + }; + let Ok(source) = uri.decode() else { + refused.push(Reason::MalformedDataUri.code()); + continue; + }; + if source.len() > context.max_media_bytes { + refused.push(Reason::MediaTooLarge.code()); + continue; + } + let mime = uri.media_type(); + let sha256 = sha256_hex(&source); + items.push(EmbeddedItem { + identity: embedded_identity(context, &mime, &sha256), + site: Site::DataUri { + start: found.start, + end: found.end, + }, + mime, + source, + sha256, + }); + } + items.sort_by_key(|item| item.site.range()); + (items, refused) +} + +/// Every item of `document`: the article's text first, then its embedded +/// media. +#[must_use] +pub fn document_items( + context: &ItemContext<'_>, + document: &str, + kind: DocumentKind, + policies: Option<&[String]>, +) -> DocumentItems { + let (text, text_refusal) = match kind { + DocumentKind::Page => { + match extract_article(document, context.article_selector, context.min_text_length) { + Ok(article) => (Some(text_item(context, document, &article)), None), + Err(reason) => (None, Some(reason.code())), + } + } + DocumentKind::Stylesheet => (None, None), + }; + let (embedded, refused) = embedded_items(context, document, kind, policies); + DocumentItems { + text, + text_refusal, + embedded, + refused, + } +} + +/// The edit that puts an embedded item's output in place of its source in +/// `document`, or `None` where the output does not fit the site: an SVG +/// that is not UTF-8. +#[must_use] +pub fn embedded_edit( + document: &str, + item: &EmbeddedItem, + output: &[u8], +) -> Option<(usize, usize, String)> { + match item.site { + Site::DataUri { start, end } => { + let uri = DataUri::parse(document.get(start..end)?)?; + Some((start, end, uri.encode(output))) + } + Site::InlineSvg { start, end } => { + Some((start, end, String::from_utf8(output.to_vec()).ok()?)) + } + } +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD; + + use super::*; + + const POLICY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn context() -> ItemContext<'static> { + ItemContext { + credential_context: "0123456789abcdef", + policy_digest: POLICY, + page_url: "https://www.publisher.example.com/news/a", + variant: "", + article_selector: "", + min_text_length: 50, + max_media_bytes: 1024 * 1024, + } + } + + fn page(pixel: &[u8]) -> String { + format!( + "Harbor
\ +

Harbor council votes on the ferry schedule

The harbor council met on \ + Tuesday evening and approved a revised ferry schedule for the season.

\ +
", + STANDARD.encode(pixel) + ) + } + + #[test] + fn identical_documents_yield_identical_items_and_one_changed_item_changes_only_its_own() { + let first = document_items(&context(), &page(b"pixel-one"), DocumentKind::Page, None); + let again = document_items(&context(), &page(b"pixel-one"), DocumentKind::Page, None); + assert_eq!( + first, again, + "a stable document should yield the same items" + ); + let text = first.text.as_ref().expect("should find the article"); + assert_eq!(first.embedded.len(), 1, "should find the embedded image"); + + let changed = document_items(&context(), &page(b"pixel-two"), DocumentKind::Page, None); + assert_eq!( + changed.text.as_ref().map(|item| &item.identity), + Some(&text.identity), + "a changed image should leave the article's identity unchanged" + ); + assert_ne!( + changed.embedded[0].identity, first.embedded[0].identity, + "the changed image should take a new identity" + ); + } + + #[test] + fn policy_change_changes_every_identity() { + let document = page(b"pixel-one"); + let first = document_items(&context(), &document, DocumentKind::Page, None); + let other_policy = "b".repeat(64); + let changed = document_items( + &ItemContext { + policy_digest: &other_policy, + ..context() + }, + &document, + DocumentKind::Page, + None, + ); + assert_ne!( + first.text.map(|item| item.identity), + changed.text.map(|item| item.identity), + "a new policy should never reuse a result made under the old one" + ); + assert_ne!(first.embedded[0].identity, changed.embedded[0].identity); + } + + #[test] + fn embedded_edit_replaces_only_the_items_own_range() { + let document = page(b"pixel-one"); + let items = document_items(&context(), &document, DocumentKind::Page, None); + let item = &items.embedded[0]; + let (start, end, text) = + embedded_edit(&document, item, b"signed-pixel").expect("should build the edit"); + assert!( + text.starts_with("data:image/png;base64,"), + "should keep the URI's own type" + ); + let output = + embedded::splice(&document, &[(start, end, text)], usize::MAX).expect("should splice"); + assert!(output.contains(&STANDARD.encode(b"signed-pixel"))); + assert!(!output.contains(&STANDARD.encode(b"pixel-one"))); + } +} diff --git a/crates/provenance/encypher/src/preparation/mod.rs b/crates/provenance/encypher/src/preparation/mod.rs new file mode 100644 index 0000000000..6e599260c0 --- /dev/null +++ b/crates/provenance/encypher/src/preparation/mod.rs @@ -0,0 +1,194 @@ +//! Asynchronous prepared signing: content versions are signed by the +//! publisher's native preparation worker, outside reader delivery, and +//! readers only look up what the delivery instance verified and published. +//! +//! In `execution = "async_prepared"` a reader never signs, prepares, +//! finalizes or uploads. It computes each item's identity from the exact +//! projection or bytes it delivers, looks up a published result under that +//! immutable identity, and on a miss delivers the current version unsigned +//! and hands the worker a bounded hint. A prior version's result is never +//! applied to new bytes, and the mode never falls back to signing in +//! delivery. +//! +//! [`protocol`] holds the identities and messages both sides agree on, +//! [`items`] the one extraction of a document's items both sides run, and +//! [`pipeline`] the prepared-hash steps the worker runs and the delivery +//! instance re-verifies through the authenticated API before it publishes +//! anything. + +pub mod items; +pub mod pipeline; +pub mod protocol; + +pub(crate) mod edge; + +use serde::{Deserialize, Serialize}; +use validator::{Validate, ValidationError}; + +use trusted_server_core::redacted::Redacted; + +pub use crate::api::{EmbeddingPlan, PlanOperation}; +pub use protocol::{VariantParam, VariantSchema}; + +/// How the module signs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Execution { + /// Each reader's delivery signs what it has no cached result for. The + /// existing mode, kept as a deliberate operator choice. + #[default] + Synchronous, + /// The publisher's preparation worker signs; delivery only looks up + /// published results and never signs. + AsyncPrepared, +} + +/// `[provenance.encypher.preparation]`, the asynchronous mode's settings. +#[derive(Debug, Clone, Deserialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct PreparationConfig { + /// The preparation worker's origin, `https`, or `http` on a loopback + /// host for a local deployment. Reader hints and public source reads go + /// here, never with a reader's credentials. + #[validate(custom(function = validate_service_url))] + pub worker_url: String, + /// The origin the delivery instance reads its own snapshots from: its + /// public `https` address, or `http` on a loopback host. + #[validate(custom(function = validate_service_url))] + pub loopback_url: String, + /// The deployment's identifier, which every message carries. + #[validate(custom(function = validate_deployment_id))] + pub deployment_id: String, + /// The host readers are served on and every prepared address names: + /// the claimed domain or its `www.` name. Readers on another host find + /// no published result. + pub public_host: String, + /// The secret-store entry of the reader-signal scope, resolved to the + /// secret as settings load. It authorizes hints and nothing else. + pub signal_secret: Redacted, + /// The secret-store entry of the snapshot and publication scope, + /// resolved as settings load. It authorizes snapshots, result and marker + /// publication and the worker's public source reads. + pub publication_secret: Redacted, + /// Whether readers hint the worker about unprepared items. Needs a + /// platform with a real timer or an after-response lifetime; refused at + /// registration on one with neither. Publication events, warming and + /// reconciliation prepare content either way. + #[serde(default)] + pub reader_trigger: bool, + /// The longest a reader's hint may take on a platform with a timer, in + /// milliseconds. + #[serde(default = "default_admission_ms")] + #[validate(range(min = 10, max = 200))] + pub admission_ms: u64, + /// Path prefixes snapshots may read and hints may name. Nothing else is + /// ever read for preparation. + #[validate(length(min = 1, max = 64), custom(function = validate_routes))] + pub routes: Vec, + /// The bounded variant schema: query parameters and the values that + /// name a variant. A reader query naming anything else is not hinted. + #[serde(default)] + #[validate(custom(function = validate_variants))] + pub variants: VariantSchema, +} + +fn default_admission_ms() -> u64 { + 50 +} + +/// Whether `value` is an `https` origin, or `http` on a loopback host, with +/// no path, query, fragment or user information. +fn service_url_ok(value: &str) -> bool { + let Ok(parsed) = url::Url::parse(value) else { + return false; + }; + let loopback = matches!(parsed.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")); + let scheme_ok = parsed.scheme() == "https" || (parsed.scheme() == "http" && loopback); + scheme_ok + && parsed.host_str().is_some() + && parsed.username().is_empty() + && parsed.password().is_none() + && parsed.query().is_none() + && parsed.fragment().is_none() + && matches!(parsed.path(), "" | "/") +} + +fn validate_service_url(value: &str) -> Result<(), ValidationError> { + if service_url_ok(value) { + Ok(()) + } else { + Err(ValidationError::new("preparation_url")) + } +} + +fn validate_deployment_id(value: &str) -> Result<(), ValidationError> { + let ok = !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'); + if ok { + Ok(()) + } else { + Err(ValidationError::new("deployment_id")) + } +} + +fn validate_routes(routes: &[String]) -> Result<(), ValidationError> { + let ok = routes.iter().all(|route| { + route.starts_with('/') + && route.len() <= protocol::MAX_TARGET_PATH_BYTES + && route + .bytes() + .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'?' | b'#')) + }); + if ok { + Ok(()) + } else { + Err(ValidationError::new("preparation_routes")) + } +} + +fn validate_variants(variants: &VariantSchema) -> Result<(), ValidationError> { + if variants.is_bounded() { + Ok(()) + } else { + Err(ValidationError::new("preparation_variants")) + } +} + +impl PreparationConfig { + /// Whether `path` lies under a configured route. + #[must_use] + pub fn admits_path(&self, path: &str) -> bool { + self.routes + .iter() + .any(|route| path.starts_with(route.as_str())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_urls_are_https_or_loopback_http_origins_only() { + for good in [ + "https://prep.example.com", + "https://prep.example.com/", + "http://127.0.0.1:8181", + "http://localhost:8181", + ] { + assert!(service_url_ok(good), "should admit {good}"); + } + for bad in [ + "http://prep.example.com", + "https://user:pw@prep.example.com", + "https://prep.example.com/path", + "https://prep.example.com/?q=1", + "ftp://prep.example.com", + ] { + assert!(!service_url_ok(bad), "should refuse {bad}"); + } + } +} diff --git a/crates/provenance/encypher/src/preparation/pipeline.rs b/crates/provenance/encypher/src/preparation/pipeline.rs new file mode 100644 index 0000000000..39e699b35e --- /dev/null +++ b/crates/provenance/encypher/src/preparation/pipeline.rs @@ -0,0 +1,2410 @@ +//! The prepared-signing steps the worker runs and the delivery instance +//! re-verifies, over any HTTP transport and any source reader. +//! +//! Nothing here uploads source bytes. A media item is planned from its +//! structure disclosure, hashed locally by the hash program the signed +//! probe verified, and finalized by digest. An external manifest binds the +//! whole unchanged source; an embedded one is carried in output bytes +//! assembled here from the verified edits and the locally built carrier, +//! then walked by the probe's `verify_output` so its binding reproduces the +//! digest the service signed. Every refusal is a [`Failure`] with a class +//! the worker damps by. +//! +//! The delivery instance never trusts what the worker says it signed. It +//! recovers the API's exact completed receipt with +//! [`recover_media`] or [`recover_text`], and checks it against the source +//! bytes and output with [`verify_external_receipt`] and +//! [`verify_embedded_receipt`]. + +use std::convert::Infallible; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use http::Method; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::protocol::{Carrier, PublishedResult}; +use crate::api::{self, EmbeddingPlan}; +use crate::embedded; +use crate::media_api::{hex_lower, sha256_hex}; +use crate::prepared_probe::{ + ProbeCallError, ProbeDescriptor, ProbeLimits, ProbeModule, ProbeSource, ReleaseAuthority, +}; + +/// The prepared protocol every prepared request carries. +pub const PREPARED_PROTOCOL: &str = "encypher-prepared-signing/2"; +/// The worker contract version this pipeline speaks. +pub const CONTRACT_VERSION: u8 = 2; + +const POLICY_PATH: &str = "/api/v1/public/cdn/trusted-server/policy"; +const SIGN_PATH: &str = "/api/v1/public/cdn/sign"; +const SIGN_RECOVER_PATH: &str = "/api/v1/public/cdn/sign/recover"; +const CAPABILITIES_PATH: &str = "/api/v1/public/cdn/media/hash/capabilities"; +const PREPARE_PATH: &str = "/api/v1/public/cdn/media/hash/prepare"; +const FINALIZE_PATH: &str = "/api/v1/public/cdn/media/hash/finalize"; +const MEDIA_RECOVER_PATH: &str = "/api/v1/public/cdn/media/hash/recover"; +const PROBE_PATH: &str = "/api/v1/"; + +const MAX_POLICY_BYTES: usize = 1024; +const MAX_CAPABILITIES_BYTES: usize = 64 * 1024; +const MAX_PREPARE_BYTES: usize = 1024 * 1024; +const MAX_PROBE_BYTES: usize = 4 * 1024 * 1024; +const MAX_FINALIZED_MANIFEST_BYTES: usize = 2 * 1024 * 1024; +const FINALIZE_OVERHEAD_BYTES: usize = 64 * 1024; +/// The contract ceiling on one `insert_bytes` payload. +const MAX_INSERT_BYTES: usize = 65_536; +/// The contract ceilings on one structure disclosure: its chunks and their +/// decoded bytes. +const MAX_STRUCTURE_CHUNKS: usize = 65_536 + 8; +const MAX_STRUCTURE_BYTES: usize = 32 * 1024 * 1024; +/// The most an embedded output may exceed its source by: the largest +/// manifest store plus as much again for its carrier framing and the +/// plan's inserted bytes. A plan over it is refused before anything is +/// allocated or finalized, and a worker reserves at most this much beyond +/// the source for an output. +pub const MAX_OUTPUT_GROWTH_BYTES: u64 = 2 * MAX_FINALIZED_MANIFEST_BYTES as u64; + +/// The largest output an embedded plan over a source of `source_length` +/// bytes may name. +#[must_use] +pub fn output_ceiling(source_length: u64) -> u64 { + source_length.saturating_add(MAX_OUTPUT_GROWTH_BYTES) +} + +/// How a failure is damped by the worker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailureClass { + /// May succeed on a later attempt: transport, rate limit, server error. + Transient, + /// The policy changed under the operation; a new identity follows. + PolicyChanged, + /// A binding, plan, carrier, store or output check failed. + Integrity, + /// The source is one reader's, or not admitted as public. + Privacy, + /// The format, carrier or contract is not supported by the engine. + Unsupported, + /// The source carries provenance of its own, which is kept. + ExistingProvenance, + /// The API holds no completed result for the operation. + Missing, +} + +/// Why a step did not complete. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +#[display("{code} ({class:?})")] +pub struct Failure { + /// A short stable code. + pub code: String, + /// How the worker damps it. + pub class: FailureClass, +} + +impl core::error::Error for Failure {} + +impl Failure { + /// A failure of `class` with `code`. + #[must_use] + pub fn new(class: FailureClass, code: &str) -> Self { + Self { + code: code.to_owned(), + class, + } + } + + fn integrity(code: &str) -> Self { + Self::new(FailureClass::Integrity, code) + } + + fn transient(code: &str) -> Self { + Self::new(FailureClass::Transient, code) + } + + fn unsupported(code: &str) -> Self { + Self::new(FailureClass::Unsupported, code) + } +} + +/// One request to the Encypher API. +#[derive(Debug, Clone)] +pub struct ApiRequest { + /// The method. + pub method: Method, + /// The path and query below the API origin. + pub path_and_query: String, + /// A JSON body, if any. + pub body: Option>, + /// The `Idempotency-Key`, if any. + pub idempotency_key: Option, + /// Whether the worker credential is sent. A probe artifact is fetched + /// without it. + pub credentialed: bool, + /// The most response body bytes read. + pub max_response_bytes: usize, +} + +/// The API's answer: its status and bounded body. +#[derive(Debug, Clone)] +pub struct ApiAnswer { + /// The status. + pub status: u16, + /// The body, at most the request's bound plus one byte. + pub body: Bytes, +} + +/// An HTTP transport to the Encypher API, the edge's platform client on the +/// delivery instance and a native client on the worker. +#[allow(async_fn_in_trait)] +pub trait ApiTransport { + /// Sends `request` once. + /// + /// # Errors + /// + /// A transient [`Failure`] where no answer arrived. + async fn exchange(&self, request: ApiRequest) -> Result; +} + +/// The bytes of one pinned source version, read at offsets. +#[allow(async_fn_in_trait)] +pub trait SourceReader { + /// The version's size. + fn size(&self) -> u64; + + /// Fills `destination` from `offset`. + /// + /// # Errors + /// + /// A [`Failure`] where the read fails or the version changed. + async fn read_exact_at(&self, offset: u64, destination: &mut [u8]) -> Result<(), Failure>; +} + +impl SourceReader for [u8] { + fn size(&self) -> u64 { + u64::try_from(self.len()).unwrap_or(u64::MAX) + } + + async fn read_exact_at(&self, offset: u64, destination: &mut [u8]) -> Result<(), Failure> { + let start = + usize::try_from(offset).map_err(|_| Failure::integrity("read_out_of_bounds"))?; + let end = start + .checked_add(destination.len()) + .filter(|end| *end <= self.len()) + .ok_or_else(|| Failure::integrity("read_out_of_bounds"))?; + destination.copy_from_slice(&self[start..end]); + Ok(()) + } +} + +/// A [`SourceReader`] as the probe reads it. +struct Probed<'a, S: ?Sized>(&'a S); + +impl ProbeSource for Probed<'_, S> { + type Error = Failure; + + fn size(&self) -> u64 { + self.0.size() + } + + async fn read_exact_at(&self, offset: u64, destination: &mut [u8]) -> Result<(), Failure> { + self.0.read_exact_at(offset, destination).await + } +} + +fn probe_failure>(op: &str, error: ProbeCallError) -> Failure { + log::info!("encypher preparation: probe op={op} code={}", error.code()); + match error { + ProbeCallError::Source(error) => error.into(), + ProbeCallError::Refused(refusal) if refusal.integrity => { + Failure::integrity(&refusal.code.to_ascii_lowercase()) + } + ProbeCallError::Refused(refusal) => { + Failure::unsupported(&refusal.code.to_ascii_lowercase()) + } + ProbeCallError::Failed(_) => Failure::transient("probe_failed"), + } +} + +impl From for Failure { + fn from(never: Infallible) -> Self { + match never {} + } +} + +/// The failure an error answer of the API names. +fn answer_failure(status: u16, body: &[u8]) -> Failure { + let code = crate::media_api::error_code(body); + let code = code.as_deref(); + match (status, code) { + (409, Some("TRUSTED_SERVER_POLICY_CHANGED")) => { + Failure::new(FailureClass::PolicyChanged, "policy_changed") + } + (409, Some("PROBE_VERSION_UNSUPPORTED" | "PLAN_STALE")) => { + Failure::transient("probe_release_stale") + } + (409, Some("SOURCE_DIGEST_MISMATCH")) => Failure::integrity("source_digest_mismatch"), + (422, Some("SOURCE_PROVENANCE_INVALID")) => Failure::integrity("source_provenance_invalid"), + (422, Some("SOURCE_MANIFEST_PRESENT")) => Failure::new( + FailureClass::ExistingProvenance, + "source_has_embedded_provenance", + ), + (422, Some("HASH_MODE_UNSUPPORTED")) => Failure::unsupported("format_unsupported"), + (404, _) => Failure::new(FailureClass::Missing, "result_not_retained"), + (410, _) => Failure::integrity("result_retired"), + (403, _) => Failure::new(FailureClass::Privacy, "forbidden"), + (409, _) => Failure::integrity("prepared_conflict"), + (413, _) => Failure::unsupported("payload_too_large"), + (422, _) => Failure::unsupported("unprocessable"), + (429, _) => Failure::transient("rate_limited"), + (500..=599, _) => Failure::transient("server_error"), + _ => Failure::transient("unexpected_status"), + } +} + +async fn exchange_ok( + transport: &T, + request: ApiRequest, +) -> Result { + let limit = request.max_response_bytes; + let answer = transport.exchange(request).await?; + if answer.status != 200 { + return Err(answer_failure(answer.status, &answer.body)); + } + if answer.body.len() > limit { + return Err(Failure::transient("response_too_large")); + } + Ok(answer.body) +} + +async fn post_json( + transport: &T, + path: &str, + body: &impl Serialize, + idempotency_key: &str, + max_response_bytes: usize, +) -> Result { + let body = serde_json::to_vec(body).map_err(|_| Failure::integrity("request_build"))?; + let answer = exchange_ok( + transport, + ApiRequest { + method: Method::POST, + path_and_query: path.to_owned(), + body: Some(body), + idempotency_key: Some(idempotency_key.to_owned()), + credentialed: true, + max_response_bytes, + }, + ) + .await?; + serde_json::from_slice(&answer).map_err(|_| Failure::transient("malformed_response")) +} + +fn path_with_page_url(path: &str, page_url: &str) -> String { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + query.append_pair("page_url", page_url); + format!("{path}?{}", query.finish()) +} + +/// The policy the API holds now for `page_url`. +/// +/// # Errors +/// +/// A [`Failure`] for any answer other than a valid digest. +pub async fn policy_digest( + transport: &T, + page_url: &str, +) -> Result { + let body = exchange_ok( + transport, + ApiRequest { + method: Method::GET, + path_and_query: path_with_page_url(POLICY_PATH, page_url), + body: None, + idempotency_key: None, + credentialed: true, + max_response_bytes: MAX_POLICY_BYTES, + }, + ) + .await?; + api::parse_policy_response(&body).map_err(|_| Failure::transient("malformed_policy")) +} + +/// What the prepared lane signs for a host, as the API answers it. +#[derive(Debug, Clone)] +pub struct Capabilities { + /// The policy the answer was made under. + pub policy_digest: String, + /// Formats signed with an external manifest. + pub formats: Vec, + /// Formats signed with an embedded manifest under contract version 2. + pub embedded_formats: Vec, + /// Worker contract versions the API speaks. + pub contract_versions: Vec, + /// The largest source. + pub total_size_bytes: u64, + /// The largest decoded manifest store. + pub manifest_decoded_bytes: usize, + probes: Vec, +} + +impl Capabilities { + fn parse(body: &[u8]) -> Option { + let value: Value = serde_json::from_slice(body).ok()?; + if value.get("protocol")?.as_str()? != PREPARED_PROTOCOL + || !value.get("enabled")?.as_bool()? + { + return None; + } + let strings = |name: &str| -> Option> { + Some(match value.get(name) { + None => Vec::new(), + Some(list) => list + .as_array()? + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + }) + }; + let maximums = value.get("maximums")?; + Some(Self { + policy_digest: value.get("policy_digest")?.as_str()?.to_owned(), + formats: strings("formats")?, + embedded_formats: strings("embedded_formats")?, + contract_versions: match value.get("contract_versions") { + None => vec![1], + Some(list) => list + .as_array()? + .iter() + .filter_map(Value::as_u64) + .filter_map(|version| u8::try_from(version).ok()) + .collect(), + }, + total_size_bytes: maximums.get("total_size_bytes")?.as_u64()?, + manifest_decoded_bytes: usize::try_from( + maximums.get("manifest_decoded_bytes")?.as_u64()?, + ) + .ok()?, + probes: ProbeDescriptor::parse_list(value.get("probe")), + }) + } + + /// Whether the API signs `mime` with `carrier` under this pipeline's + /// contract version. Decided by what the engine advertises, never by a + /// list of the edge's own. + #[must_use] + pub fn supports(&self, mime: &str, carrier: Carrier) -> bool { + self.contract_versions.contains(&CONTRACT_VERSION) + && match carrier { + Carrier::External => self.formats.iter().any(|format| format == mime), + Carrier::Embedded => self.embedded_formats.iter().any(|format| format == mime), + } + } +} + +/// The prepared lane's capabilities for `page_url`. +/// +/// # Errors +/// +/// A [`Failure`] for any answer other than enabled capabilities. +pub async fn capabilities( + transport: &T, + page_url: &str, +) -> Result { + let body = exchange_ok( + transport, + ApiRequest { + method: Method::GET, + path_and_query: path_with_page_url(CAPABILITIES_PATH, page_url), + body: None, + idempotency_key: None, + credentialed: true, + max_response_bytes: MAX_CAPABILITIES_BYTES, + }, + ) + .await?; + Capabilities::parse(&body).ok_or_else(|| Failure::unsupported("capabilities_unavailable")) +} + +/// A signed probe release descriptor, as a reconstruction record keeps it so +/// recovery runs exactly the release a plan was verified with. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeRelease { + /// The probe ABI. + pub abi: u64, + /// The planner. + pub planner_id: String, + /// The release number. + pub release: u64, + /// The artifact SHA-256. + pub sha256: String, + /// The release key's signature over the other fields. + pub signature: String, +} + +impl ProbeRelease { + fn descriptor(&self) -> ProbeDescriptor { + ProbeDescriptor { + abi: self.abi, + planner_id: self.planner_id.clone(), + release: self.release, + sha256: self.sha256.clone(), + signature: self.signature.clone(), + } + } +} + +/// A verified, compiled probe release. +#[derive(Debug)] +pub struct LoadedProbe(ProbeModule); + +impl LoadedProbe { + /// The release's artifact SHA-256 and planner, which reconstruction + /// records bind. + #[must_use] + pub fn identity(&self) -> (&str, &str) { + let descriptor = self.0.descriptor(); + (&descriptor.sha256, &descriptor.planner_id) + } + + /// The signed descriptor of the release, which a reconstruction record + /// keeps. + #[must_use] + pub fn release(&self) -> ProbeRelease { + let descriptor = self.0.descriptor(); + ProbeRelease { + abi: descriptor.abi, + planner_id: descriptor.planner_id.clone(), + release: descriptor.release, + sha256: descriptor.sha256.clone(), + signature: descriptor.signature.clone(), + } + } +} + +/// Where verified probe artifacts are kept between loads. +#[allow(async_fn_in_trait)] +pub trait ProbeArtifactCache { + /// The artifact with SHA-256 `sha256`, if kept. + async fn get(&self, sha256: &str) -> Option; + /// Keeps `artifact`, whose SHA-256 is `sha256`. + /// + /// # Errors + /// + /// A [`Failure`] where the artifact may not be kept, such as a store + /// whose byte quota refuses it. The load then fails before the release + /// plans, finalizes or reconstructs anything, so nothing comes to + /// depend on an artifact that was never retained. + async fn put(&self, sha256: &str, artifact: Bytes) -> Result<(), Failure>; +} + +/// The authenticated advertised artifact and release, without loading its WASM. +/// +/// # Errors +/// +/// A [`Failure`] when the newest compatible descriptor fails production trust. +pub fn advertised_release_identity(capabilities: &Capabilities) -> Result<(&str, u64), Failure> { + let authority = ReleaseAuthority::production(); + let descriptor = authority + .select(&capabilities.probes) + .map_err(|error| Failure::unsupported(&error.code().to_ascii_lowercase()))?; + Ok((descriptor.sha256.as_str(), descriptor.release)) +} + +/// The newest release the capabilities advertise that Encypher's pinned +/// production key signed, loaded from `cache` or fetched without +/// credentials and verified before it runs. +/// +/// # Errors +/// +/// A [`Failure`] where no advertised release verifies. +pub async fn load_probe( + transport: &T, + cache: &C, + capabilities: &Capabilities, +) -> Result { + let authority = ReleaseAuthority::production(); + let descriptor = authority + .select(&capabilities.probes) + .map_err(|error| Failure::unsupported(&error.code().to_ascii_lowercase()))?; + load_descriptor(transport, cache, descriptor, &authority).await +} + +/// Exactly `release`, for recovering a completed receipt: verified against +/// Encypher's pinned production key, floor and revocations before it runs, +/// never replaced by a newer release, and refused explicitly where it is no +/// longer allowed to run. Needs no capabilities, so recovery works while +/// new preparation is closed. +/// +/// # Errors +/// +/// A [`Failure`] where the release does not verify or cannot be fetched. +pub async fn load_release( + transport: &T, + cache: &C, + release: &ProbeRelease, +) -> Result { + load_descriptor( + transport, + cache, + &release.descriptor(), + &ReleaseAuthority::production(), + ) + .await +} + +async fn load_descriptor( + transport: &T, + cache: &C, + descriptor: &ProbeDescriptor, + authority: &ReleaseAuthority, +) -> Result { + authority + .verify(descriptor) + .map_err(|error| Failure::integrity(&error.code().to_ascii_lowercase()))?; + let artifact = match cache.get(&descriptor.sha256).await { + Some(bytes) if sha256_hex(&bytes) == descriptor.sha256 => bytes, + _ => { + let bytes = exchange_ok( + transport, + ApiRequest { + method: Method::GET, + path_and_query: format!("{PROBE_PATH}{}", descriptor.artifact_path()), + body: None, + idempotency_key: None, + credentialed: false, + max_response_bytes: MAX_PROBE_BYTES, + }, + ) + .await?; + if sha256_hex(&bytes) != descriptor.sha256 { + return Err(Failure::integrity("probe_release_invalid")); + } + cache.put(&descriptor.sha256, bytes.clone()).await?; + bytes + } + }; + ProbeModule::load(authority, descriptor, &artifact, ProbeLimits::default()) + .await + .map(LoadedProbe) + .map_err(|error| Failure::unsupported(&error.code().to_ascii_lowercase())) +} + +/// The API cache partition required by prepared text operations. +/// +/// A closed scope keeps old or unsupported requests from using the legacy +/// content-and-rights cache during signing, recovery or result publication. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TextCacheScope { + /// Reuse only a result for the exact canonical sign request. + ExactRequest, +} + +/// The exact text sign request, as sent and as recovered. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TextSignRequest { + /// Canonical visible article text. + pub text: String, + /// Absolute page URL without its query. + pub page_url: String, + /// Optional page title. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub document_title: Option, + /// The article boundary selector. + pub boundary_selector: String, + /// The policy the request is frozen under. + pub expected_policy_digest: String, + /// Required exact-request cache isolation, including on recovery. + pub cache_scope: TextCacheScope, +} + +/// A text sign result. +#[derive(Debug, Clone, PartialEq)] +pub struct TextSigned { + /// The marker plan. + pub plan: EmbeddingPlan, + /// The manifest URL, if any. + pub manifest_url: Option, +} + +fn text_result(body: &[u8], request: &TextSignRequest) -> Result { + let parsed = + api::parse_sign_response(body).map_err(|_| Failure::transient("malformed_response"))?; + if !parsed.success { + return Err(if parsed.error.as_deref() == Some("quota_exceeded") { + Failure::transient("quota_exceeded") + } else { + Failure::unsupported("sign_rejected") + }); + } + if parsed.policy_digest.as_deref() != Some(request.expected_policy_digest.as_str()) { + return Err(Failure::new( + FailureClass::PolicyChanged, + "policy_digest_mismatch", + )); + } + let plan = parsed + .embedding_plan + .ok_or_else(|| Failure::integrity("no_plan"))?; + let scalars = request.text.chars().count(); + api::validate_plan(&plan, parsed.manifest_url.as_deref(), scalars) + .map_err(|_| Failure::integrity("invalid_plan"))?; + Ok(TextSigned { + plan, + manifest_url: parsed.manifest_url, + }) +} + +async fn text_call( + transport: &T, + path: &str, + request: &TextSignRequest, + idempotency_key: &str, +) -> Result { + let body = serde_json::to_vec(request).map_err(|_| Failure::integrity("request_build"))?; + let answer = exchange_ok( + transport, + ApiRequest { + method: Method::POST, + path_and_query: path.to_owned(), + body: Some(body), + idempotency_key: Some(idempotency_key.to_owned()), + credentialed: true, + max_response_bytes: api::MAX_RESPONSE_BYTES, + }, + ) + .await?; + text_result(&answer, request) +} + +/// Signs an article's text once under `idempotency_key`. Only the worker +/// calls this; a reader never does. +/// +/// # Errors +/// +/// A [`Failure`] for any answer other than a valid plan under the request's +/// policy. +pub async fn sign_text( + transport: &T, + request: &TextSignRequest, + idempotency_key: &str, +) -> Result { + text_call(transport, SIGN_PATH, request, idempotency_key).await +} + +/// Recovers the exact completed text result for `request` and +/// `idempotency_key`, without signing. +/// +/// # Errors +/// +/// A [`FailureClass::Missing`] failure where the API holds none. +pub async fn recover_text( + transport: &T, + request: &TextSignRequest, + idempotency_key: &str, +) -> Result { + text_call(transport, SIGN_RECOVER_PATH, request, idempotency_key).await +} + +/// The prepared source a prepare request names. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PreparedSourceRef { + /// The public address of the exact source bytes. + pub reference: String, + /// The host the source was fetched from. + pub origin_host: String, + /// The canonical media type. + pub mime: String, + /// SHA-256 of every source byte. + pub sha256: String, + /// The source length. + pub byte_length: u64, + /// `origin` or `publisher_snapshot`. + pub reference_kind: String, +} + +/// The probe release a prepare request selects. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeSelection { + /// The artifact SHA-256. + pub sha256: String, + /// The planner. + pub planner_id: String, +} + +/// One disclosed structure chunk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StructureChunk { + /// Its source offset. + pub offset: u64, + /// Its bytes, base64. + pub b64: String, +} + +/// The exact contract version 2 prepare request, as sent and as recovered. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrepareRequest { + /// [`CONTRACT_VERSION`]. + pub contract_version: u8, + /// `external_allowed` or `embedded_required`. + pub carrier_requirement: String, + /// The reader-facing address of the item. + pub page_url: String, + /// The policy frozen into the plan. + pub expected_policy_digest: String, + /// [`PREPARED_PROTOCOL`]. + pub protocol: String, + /// The probe release. + pub probe: ProbeSelection, + /// The source. + pub source: PreparedSourceRef, + /// The structure disclosure. + pub structure: Vec, +} + +impl PrepareRequest { + /// The carrier the request requires. + #[must_use] + pub fn carrier(&self) -> Carrier { + if self.carrier_requirement == "embedded_required" { + Carrier::Embedded + } else { + Carrier::External + } + } + + fn disclosure(&self) -> Value { + Value::Array( + self.structure + .iter() + .map(|chunk| json!({"offset": chunk.offset, "data": chunk.b64})) + .collect(), + ) + } +} + +/// What one media item is prepared from. +#[derive(Debug, Clone)] +pub struct MediaJob<'a> { + /// The reader-facing address of the item. + pub page_url: &'a str, + /// The policy it is prepared under. + pub policy_digest: &'a str, + /// The carrier required. + pub carrier: Carrier, + /// The canonical media type. + pub mime: &'a str, + /// SHA-256 of every source byte, computed by the caller as it captured + /// the source. + pub source_sha256: &'a str, + /// The public reference, its host and its kind. + pub reference: &'a str, + /// The host the source was fetched from. + pub origin_host: &'a str, + /// `origin` or `publisher_snapshot`. + pub reference_kind: &'a str, + /// The idempotency key prepare and finalize send. + pub idempotency_key: &'a str, +} + +/// The external extract of `source`, refusing a source with provenance of +/// its own. It alone reports whether the XMP names a manifest, which the +/// embedded planner cannot see. +async fn inspect_provenance( + probe: &LoadedProbe, + source: &S, + mime: &str, +) -> Result { + let inspected = probe + .0 + .call( + "extract", + &json!({"mime": mime, "total_size": source.size(), "disposition": "external"}), + &Probed(source), + ) + .await + .map_err(|error| probe_failure("extract", error))?; + // An embedded manifest wins over a new one, and a reference in the XMP + // is provenance of its own: either way the source keeps it. + if inspected.get("credentials").is_some() { + return Err(Failure::new( + FailureClass::ExistingProvenance, + "source_has_embedded_provenance", + )); + } + match inspected + .get("has_provenance_reference") + .and_then(Value::as_bool) + { + Some(true) => Err(Failure::new( + FailureClass::ExistingProvenance, + "source_has_provenance_reference", + )), + Some(false) => Ok(inspected), + None => Err(Failure::integrity("probe_result_invalid")), + } +} + +/// Refuses a disclosure that is not the source's own bytes. Every chunk is +/// non-empty, in order, without overlap, within the contract's ceilings and +/// byte for byte `source` at its offset. The probe takes a disclosure as +/// the parse of the source and never reads a disclosed byte again, so a +/// chunk the source does not hold would plan the output from structure the +/// source does not have. +fn check_structure(structure: &[StructureChunk], source: &[u8]) -> Result<(), Failure> { + let mismatch = || Failure::integrity("structure_mismatch"); + if structure.is_empty() || structure.len() > MAX_STRUCTURE_CHUNKS { + return Err(mismatch()); + } + let mut decoded = Vec::new(); + let mut total = 0_usize; + let mut end = 0_usize; + for chunk in structure { + // Three bytes per four characters bounds the decode before it + // allocates. + if chunk.b64.len() / 4 * 3 > MAX_STRUCTURE_BYTES - total { + return Err(mismatch()); + } + decoded.clear(); + STANDARD + .decode_vec(&chunk.b64, &mut decoded) + .map_err(|_| mismatch())?; + total += decoded.len(); + let start = usize::try_from(chunk.offset).map_err(|_| mismatch())?; + let chunk_end = start + .checked_add(decoded.len()) + .filter(|chunk_end| *chunk_end <= source.len()) + .ok_or_else(mismatch)?; + if decoded.is_empty() || start < end || source[start..chunk_end] != decoded[..] { + return Err(mismatch()); + } + end = chunk_end; + } + Ok(()) +} + +/// The extract step's disclosure, or the failure that keeps the source as +/// it is. +/// +/// The provenance inspection, [`inspect_provenance`], runs first for every +/// carrier. A source with provenance of either kind is kept, never +/// overwritten. An embedded item's disclosure then comes from the embedded +/// extract. +async fn extract( + probe: &LoadedProbe, + source: &S, + mime: &str, + carrier: Carrier, +) -> Result, Failure> { + let inspected = inspect_provenance(probe, source, mime).await?; + let extracted = match carrier { + Carrier::External => inspected, + Carrier::Embedded => { + let embedded = probe + .0 + .call( + "extract", + &json!({"mime": mime, "total_size": source.size()}), + &Probed(source), + ) + .await + .map_err(|error| probe_failure("extract", error))?; + if embedded.get("credentials").is_some() { + return Err(Failure::new( + FailureClass::ExistingProvenance, + "source_has_embedded_provenance", + )); + } + embedded + } + }; + extracted + .get("chunks") + .and_then(Value::as_array) + .ok_or_else(|| Failure::integrity("probe_result_invalid"))? + .iter() + .map(|chunk| { + Some(StructureChunk { + offset: chunk.get("offset")?.as_u64()?, + b64: chunk.get("data")?.as_str()?.to_owned(), + }) + }) + .collect::>>() + .ok_or_else(|| Failure::integrity("probe_result_invalid")) +} + +/// Runs a verified hash program over `source`: every range, then every +/// literal, in order, into one SHA-256. +async fn run_hash_program( + items: &Value, + source: &S, +) -> Result { + const CHUNK: u64 = 1024 * 1024; + let mut hasher = Sha256::new(); + let mut buffer = Vec::new(); + for item in items + .as_array() + .ok_or_else(|| Failure::integrity("hash_program_invalid"))? + { + if let Some(range) = item.get("range").and_then(Value::as_array) { + let (Some(offset), Some(length)) = ( + range.first().and_then(Value::as_u64), + range.get(1).and_then(Value::as_u64), + ) else { + return Err(Failure::integrity("hash_program_invalid")); + }; + let end = offset + .checked_add(length) + .filter(|end| *end <= source.size()) + .ok_or_else(|| Failure::integrity("hash_program_out_of_bounds"))?; + let mut at = offset; + while at < end { + let take = (end - at).min(CHUNK); + buffer.resize(usize::try_from(take).unwrap_or(0), 0); + source.read_exact_at(at, &mut buffer).await?; + hasher.update(&buffer); + at += take; + } + } else if let Some(literal) = item.get("literal").and_then(Value::as_str) { + let bytes = STANDARD + .decode(literal) + .map_err(|_| Failure::integrity("hash_program_invalid"))?; + hasher.update(&bytes); + } else { + return Err(Failure::integrity("hash_program_invalid")); + } + } + Ok(hex_lower(&hasher.finalize())) +} + +/// Whether `items` is the one program of an unchanged external plan: the +/// whole source, once, with nothing excluded or inserted. +fn covers_whole_source(items: &Value, size: u64) -> bool { + matches!(items.as_array().map(Vec::as_slice), Some([item]) if item.get("range").and_then(Value::as_array).is_some_and(|range| { + range.len() == 2 + && range.first().and_then(Value::as_u64) == Some(0) + && range.get(1).and_then(Value::as_u64) == Some(size) + }) && item.as_object().is_some_and(|object| object.len() == 1)) +} + +/// Writes `source` through the verified edits of `geometry` with the +/// locally built `carrier`, refusing any edit out of order, beyond the +/// source, malformed or over its bound, and any output whose size is not +/// the plan's. +fn compose(source: &[u8], geometry: &Value, carrier: &[u8]) -> Result, Failure> { + let mismatch = || Failure::integrity("plan_mismatch"); + let rows = geometry + .get("edits") + .and_then(Value::as_array) + .ok_or_else(mismatch)?; + let source_length = u64::try_from(source.len()).unwrap_or(u64::MAX); + let expected = geometry + .get("expected_final_size") + .and_then(Value::as_u64) + .filter(|size| *size <= output_ceiling(source_length)) + .and_then(|size| usize::try_from(size).ok()) + .ok_or_else(mismatch)?; + let mut output = Vec::with_capacity(expected); + let mut cursor = 0_usize; + for row in rows { + let offset = row + .get("offset") + .and_then(Value::as_u64) + .and_then(|offset| usize::try_from(offset).ok()) + .filter(|offset| *offset >= cursor && *offset <= source.len()) + .ok_or_else(mismatch)?; + output.extend_from_slice(&source[cursor..offset]); + cursor = offset; + let decode = |row: &Value| { + row.get("b64") + .and_then(Value::as_str) + .and_then(|text| STANDARD.decode(text).ok()) + }; + match row.get("op").and_then(Value::as_str) { + Some("delete") => { + let length = row + .get("length") + .and_then(Value::as_u64) + .and_then(|length| usize::try_from(length).ok()) + .ok_or_else(mismatch)?; + cursor = cursor + .checked_add(length) + .filter(|end| *end <= source.len()) + .ok_or_else(mismatch)?; + } + Some("patch") => { + let bytes = decode(row).ok_or_else(mismatch)?; + cursor = cursor + .checked_add(bytes.len()) + .filter(|end| *end <= source.len()) + .ok_or_else(mismatch)?; + output.extend_from_slice(&bytes); + } + Some("insert_carrier") => { + let length = row.get("length").and_then(Value::as_u64); + if length != Some(u64::try_from(carrier.len()).unwrap_or(u64::MAX)) { + return Err(Failure::integrity("carrier_mismatch")); + } + output.extend_from_slice(carrier); + } + Some("insert_bytes") => { + let bytes = decode(row) + .filter(|bytes| !bytes.is_empty() && bytes.len() <= MAX_INSERT_BYTES) + .ok_or_else(mismatch)?; + output.extend_from_slice(&bytes); + } + _ => return Err(mismatch()), + } + if output.len() > expected { + return Err(Failure::integrity("output_verification_failed")); + } + } + output.extend_from_slice(&source[cursor..]); + if output.len() != expected { + return Err(Failure::integrity("output_verification_failed")); + } + Ok(output) +} + +fn text_field<'a>(value: &'a Value, name: &str) -> Option<&'a str> { + value.get(name).and_then(Value::as_str) +} + +/// Whether `value` is an identifier safe in a response header. +fn valid_identifier(value: &str) -> bool { + !value.is_empty() && value.len() <= 256 && value.bytes().all(|byte| byte.is_ascii_graphic()) +} + +/// Whether `value` is a canonical absolute `https` URL safe inside a `Link`. +fn valid_https_url(value: &str) -> bool { + value.len() <= 2048 + && value + .bytes() + .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'<' | b'>' | b'"')) + && url::Url::parse(value).is_ok_and(|parsed| { + parsed.scheme() == "https" && parsed.host_str().is_some() && parsed.as_str() == value + }) +} + +/// The identifier headers a finalize answer names. +fn identity_headers(finalized: &Value) -> Result, Failure> { + let instance_id = text_field(finalized, "instance_id") + .filter(|id| valid_identifier(id)) + .ok_or_else(|| Failure::integrity("invalid_signed_response"))?; + let document_id = text_field(finalized, "document_id") + .filter(|id| valid_identifier(id)) + .ok_or_else(|| Failure::integrity("invalid_signed_response"))?; + let mut headers = vec![ + ( + "x-encypher-manifest-instance-id".to_owned(), + instance_id.to_owned(), + ), + ("x-encypher-document-id".to_owned(), document_id.to_owned()), + ]; + if let Some(url) = text_field(finalized, "verification_url") { + if !valid_https_url(url) { + return Err(Failure::integrity("invalid_signed_response")); + } + headers.push(("x-encypher-verification-url".to_owned(), url.to_owned())); + } + Ok(headers) +} + +/// The decoded manifest store a finalize answer carries, checked against +/// its stated digest and `limit`. +fn finalized_store(finalized: &Value, limit: usize) -> Result<(String, Vec), Failure> { + let store_b64 = text_field(finalized, "manifest_store_b64") + .ok_or_else(|| Failure::integrity("unsigned_response"))?; + if store_b64.len() > limit.saturating_mul(4).div_ceil(3).saturating_add(3) { + return Err(Failure::unsupported("manifest_too_large")); + } + let store = STANDARD + .decode(store_b64) + .map_err(|_| Failure::integrity("invalid_signed_response"))?; + if store.is_empty() || store.len() > limit { + return Err(Failure::integrity("invalid_signed_response")); + } + if text_field(finalized, "manifest_sha256") != Some(sha256_hex(&store).as_str()) { + return Err(Failure::integrity("signed_digest_mismatch")); + } + Ok((store_b64.to_owned(), store)) +} + +async fn check_store( + probe: &LoadedProbe, + store_b64: &str, + digest: &str, + recipe: &Value, +) -> Result<(), Failure> { + let checked = probe + .0 + .call_without_source::( + "check_store", + &json!({"manifest_store_b64": store_b64, "digest": digest, "binding_recipe": recipe}), + ) + .await + .map_err(|error| probe_failure("check_store", error))?; + if checked.get("ok").and_then(Value::as_bool) == Some(false) { + return Err(Failure::integrity("store_mismatch")); + } + Ok(()) +} + +fn prepare_request( + job: &MediaJob<'_>, + probe: &LoadedProbe, + size: u64, + structure: Vec, +) -> PrepareRequest { + let (sha256, planner_id) = probe.identity(); + PrepareRequest { + contract_version: CONTRACT_VERSION, + carrier_requirement: match job.carrier { + Carrier::External => "external_allowed", + Carrier::Embedded => "embedded_required", + } + .to_owned(), + page_url: job.page_url.to_owned(), + expected_policy_digest: job.policy_digest.to_owned(), + protocol: PREPARED_PROTOCOL.to_owned(), + probe: ProbeSelection { + sha256: sha256.to_owned(), + planner_id: planner_id.to_owned(), + }, + source: PreparedSourceRef { + reference: job.reference.to_owned(), + origin_host: job.origin_host.to_owned(), + mime: job.mime.to_owned(), + sha256: job.source_sha256.to_owned(), + byte_length: size, + reference_kind: job.reference_kind.to_owned(), + }, + structure, + } +} + +/// The scalar binding type a verified plan finalizes with: `data_hash` for +/// every external plan, whose manifest binds the whole unchanged source, +/// and the type of the embedded plan's own recipe otherwise, which the +/// server chose from the format (`bmff_hash` for ISO BMFF, `data_hash` for +/// the rest). Any other recipe, such as a Merkle or collection binding, is +/// not a scalar this lane finalizes. +fn binding_type(request: &PrepareRequest, geometry: &Value) -> Result<&'static str, Failure> { + match request.carrier() { + Carrier::External => Ok("data_hash"), + Carrier::Embedded => match geometry + .get("binding_recipe") + .and_then(|recipe| text_field(recipe, "type")) + { + Some("data_hash") => Ok("data_hash"), + Some("bmff_hash") => Ok("bmff_hash"), + _ => Err(Failure::unsupported("binding_recipe_unsupported")), + }, + } +} + +fn finalize_body(planned: &PlannedMedia) -> Result { + let request = &planned.request; + let binding = binding_type(request, &planned.geometry)?; + Ok(json!({ + "contract_version": request.contract_version, + "carrier_requirement": request.carrier_requirement, + "page_url": request.page_url, + "expected_policy_digest": request.expected_policy_digest, + "protocol": PREPARED_PROTOCOL, + "plan_id": planned.plan_id, + "binding": {"type": binding, "digest": planned.digest}, + "xmp_reference": false, + })) +} + +fn checked_plan(prepared: &Value, request: &PrepareRequest) -> Result<(String, Value), Failure> { + let plan_id = text_field(prepared, "plan_id") + .filter(|id| id.len() == 36) + .ok_or_else(|| Failure::integrity("prepare_answer_invalid"))? + .to_owned(); + if text_field(prepared, "policy_digest") != Some(request.expected_policy_digest.as_str()) { + return Err(Failure::new( + FailureClass::PolicyChanged, + "policy_digest_mismatch", + )); + } + let geometry = prepared + .get("geometry") + .cloned() + .ok_or_else(|| Failure::integrity("prepare_answer_invalid"))?; + let disposition = text_field(&geometry, "disposition"); + match request.carrier() { + Carrier::External => { + let unchanged = prepared + .get("local_ops") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + && text_field(prepared, "effective_action") == Some("c2pa.opened"); + if disposition != Some("external") || !unchanged { + return Err(Failure::integrity("prepare_answer_invalid")); + } + } + Carrier::Embedded => { + if disposition != Some("embedded") { + return Err(Failure::unsupported("embedded_plan_unavailable")); + } + binding_type(request, &geometry)?; + let within = geometry + .get("expected_final_size") + .and_then(Value::as_u64) + .is_some_and(|size| size <= output_ceiling(request.source.byte_length)); + if !within { + return Err(Failure::unsupported("output_too_large")); + } + } + } + Ok((plan_id, geometry)) +} + +/// The digest a verified plan's hash program yields over `source`, after +/// the probe recomputed the server's plan from the same bytes. +async fn verified_digest( + probe: &LoadedProbe, + request: &PrepareRequest, + geometry: &Value, + source: &S, +) -> Result { + let verified = probe + .0 + .call( + "verify_plan", + &json!({ + "mime": request.source.mime, + "total_size": source.size(), + "disclosure": request.disclosure(), + "geometry": geometry, + }), + &Probed(source), + ) + .await + .map_err(|error| probe_failure("verify_plan", error))?; + let items = verified + .get("hash_items") + .ok_or_else(|| Failure::integrity("probe_result_invalid"))?; + if request.carrier() == Carrier::External { + if !covers_whole_source(items, source.size()) { + return Err(Failure::integrity("plan_not_whole_source")); + } + // The whole source, once: its digest is the one the caller + // computed while it captured every byte. + return Ok(request.source.sha256.clone()); + } + run_hash_program(items, source).await +} + +/// Whether a finalize answer's scalar binding is exactly `kind` over +/// `digest`. +fn binding_names(finalized: &Value, kind: &str, digest: &str) -> bool { + finalized.get("binding").is_some_and(|binding| { + text_field(binding, "type") == Some(kind) && text_field(binding, "digest") == Some(digest) + }) +} + +/// The external result a finalize answer supports for `request`. +async fn external_result( + probe: &LoadedProbe, + request: &PrepareRequest, + finalized: &Value, + manifest_limit: usize, +) -> Result { + if text_field(finalized, "policy_digest") != Some(request.expected_policy_digest.as_str()) { + return Err(Failure::new( + FailureClass::PolicyChanged, + "policy_digest_mismatch", + )); + } + let external = text_field(finalized, "manifest_disposition") == Some("external") + && text_field(finalized, "binding_algorithm") == Some("c2pa.hash.data") + && finalized.get("expected_final_size").and_then(Value::as_u64) + == Some(request.source.byte_length) + && binding_names(finalized, "data_hash", &request.source.sha256); + if !external { + return Err(Failure::integrity("finalize_answer_invalid")); + } + let (store_b64, _) = finalized_store(finalized, manifest_limit)?; + check_store( + probe, + &store_b64, + &request.source.sha256, + &json!({"type": "data_hash", "algorithm": "sha256", "exclusions": []}), + ) + .await?; + let manifest_url = text_field(finalized, "manifest_url") + .filter(|url| valid_https_url(url)) + .ok_or_else(|| Failure::integrity("invalid_signed_response"))?; + let mut headers = vec![( + "link".to_owned(), + format!("<{manifest_url}>; rel=\"c2pa-manifest\""), + )]; + headers.extend(identity_headers(finalized)?); + if !headers + .iter() + .any(|(name, _)| name == "x-encypher-verification-url") + { + return Err(Failure::integrity("invalid_signed_response")); + } + if request.source.byte_length > u64::try_from(usize::MAX).unwrap_or(u64::MAX) { + return Err(Failure::integrity("source_too_large")); + } + Ok(PublishedResult::External { + source_sha256: request.source.sha256.clone(), + headers, + }) +} + +/// The embedded output a finalize answer supports for `request` over +/// `source`, built and walked here, and the result a reader applies. +async fn embedded_output( + probe: &LoadedProbe, + request: &PrepareRequest, + geometry: &Value, + digest: &str, + finalized: &Value, + source: &[u8], + manifest_limit: usize, +) -> Result<(Vec, PublishedResult), Failure> { + if text_field(finalized, "policy_digest") != Some(request.expected_policy_digest.as_str()) { + return Err(Failure::new( + FailureClass::PolicyChanged, + "policy_digest_mismatch", + )); + } + let bound = binding_names(finalized, binding_type(request, geometry)?, digest) + && finalized.get("expected_final_size") == geometry.get("expected_final_size"); + if !bound { + return Err(Failure::integrity("finalize_answer_invalid")); + } + let (store_b64, _) = finalized_store(finalized, manifest_limit)?; + let carrier_sha256 = text_field(finalized, "carrier_sha256") + .filter(|sha| super::protocol::is_digest(sha)) + .ok_or_else(|| Failure::integrity("invalid_signed_response"))?; + let built = probe + .0 + .call_without_source::( + "build_carrier", + &json!({ + "mime": request.source.mime, + "geometry": geometry, + "manifest_store_b64": store_b64, + "carrier_sha256": carrier_sha256, + }), + ) + .await + .map_err(|error| probe_failure("build_carrier", error))?; + let carrier_b64 = text_field(&built, "carrier_b64") + .ok_or_else(|| Failure::integrity("probe_result_invalid"))?; + let carrier = STANDARD + .decode(carrier_b64) + .map_err(|_| Failure::integrity("probe_result_invalid"))?; + if sha256_hex(&carrier) != carrier_sha256 { + return Err(Failure::integrity("carrier_mismatch")); + } + let output = compose(source, geometry, &carrier)?; + let walked = probe + .0 + .call( + "verify_output", + &json!({ + "total_size": output.len(), + "mime": request.source.mime, + "geometry": geometry, + "carrier_b64": carrier_b64, + }), + &Probed(output.as_slice()), + ) + .await + .map_err(|error| probe_failure("verify_output", error))?; + let items = walked + .get("hash_items") + .ok_or_else(|| Failure::integrity("probe_result_invalid"))?; + if run_hash_program(items, output.as_slice()).await? != digest { + return Err(Failure::integrity("output_verification_failed")); + } + let recipe = geometry + .get("binding_recipe") + .cloned() + .ok_or_else(|| Failure::integrity("plan_mismatch"))?; + check_store(probe, &store_b64, digest, &recipe).await?; + if request.source.mime == "image/svg+xml" { + embedded::verify_svg_carrier(source, &output) + .map_err(|_| Failure::integrity("inline_svg_altered"))?; + } + let result = PublishedResult::Embedded { + source_sha256: request.source.sha256.clone(), + output_sha256: sha256_hex(&output), + content_type: request.source.mime.clone(), + output_b64: STANDARD.encode(&output), + headers: identity_headers(finalized)?, + }; + Ok((output, result)) +} + +/// A verified plan, recorded durably by the worker before it finalizes, so +/// a crash after finalize recovers the receipt rather than planning again. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlannedMedia { + /// The exact prepare request sent. + pub request: PrepareRequest, + /// The signed release the plan was verified with. + pub release: ProbeRelease, + /// The plan's identifier. + pub plan_id: String, + /// The geometry the probe verified against the source, persisted before + /// finalize as a reconstruction input. Never authoritative on its own: + /// every receipt check requires the API's recovered plan to carry + /// exactly this geometry and verifies the recovered one itself. + pub geometry: Value, + /// The digest the verified hash program yields over the source. + pub digest: String, + /// The idempotency key prepare and finalize send. + pub idempotency_key: String, +} + +/// Plans one media item: extract, prepare and the probe's plan check, then +/// the local hash program. No source byte is uploaded, and nothing is +/// signed yet. +/// +/// # Errors +/// +/// A [`Failure`] naming the step that refused. +pub async fn plan_media( + transport: &T, + probe: &LoadedProbe, + capabilities: &Capabilities, + job: &MediaJob<'_>, + source: &S, +) -> Result { + if !capabilities.supports(job.mime, job.carrier) { + return Err(Failure::unsupported(match job.carrier { + Carrier::External => "format_unsupported", + Carrier::Embedded => "unsupported_carrier", + })); + } + if capabilities.policy_digest != job.policy_digest { + return Err(Failure::new(FailureClass::PolicyChanged, "policy_changed")); + } + if source.size() == 0 || source.size() > capabilities.total_size_bytes { + return Err(Failure::unsupported("media_too_large")); + } + let structure = extract(probe, source, job.mime, job.carrier).await?; + let request = prepare_request(job, probe, source.size(), structure); + let prepared = post_json( + transport, + PREPARE_PATH, + &request, + job.idempotency_key, + MAX_PREPARE_BYTES, + ) + .await?; + let (plan_id, geometry) = checked_plan(&prepared, &request)?; + let digest = verified_digest(probe, &request, &geometry, source).await?; + Ok(PlannedMedia { + request, + release: probe.release(), + plan_id, + geometry, + digest, + idempotency_key: job.idempotency_key.to_owned(), + }) +} + +/// Finalizes a verified plan by its digest, then recovers the exact +/// receipt and rebuilds the item from it with +/// [`reconstruct_media`], so the worker's own output is made exactly as the +/// delivery instance will check it. An embedded item needs its whole +/// `source`. Returns embedded output bytes, or `None` for an external item. +/// +/// # Errors +/// +/// A [`Failure`] naming the step that refused. +pub async fn finalize_media( + transport: &T, + probe: &LoadedProbe, + planned: &PlannedMedia, + whole_source: Option<&[u8]>, +) -> Result>, Failure> { + same_release(probe, &planned.request)?; + if planned.request.carrier() == Carrier::Embedded && whole_source.is_none() { + return Err(Failure::unsupported("embedded_requires_bounded_source")); + } + let answer_limit = MAX_FINALIZED_MANIFEST_BYTES + .saturating_mul(4) + .div_ceil(3) + .saturating_add(FINALIZE_OVERHEAD_BYTES); + post_json( + transport, + FINALIZE_PATH, + &finalize_body(planned)?, + &planned.idempotency_key, + answer_limit, + ) + .await?; + let recovered = recover_media(transport, &planned.request, &planned.idempotency_key).await?; + reconstruct_media(probe, planned, &recovered, whole_source).await +} + +/// The exact completed receipt the API recovers for a prepare request. +#[derive(Debug, Clone)] +pub struct RecoveredMedia { + prepare: Value, + finalize: Value, +} + +/// Recovers the API's exact completed receipt for `request` under +/// `idempotency_key`, without preparing, finalizing or reserving anything. +/// +/// # Errors +/// +/// A [`FailureClass::Missing`] failure where the API holds none, and a +/// [`Failure`] for any other refusal or a receipt of another protocol or +/// policy. +pub async fn recover_media( + transport: &T, + request: &PrepareRequest, + idempotency_key: &str, +) -> Result { + let answer = post_json( + transport, + MEDIA_RECOVER_PATH, + request, + idempotency_key, + MAX_FINALIZED_MANIFEST_BYTES + .saturating_mul(4) + .div_ceil(3) + .saturating_add(MAX_PREPARE_BYTES) + .saturating_add(FINALIZE_OVERHEAD_BYTES), + ) + .await?; + if text_field(&answer, "protocol") != Some(PREPARED_PROTOCOL) + || text_field(&answer, "policy_digest") != Some(request.expected_policy_digest.as_str()) + { + return Err(Failure::integrity("receipt_invalid")); + } + let prepare = answer + .get("prepare") + .cloned() + .ok_or_else(|| Failure::integrity("receipt_invalid"))?; + let finalize = answer + .get("finalize") + .cloned() + .ok_or_else(|| Failure::integrity("receipt_invalid"))?; + Ok(RecoveredMedia { prepare, finalize }) +} + +/// Whether `probe` is exactly the release `request` was planned with. +fn same_release(probe: &LoadedProbe, request: &PrepareRequest) -> Result<(), Failure> { + let (sha256, planner_id) = probe.identity(); + if sha256 == request.probe.sha256 && planner_id == request.probe.planner_id { + Ok(()) + } else { + Err(Failure::integrity("probe_release_mismatch")) + } +} + +/// The geometry of the API's recovered plan, after its plan check, where it +/// is exactly the plan `planned` recorded: the same plan identifier in the +/// prepare and finalize answers and the same verified geometry. The +/// recovered geometry is the authoritative one; the recorded copy only has +/// to agree with it. +fn recovered_geometry( + planned: &PlannedMedia, + recovered: &RecoveredMedia, +) -> Result { + let (plan_id, geometry) = checked_plan(&recovered.prepare, &planned.request)?; + if plan_id != planned.plan_id + || text_field(&recovered.finalize, "plan_id") != Some(planned.plan_id.as_str()) + { + return Err(Failure::integrity("receipt_plan_mismatch")); + } + if geometry != planned.geometry { + return Err(Failure::integrity("receipt_geometry_mismatch")); + } + Ok(geometry) +} + +/// The embedded output and result a recovered receipt supports over +/// `source`, the hash program of the recovered plan run here over the +/// actual bytes. +async fn embedded_from_receipt( + probe: &LoadedProbe, + planned: &PlannedMedia, + recovered: &RecoveredMedia, + source: &[u8], +) -> Result<(Vec, PublishedResult), Failure> { + let request = &planned.request; + if sha256_hex(source) != request.source.sha256 + || u64::try_from(source.len()).ok() != Some(request.source.byte_length) + { + return Err(Failure::integrity("source_digest_mismatch")); + } + let geometry = recovered_geometry(planned, recovered)?; + let digest = verified_digest(probe, request, &geometry, source).await?; + if digest != planned.digest { + return Err(Failure::integrity("plan_digest_mismatch")); + } + embedded_output( + probe, + request, + &geometry, + &digest, + &recovered.finalize, + source, + MAX_FINALIZED_MANIFEST_BYTES, + ) + .await +} + +/// Rebuilds a completed item from its recovered receipt with exactly the +/// release it was planned with, without preparing or finalizing again: the +/// worker's path after a crash, a lost publication or an evicted result. +/// An embedded item needs its whole `source`. Plan expiry is never checked: +/// a completed receipt is reconstructed whatever its plan's lifetime was. +/// Returns embedded output bytes, or `None` for an external item. +/// +/// # Errors +/// +/// A [`Failure`] for a receipt that does not verify against the recorded +/// plan and source. +pub async fn reconstruct_media( + probe: &LoadedProbe, + planned: &PlannedMedia, + recovered: &RecoveredMedia, + whole_source: Option<&[u8]>, +) -> Result>, Failure> { + let request = &planned.request; + same_release(probe, request)?; + match (request.carrier(), whole_source) { + (Carrier::External, _) => { + recovered_geometry(planned, recovered)?; + external_result( + probe, + request, + &recovered.finalize, + MAX_FINALIZED_MANIFEST_BYTES, + ) + .await?; + Ok(None) + } + (Carrier::Embedded, Some(source)) => { + let (output, _) = embedded_from_receipt(probe, planned, recovered, source).await?; + Ok(Some(output)) + } + (Carrier::Embedded, None) => Err(Failure::unsupported("embedded_requires_bounded_source")), + } +} + +/// Refuses `source` where it carries provenance of its own: an embedded +/// manifest, or an XMP reference to one. An external manifest delivered +/// over either would never be read, so the source keeps it, whatever the +/// worker's disclosure showed. Runs exactly the release `request` was +/// planned with, under its probe budgets, over the caller's actual bytes: +/// a bounded item's hash-checked bytes or a large one's pinned version. +/// +/// # Errors +/// +/// A [`FailureClass::ExistingProvenance`] failure for a source with +/// provenance, and a [`Failure`] where `probe` is not the request's release +/// or cannot inspect the source. +pub async fn inspect_external_source( + probe: &LoadedProbe, + request: &PrepareRequest, + source: &S, +) -> Result<(), Failure> { + same_release(probe, request)?; + inspect_provenance(probe, source, &request.source.mime).await?; + Ok(()) +} + +/// Checks a recovered external receipt against `planned`, whose source the +/// caller verified independently and inspected with +/// [`inspect_external_source`]: the bytes of a bounded item hashed by the +/// caller, a large one by its own pinned whole read. Needs no capabilities, +/// so a completed receipt is checked while new preparation is closed. +/// +/// # Errors +/// +/// A [`Failure`] for a receipt that does not bind the request's exact +/// source, policy, plan and manifest. +pub async fn verify_external_receipt( + probe: &LoadedProbe, + planned: &PlannedMedia, + recovered: &RecoveredMedia, +) -> Result { + let request = &planned.request; + if request.carrier() != Carrier::External { + return Err(Failure::integrity("carrier_mismatch")); + } + if planned.digest != request.source.sha256 { + return Err(Failure::integrity("plan_digest_mismatch")); + } + same_release(probe, request)?; + recovered_geometry(planned, recovered)?; + external_result( + probe, + request, + &recovered.finalize, + MAX_FINALIZED_MANIFEST_BYTES, + ) + .await +} + +/// Checks a recovered embedded receipt against the actual `source` bytes +/// and the worker's `output`. The worker's disclosure must be the source's +/// own bytes, and the source must carry no provenance of its own, however +/// little of it the disclosure shows. The probe's plan check and hash +/// program over the source must yield the digest the receipt signed, and +/// the output the delivery instance builds itself from the receipt must be +/// exactly the worker's and walk back to that digest. A self-consistent +/// output with changed pixels, markup or structure is refused. Needs no +/// capabilities. +/// +/// # Errors +/// +/// A [`Failure`] for any mismatch. +pub async fn verify_embedded_receipt( + probe: &LoadedProbe, + planned: &PlannedMedia, + recovered: &RecoveredMedia, + source: &[u8], + output: &[u8], +) -> Result { + if planned.request.carrier() != Carrier::Embedded { + return Err(Failure::integrity("carrier_mismatch")); + } + same_release(probe, &planned.request)?; + // The disclosure seeds the probe's plan check, and an embedded one never + // shows the XMP: neither is taken on the worker's word. + check_structure(&planned.request.structure, source)?; + inspect_provenance(probe, source, &planned.request.source.mime).await?; + let (built, result) = embedded_from_receipt(probe, planned, recovered, source).await?; + if built != output { + return Err(Failure::integrity("output_substituted")); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::executor::block_on; + + #[test] + fn prepared_text_requires_the_exact_request_cache_scope() { + let request = json!({ + "text": "Harbor council votes", + "page_url": "https://www.example.com/news/a", + "document_title": "Harbor", + "boundary_selector": "article", + "expected_policy_digest": "a".repeat(64), + "cache_scope": "exact_request", + }); + let parsed: TextSignRequest = + serde_json::from_value(request.clone()).expect("should admit the exact scope"); + assert_eq!( + serde_json::to_value(parsed).expect("should preserve the frozen request"), + request + ); + let mut missing = request.clone(); + missing + .as_object_mut() + .expect("should be an object") + .remove("cache_scope"); + assert!( + serde_json::from_value::(missing).is_err(), + "an old request must not silently inherit the current cache contract" + ); + for scope in [ + Value::Null, + json!(""), + json!("legacy"), + json!("EXACT_REQUEST"), + ] { + let mut unsupported = request.clone(); + unsupported["cache_scope"] = scope; + assert!( + serde_json::from_value::(unsupported).is_err(), + "only the exact_request cache scope is admitted" + ); + } + } + + #[test] + fn synchronous_text_request_keeps_its_existing_wire_contract() { + let request = api::SignRequest { + text: "Harbor council votes", + page_url: "https://www.example.com/news/a", + document_title: Some("Harbor"), + boundary_selector: "article", + expected_policy_digest: "policy", + }; + assert_eq!( + serde_json::to_value(request).expect("should serialize the synchronous request"), + json!({ + "text": "Harbor council votes", + "page_url": "https://www.example.com/news/a", + "document_title": "Harbor", + "boundary_selector": "article", + "expected_policy_digest": "policy", + }), + "synchronous signing must not opt into prepared cache isolation" + ); + } + + #[test] + fn compose_applies_edits_in_order_and_refuses_overlap_and_wrong_sizes() { + let source = b"abcdefgh"; + let geometry = json!({ + "edits": [ + {"op": "patch", "offset": 1, "b64": STANDARD.encode(b"XY")}, + {"op": "insert_carrier", "offset": 4, "length": 3}, + {"op": "delete", "offset": 6, "length": 2}, + ], + "expected_final_size": 9, + }); + let output = compose(source, &geometry, b"CCC").expect("should compose"); + assert_eq!(output, b"aXYdCCCef", "should apply every edit in order"); + + let wrong_carrier = compose(source, &geometry, b"CC").expect_err("should refuse"); + assert_eq!(wrong_carrier.code, "carrier_mismatch"); + + let overlapping = json!({ + "edits": [ + {"op": "delete", "offset": 4, "length": 2}, + {"op": "patch", "offset": 3, "b64": STANDARD.encode(b"Z")}, + ], + "expected_final_size": 6, + }); + assert_eq!( + compose(source, &overlapping, b"") + .expect_err("should refuse") + .code, + "plan_mismatch" + ); + + let wrong_size = json!({"edits": [], "expected_final_size": 7}); + assert_eq!( + compose(source, &wrong_size, b"") + .expect_err("should refuse") + .code, + "output_verification_failed" + ); + } + + #[test] + fn hash_program_hashes_ranges_and_literals_and_refuses_out_of_bounds() { + let source = b"hello world"; + let items = json!([ + {"range": [0, 5]}, + {"literal": STANDARD.encode(b"!")}, + {"range": [5, 6]}, + ]); + let digest = futures::executor::block_on(run_hash_program(&items, source.as_slice())) + .expect("should hash"); + assert_eq!( + digest, + sha256_hex(b"hello! world"), + "should hash in program order" + ); + let beyond = json!([{"range": [6, 6]}]); + assert_eq!( + futures::executor::block_on(run_hash_program(&beyond, source.as_slice())) + .expect_err("should refuse") + .code, + "hash_program_out_of_bounds" + ); + } + + #[test] + fn whole_source_program_is_only_one_unexcluded_range() { + assert!(covers_whole_source(&json!([{"range": [0, 10]}]), 10)); + assert!(!covers_whole_source(&json!([{"range": [0, 9]}]), 10)); + assert!(!covers_whole_source( + &json!([{"range": [0, 5]}, {"range": [5, 5]}]), + 10 + )); + assert!(!covers_whole_source( + &json!([{"range": [0, 10], "extra": 1}]), + 10 + )); + } + + #[test] + fn capabilities_decide_carrier_support_from_engine_advertisement() { + let body = json!({ + "protocol": PREPARED_PROTOCOL, + "enabled": true, + "policy_digest": "a".repeat(64), + "formats": ["image/jpeg"], + "contract_versions": [1, 2], + "embedded_formats": ["image/png"], + "maximums": {"total_size_bytes": 10, "manifest_decoded_bytes": 10}, + "probe": [], + }); + let parsed = Capabilities::parse(body.to_string().as_bytes()).expect("should parse"); + assert!(parsed.supports("image/jpeg", Carrier::External)); + assert!(!parsed.supports("image/jpeg", Carrier::Embedded)); + assert!(parsed.supports("image/png", Carrier::Embedded)); + + let mut legacy = body.clone(); + legacy + .as_object_mut() + .expect("object") + .remove("contract_versions"); + let legacy = Capabilities::parse(legacy.to_string().as_bytes()).expect("should parse"); + assert!( + !legacy.supports("image/png", Carrier::Embedded), + "an engine that never advertised version 2 supports no new carrier" + ); + } + + // --- the publication boundary over the real signed probe --------------- + + const TIFF_MIME: &str = "image/tiff"; + const PHOTOMETRIC: u16 = 262; + const TIFF_CARRIER_LENGTH: u64 = 4097; + const POLICY_DIGEST: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const PLAN_ID: &str = "00000000-0000-4000-8000-000000000018"; + const IDEMPOTENCY_KEY: &str = "tiff-publication-fixture"; + + /// The production-signed release 18 probe every retained receipt + /// fixture was planned with. + fn signed_probe() -> LoadedProbe { + let release: ProbeRelease = + serde_json::from_slice(include_bytes!("../fixtures/prepared/descriptor.json")) + .expect("should parse the production-signed descriptor"); + let module = block_on(ProbeModule::load( + &ReleaseAuthority::production(), + &release.descriptor(), + include_bytes!("../fixtures/prepared/probe.wasm"), + ProbeLimits::default(), + )) + .expect("should load the production-signed probe"); + LoadedProbe(module) + } + + fn le_u16(bytes: &[u8], at: usize) -> u16 { + u16::from_le_bytes([bytes[at], bytes[at + 1]]) + } + + fn le_u32(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) + } + + /// One classic little-endian IFD entry. + fn tiff_entry(tag: u16, kind: u16, count: u32, value: u32) -> [u8; 12] { + let mut entry = [0_u8; 12]; + entry[..2].copy_from_slice(&tag.to_le_bytes()); + entry[2..4].copy_from_slice(&kind.to_le_bytes()); + entry[4..8].copy_from_slice(&count.to_le_bytes()); + entry[8..].copy_from_slice(&value.to_le_bytes()); + entry + } + + /// A manifest-free, single-IFD, uncompressed 4x2 grayscale TIFF whose + /// samples read as `BlackIsZero`, with `xmp` as its tag 700 packet. + fn grayscale_tiff(xmp: Option<&[u8]>) -> Vec { + const SAMPLES: [u8; 8] = [0, 32, 64, 96, 128, 160, 192, 255]; + let count: u16 = if xmp.is_some() { 10 } else { 9 }; + let pixels = 8 + 2 + 12 * u32::from(count) + 4; + let mut entries = vec![ + tiff_entry(256, 3, 1, 4), + tiff_entry(257, 3, 1, 2), + tiff_entry(258, 3, 1, 8), + tiff_entry(259, 3, 1, 1), + tiff_entry(PHOTOMETRIC, 3, 1, 1), + tiff_entry(273, 4, 1, pixels), + tiff_entry(277, 3, 1, 1), + tiff_entry(278, 3, 1, 2), + tiff_entry(279, 4, 1, 8), + ]; + if let Some(xmp) = xmp { + let length = u32::try_from(xmp.len()).expect("should fit"); + entries.push(tiff_entry(700, 1, length, pixels + 8)); + } + let mut tiff = b"II".to_vec(); + tiff.extend_from_slice(&42_u16.to_le_bytes()); + tiff.extend_from_slice(&8_u32.to_le_bytes()); + tiff.extend_from_slice(&count.to_le_bytes()); + for entry in &entries { + tiff.extend_from_slice(entry); + } + tiff.extend_from_slice(&0_u32.to_le_bytes()); + tiff.extend_from_slice(&SAMPLES); + if let Some(xmp) = xmp { + tiff.extend_from_slice(xmp); + } + tiff + } + + /// The entries of the IFD the header of `tiff` points at. + fn active_entries(tiff: &[u8]) -> Vec<[u8; 12]> { + let ifd = usize::try_from(le_u32(tiff, 4)).expect("should fit"); + (0..usize::from(le_u16(tiff, ifd))) + .map(|index| { + let start = ifd + 2 + 12 * index; + tiff[start..start + 12] + .try_into() + .expect("should be one entry") + }) + .collect() + } + + /// The active IFD's `PhotometricInterpretation`. + fn photometric(tiff: &[u8]) -> Option { + active_entries(tiff) + .iter() + .find(|entry| le_u16(entry.as_slice(), 0) == PHOTOMETRIC) + .map(|entry| le_u16(entry.as_slice(), 8)) + } + + /// The canonical C2PA 2.4 `main_ifd_when_single` plan for a classic + /// little-endian TIFF of `source_length` bytes whose one IFD holds + /// `entries`, laid out as the engine's TIFF planner lays it: the header + /// repointed at a rebuilt IFD after the carrier, holding those entries + /// and the C2PA entry. + fn tiff_geometry(source_length: u64, entries: &[[u8; 12]]) -> Value { + let carrier = TIFF_CARRIER_LENGTH; + let leading = source_length % 2; + let manifest_length = carrier - leading; + let trailing = manifest_length % 2; + let rebuilt_at = source_length + carrier + trailing; + let mut rebuilt = entries.to_vec(); + rebuilt.push(tiff_entry( + 0xCD41, + 7, + u32::try_from(manifest_length).expect("should fit"), + u32::try_from(source_length + leading).expect("should fit"), + )); + rebuilt.sort_by_key(|entry| le_u16(entry.as_slice(), 0)); + let mut inserted = vec![0_u8; usize::try_from(trailing).expect("should fit")]; + inserted.extend_from_slice( + &u16::try_from(rebuilt.len()) + .expect("should fit") + .to_le_bytes(), + ); + for entry in &rebuilt { + inserted.extend_from_slice(entry); + } + inserted.extend_from_slice(&0_u32.to_le_bytes()); + let pointer = u32::try_from(rebuilt_at).expect("should fit").to_le_bytes(); + let inserted_length = u64::try_from(inserted.len()).expect("should fit"); + json!({ + "disposition": "embedded", + "edits": [ + {"op": "patch", "offset": 4, "b64": STANDARD.encode(pointer)}, + {"op": "insert_carrier", "offset": source_length, "length": carrier}, + {"op": "insert_bytes", "offset": source_length, "b64": STANDARD.encode(&inserted)}, + ], + "carrier_span": [0, carrier], + "carrier_output_span": [source_length, carrier], + "expected_final_size": source_length + carrier + inserted_length, + "binding_recipe": { + "type": "data_hash", + "algorithm": "sha256", + "exclusions": [{"offset": source_length, "length": carrier}], + }, + "carrier_recipe": { + "type": "tiff", + "big": false, + "little_endian": true, + "entries": entries.iter().map(|entry| entry.to_vec()).collect::>(), + "next_ifd": 0, + "original_size": source_length, + }, + "planner_options": {"tiff_placement": "main_ifd_when_single"}, + }) + } + + fn length_of(source: &[u8]) -> u64 { + u64::try_from(source.len()).expect("should fit") + } + + fn tiff_request( + probe: &LoadedProbe, + source: &[u8], + structure: Vec, + ) -> PrepareRequest { + let source_sha256 = sha256_hex(source); + let reference = + format!("https://publisher.example.com/_encypher/preparation/source/{source_sha256}"); + let job = MediaJob { + page_url: "https://publisher.example.com/article", + policy_digest: POLICY_DIGEST, + carrier: Carrier::Embedded, + mime: TIFF_MIME, + source_sha256: &source_sha256, + reference: &reference, + origin_host: "publisher.example.com", + reference_kind: "publisher_snapshot", + idempotency_key: IDEMPOTENCY_KEY, + }; + prepare_request(&job, probe, length_of(source), structure) + } + + /// The plan record for `request` and a recovered receipt carrying + /// `geometry`. The finalize answer names the plan but holds no signed + /// manifest: no genuine receipt for a TIFF is among the fixtures, so a + /// check that gets this far accepted the structure, the geometry and the + /// digest over the source, and refuses only for want of a signature. + fn tiff_receipt( + probe: &LoadedProbe, + request: PrepareRequest, + geometry: &Value, + digest: String, + ) -> (PlannedMedia, RecoveredMedia) { + let recovered = RecoveredMedia { + prepare: json!({ + "plan_id": PLAN_ID, + "policy_digest": POLICY_DIGEST, + "geometry": geometry, + }), + finalize: json!({"plan_id": PLAN_ID, "policy_digest": POLICY_DIGEST}), + }; + let planned = PlannedMedia { + request, + release: probe.release(), + plan_id: PLAN_ID.to_owned(), + geometry: geometry.clone(), + digest, + idempotency_key: IDEMPOTENCY_KEY.to_owned(), + }; + (planned, recovered) + } + + /// `structure` with the `PhotometricInterpretation` value rewritten to + /// `value`, every other disclosed byte as it was. + fn with_photometric(structure: &[StructureChunk], value: u16) -> Vec { + // The fifth entry of the IFD at offset 8; its value field starts + // eight bytes into the entry. + let field = 8 + 2 + 12 * 4 + 8; + let mut forged = structure.to_vec(); + let chunk = forged + .iter_mut() + .find(|chunk| { + let length = STANDARD + .decode(&chunk.b64) + .map_or(0, |bytes| length_of(&bytes)); + chunk.offset <= field && field + 2 <= chunk.offset + length + }) + .expect("the disclosure should hold the photometric entry"); + let mut bytes = STANDARD.decode(&chunk.b64).expect("should be base64"); + let at = usize::try_from(field - chunk.offset).expect("should fit"); + bytes[at..at + 2].copy_from_slice(&value.to_le_bytes()); + chunk.b64 = STANDARD.encode(bytes); + forged + } + + fn zero_carrier() -> Vec { + vec![0_u8; usize::try_from(TIFF_CARRIER_LENGTH).expect("should fit")] + } + + /// A worker that discloses a false TIFF structure for the true source + /// gets a plan the signed probe verifies over the true bytes, because + /// the disclosure seeds the probe's parse: the output would carry a false + /// `PhotometricInterpretation` under the true source digest. Publication + /// refuses it, and the honest structure still passes every source check. + #[test] + fn publication_refuses_a_disclosure_the_source_does_not_hold() { + let probe = signed_probe(); + let source = grayscale_tiff(None); + let honest = block_on(extract( + &probe, + source.as_slice(), + TIFF_MIME, + Carrier::Embedded, + )) + .expect("the signed probe should extract a manifest-free TIFF"); + let forged = with_photometric(&honest, 0); + assert_ne!(forged, honest, "the forgery should change the disclosure"); + + let honest_geometry = tiff_geometry(length_of(&source), &active_entries(&source)); + let mut forged_entries = active_entries(&source); + forged_entries[4][8..10].copy_from_slice(&0_u16.to_le_bytes()); + let forged_geometry = tiff_geometry(length_of(&source), &forged_entries); + + // The geometry is the engine's own: the probe verifies the honest + // plan over the true source and refuses the forged one there. + let honest_request = tiff_request(&probe, &source, honest); + let honest_digest = block_on(verified_digest( + &probe, + &honest_request, + &honest_geometry, + source.as_slice(), + )) + .expect("the signed probe should verify the canonical plan"); + let refused = block_on(verified_digest( + &probe, + &honest_request, + &forged_geometry, + source.as_slice(), + )) + .expect_err("an honest disclosure should not admit the forged plan"); + assert_eq!( + refused.class, + FailureClass::Integrity, + "the forged plan should be a plan mismatch over honest structure" + ); + + // Seeded with the false structure, the same probe verifies the + // forged plan over the same true bytes. + let forged_request = tiff_request(&probe, &source, forged); + assert_eq!( + forged_request.source, honest_request.source, + "the forged request should name the true source, digest and length" + ); + let forged_digest = block_on(verified_digest( + &probe, + &forged_request, + &forged_geometry, + source.as_slice(), + )) + .expect("the signed probe should take the disclosure as the source's structure"); + assert_ne!( + forged_digest, honest_digest, + "the forged plan should bind other bytes" + ); + + // What that plan delivers: the true samples under a WhiteIsZero + // active IFD. + let honest_output = + compose(&source, &honest_geometry, &zero_carrier()).expect("should compose"); + let forged_output = + compose(&source, &forged_geometry, &zero_carrier()).expect("should compose"); + assert_eq!(photometric(&source), Some(1), "the source is BlackIsZero"); + assert_eq!( + photometric(&honest_output), + Some(1), + "the honest output should keep BlackIsZero" + ); + assert_eq!( + photometric(&forged_output), + Some(0), + "the forged output should read the same samples as WhiteIsZero" + ); + assert_eq!( + &forged_output[8..source.len()], + &source[8..], + "every source byte after the header pointer should be unchanged" + ); + + // Publication refuses the forged structure before it reads the + // receipt; the honest structure reaches the receipt. + let (planned, recovered) = + tiff_receipt(&probe, forged_request, &forged_geometry, forged_digest); + let failure = block_on(verify_embedded_receipt( + &probe, + &planned, + &recovered, + &source, + &forged_output, + )) + .expect_err("publication should refuse a disclosure the source does not hold"); + assert_eq!( + failure, + Failure::integrity("structure_mismatch"), + "the forged disclosure should be refused as not the source's own" + ); + + let (planned, recovered) = + tiff_receipt(&probe, honest_request, &honest_geometry, honest_digest); + let failure = block_on(verify_embedded_receipt( + &probe, + &planned, + &recovered, + &source, + &honest_output, + )) + .expect_err("the fixture receipt holds no signed manifest"); + assert_eq!( + failure.code, "finalize_answer_invalid", + "the honest structure should pass every source check and stop only at the unsigned receipt" + ); + } + + /// A worker that skips the provenance inspection and discloses only the + /// embedded structure of a TIFF whose XMP names a remote manifest gets a + /// plan the probe verifies over the true bytes: the packet lies outside + /// the structure. Publication inspects the source itself and keeps its + /// provenance. + #[test] + fn publication_keeps_provenance_the_disclosure_leaves_out() { + let probe = signed_probe(); + let xmp = br#""#; + let source = grayscale_tiff(Some(xmp)); + let kept = block_on(extract( + &probe, + source.as_slice(), + TIFF_MIME, + Carrier::Embedded, + )) + .expect_err("the worker's extract should keep the source's provenance"); + assert_eq!( + kept, + Failure::new( + FailureClass::ExistingProvenance, + "source_has_provenance_reference" + ), + "the signed probe should find the XMP reference" + ); + + // The embedded extract alone, as a worker skipping the inspection + // runs it. + let embedded = block_on(probe.0.call( + "extract", + &json!({"mime": TIFF_MIME, "total_size": length_of(&source)}), + &Probed(source.as_slice()), + )) + .map_err(|error| probe_failure("extract", error)) + .expect("the embedded extract should not read the XMP"); + let structure = embedded["chunks"] + .as_array() + .expect("should disclose chunks") + .iter() + .map(|chunk| StructureChunk { + offset: chunk["offset"].as_u64().expect("should have an offset"), + b64: chunk["data"].as_str().expect("should have data").to_owned(), + }) + .collect(); + let geometry = tiff_geometry(length_of(&source), &active_entries(&source)); + let request = tiff_request(&probe, &source, structure); + let digest = block_on(verified_digest( + &probe, + &request, + &geometry, + source.as_slice(), + )) + .expect("the signed probe should verify the plan over the true source"); + let output = compose(&source, &geometry, &zero_carrier()).expect("should compose"); + + let (planned, recovered) = tiff_receipt(&probe, request, &geometry, digest); + let failure = block_on(verify_embedded_receipt( + &probe, &planned, &recovered, &source, &output, + )) + .expect_err("publication should keep the source's provenance"); + assert_eq!( + failure, + Failure::new( + FailureClass::ExistingProvenance, + "source_has_provenance_reference" + ), + "publication should inspect the source it publishes over" + ); + } +} diff --git a/crates/provenance/encypher/src/preparation/protocol.rs b/crates/provenance/encypher/src/preparation/protocol.rs new file mode 100644 index 0000000000..eeb0d0d0b5 --- /dev/null +++ b/crates/provenance/encypher/src/preparation/protocol.rs @@ -0,0 +1,1075 @@ +//! The `encypher-preparation/1` protocol between a publisher's delivery +//! instance and its native preparation worker. +//! +//! Everything both sides must agree on byte for byte lives here: the +//! preparation identities, the published records readers look up, the +//! retirement markers, the reader hint, the snapshot capability and the +//! snapshot facts. The reader and the worker call these functions, never a +//! copy of them, so an identity the reader computes from the bytes it +//! delivers is the identity the worker signs under. +//! +//! An identity is a versioned, domain-separated SHA-256 over +//! length-delimited parts. A part's length is written before it, so no two +//! different lists of parts hash alike, and the domain names the kind of +//! item and its identity contract version, so no identity of one kind matches +//! another's. Identity versions can change without changing the wire protocol. + +use std::collections::BTreeMap; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use hmac::{Hmac, Mac as _}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use subtle::ConstantTimeEq as _; + +use crate::api::EmbeddingPlan; +use crate::media_api::{hex_lower, sha256_hex}; + +/// The protocol every message between the delivery instance and the worker +/// carries. +pub const PREPARATION_PROTOCOL: &str = "encypher-preparation/1"; + +/// The version of the text projection an identity binds. A corrected +/// projection takes a new version, so it never reuses an old plan. +pub const TEXT_PROJECTION_VERSION: &str = "visible-text/1"; + +/// The request header the worker and the delivery instance authenticate +/// control requests with. Its value is the scope's secret, compared in +/// constant time, never logged. +pub const TOKEN_HEADER: &str = "x-encypher-preparation-token"; + +/// The request header carrying a snapshot capability on the delivery +/// instance's own loopback read. It never reaches an origin. +pub const CAPABILITY_HEADER: &str = "x-encypher-preparation-capability"; + +/// The response header reporting what the origin's own headers said about a +/// snapshot, authenticated for one capability's nonce. +pub const FACTS_HEADER: &str = "x-encypher-preparation-facts"; + +/// The delivery instance's snapshot route. +pub const SNAPSHOT_PATH: &str = "/_encypher/preparation/snapshot"; +/// The delivery instance's result publication route, below which an +/// identity follows. +pub const RESULTS_PREFIX: &str = "/_encypher/preparation/results/"; +/// The delivery instance's retirement marker route, below which an identity +/// follows. +pub const MARKERS_PREFIX: &str = "/_encypher/preparation/markers/"; +/// The publisher route serving explicitly public source snapshots, below +/// which a raw-source SHA-256 follows. +pub const PUBLIC_SOURCE_PREFIX: &str = "/_encypher/preparation/source/"; + +/// The worker's reader-signal route. +pub const SIGNALS_PATH: &str = "/v1/preparation/signals"; +/// The worker's operator warming route. +pub const WARM_PATH: &str = "/v1/preparation/warm"; +/// The worker's operator retirement route. +pub const RETIRE_PATH: &str = "/v1/preparation/retire"; +/// The worker's operator status route. +pub const STATUS_PATH: &str = "/v1/preparation/status"; +/// The worker's readiness route. +pub const READY_PATH: &str = "/v1/preparation/ready"; +/// The worker's public source route, which only the delivery instance +/// calls, below which a raw-source SHA-256 follows. +pub const WORKER_SOURCE_PREFIX: &str = "/v1/preparation/sources/"; + +/// The largest reader hint, in bytes. +pub const MAX_SIGNAL_BYTES: usize = 8 * 1024; +/// The largest target path a hint or warm names. +pub const MAX_TARGET_PATH_BYTES: usize = 1024; +/// The most variant parameters a schema names. +pub const MAX_VARIANT_PARAMS: usize = 8; +/// The most values one variant parameter admits. +pub const MAX_VARIANT_VALUES: usize = 32; + +/// How long a snapshot capability stays valid, in seconds. +pub const CAPABILITY_LIFETIME_SECONDS: u64 = 120; + +type HmacSha256 = Hmac; + +/// SHA-256, lower case hex, over `domain` and each part, every part's +/// length written before it. +#[must_use] +pub fn domain_digest(domain: &str, parts: &[&[u8]]) -> String { + let mut digest = Sha256::new(); + digest.update( + u64::try_from(domain.len()) + .unwrap_or(u64::MAX) + .to_be_bytes(), + ); + digest.update(domain.as_bytes()); + for part in parts { + digest.update(u64::try_from(part.len()).unwrap_or(u64::MAX).to_be_bytes()); + digest.update(part); + } + hex_lower(&digest.finalize()) +} + +/// Whether `value` is a lower case SHA-256 hex digest, the shape of every +/// identity. +#[must_use] +pub fn is_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// The credential context every identity binds: the API origin, the claimed +/// domain and the worker credential, hashed, so two credentials or two +/// publishers never share a result. Never a secret. +#[must_use] +pub fn credential_context(api_base_url: &str, domain: &str, worker_token: &str) -> String { + let hash = sha256_hex(format!("{api_base_url}\n{domain}\n{}", worker_token.trim()).as_bytes()); + hash.get(..16).unwrap_or(hash.as_str()).to_owned() +} + +/// What an article's preparation identity binds: every field of the sign +/// request that affects its signed assertions, the projection version and +/// the admitted variant. Its text-only hash domain also versions the API +/// cache contract: version 2 requires exact-request isolation, so records +/// made through the old coarse cache never satisfy a current lookup. +#[derive(Debug, Clone, Copy)] +pub struct TextIdentity<'a> { + /// See [`credential_context`]. + pub credential_context: &'a str, + /// The policy the article is signed under. + pub policy_digest: &'a str, + /// The query-free page address the article is signed for. + pub page_url: &'a str, + /// The admitted variant, see [`VariantSchema::admit`]. + pub variant: &'a str, + /// The exact visible text sent for signing. + pub text: &'a str, + /// The page title sent, if any. + pub document_title: Option<&'a str>, + /// The article boundary selector sent. + pub boundary_selector: &'a str, +} + +impl TextIdentity<'_> { + /// The identity. + #[must_use] + pub fn digest(&self) -> String { + let text = sha256_hex(self.text.as_bytes()); + let (title_present, title) = match self.document_title { + Some(title) => (&b"1"[..], title.as_bytes()), + None => (&b"0"[..], &b""[..]), + }; + domain_digest( + "encypher-preparation/2:text", + &[ + self.credential_context.as_bytes(), + self.policy_digest.as_bytes(), + self.page_url.as_bytes(), + self.variant.as_bytes(), + TEXT_PROJECTION_VERSION.as_bytes(), + text.as_bytes(), + title_present, + title, + self.boundary_selector.as_bytes(), + ], + ) + } +} + +/// How a media item's manifest is delivered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Carrier { + /// Unchanged bytes and a remote manifest named by a `Link`. + External, + /// The manifest inside the delivered bytes. + Embedded, +} + +impl Carrier { + /// The name the protocol writes. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::External => "external", + Self::Embedded => "embedded", + } + } +} + +/// What a bounded media item's preparation identity binds. +#[derive(Debug, Clone, Copy)] +pub struct MediaIdentity<'a> { + /// See [`credential_context`]. + pub credential_context: &'a str, + /// The policy the item is signed under. + pub policy_digest: &'a str, + /// The reader-facing address the item is delivered on or in. + pub page_url: &'a str, + /// The admitted variant of that address. + pub variant: &'a str, + /// How the manifest is delivered. + pub carrier: Carrier, + /// The canonical media type of the source. + pub mime: &'a str, + /// SHA-256 of the exact source bytes, lower case hex. + pub source_sha256: &'a str, +} + +impl MediaIdentity<'_> { + /// The identity. + #[must_use] + pub fn digest(&self) -> String { + domain_digest( + "encypher-preparation/1:media", + &[ + self.credential_context.as_bytes(), + self.policy_digest.as_bytes(), + self.page_url.as_bytes(), + self.variant.as_bytes(), + self.carrier.as_str().as_bytes(), + self.mime.as_bytes(), + self.source_sha256.as_bytes(), + ], + ) + } +} + +/// The source-version locator of a large item: the configured origin and +/// the strong validators it answered with. An entity tag is not +/// cryptographic evidence; this relies on a publisher-controlled origin +/// honoring strong validator semantics, and the published record carries +/// the whole-source digest the worker verified. +#[derive(Debug, Clone, Copy)] +pub struct LargeLocator<'a> { + /// See [`credential_context`]. + pub credential_context: &'a str, + /// The policy the item is signed under. + pub policy_digest: &'a str, + /// The query-free reader address. + pub reader_url: &'a str, + /// The admitted variant of that address. + pub variant: &'a str, + /// The lower case host the source is fetched from. + pub origin_host: &'a str, + /// SHA-256 of core's configured-origin identity, not just its hostname. + pub origin_identity_sha256: &'a str, + /// The canonical media type. + pub mime: &'a str, + /// The single strong entity tag, quoted. + pub etag: &'a str, + /// The total length. + pub length: u64, +} + +impl LargeLocator<'_> { + /// The identity. + #[must_use] + pub fn digest(&self) -> String { + let length = self.length.to_string(); + domain_digest( + "encypher-preparation/1:large", + &[ + self.credential_context.as_bytes(), + self.policy_digest.as_bytes(), + self.reader_url.as_bytes(), + self.variant.as_bytes(), + self.origin_host.as_bytes(), + self.origin_identity_sha256.as_bytes(), + self.mime.as_bytes(), + self.etag.as_bytes(), + length.as_bytes(), + ], + ) + } +} + +/// The KV key a published result is stored under. +#[must_use] +pub fn result_key(identity: &str) -> String { + format!("encypher:prep:v1:result:{identity}") +} + +/// The KV key a retirement marker is stored under, apart from the result so +/// a late result can never overwrite it. +#[must_use] +pub fn marker_key(identity: &str) -> String { + format!("encypher:prep:v1:retired:{identity}") +} + +/// A result the delivery instance verified through the authenticated API +/// and stored under its immutable identity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublishedRecord { + /// [`PREPARATION_PROTOCOL`]. + pub protocol: String, + /// The identity the record is stored under. + pub identity: String, + /// The retirement epoch the result was prepared under. + pub retirement_epoch: u64, + /// What a reader applies. + pub result: PublishedResult, +} + +/// What a reader applies for one identity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PublishedResult { + /// An article's marker plan. + Text { + /// The verified plan. + plan: EmbeddingPlan, + /// The manifest URL written in the head, if any. + manifest_url: Option, + /// The scalars of the text the plan was made for. + scalars: usize, + }, + /// Unchanged bounded media with a remote manifest. + External { + /// The exact source the manifest binds. + source_sha256: String, + /// Headers every answer carries: the manifest `Link` and ids. + headers: Vec<(String, String)>, + }, + /// Bounded media with its manifest embedded. + Embedded { + /// The exact source the output was made from. + source_sha256: String, + /// SHA-256 of the output bytes. + output_sha256: String, + /// The output's media type. + content_type: String, + /// The output bytes, base64. + output_b64: String, + /// Headers every answer carries: the manifest ids. + headers: Vec<(String, String)>, + }, + /// An unchanged large source with a remote manifest. + Large { + /// SHA-256 of every byte of the version. + sha256: String, + /// The version's length. + length: u64, + /// Headers every answer carries. + headers: Vec<(String, String)>, + }, +} + +/// The marker that retires an identity on the delivery instance. A reader +/// refuses the identity's result while it is present, and a result +/// publication never removes it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RetirementMarker { + /// The retirement epoch that retired the identity. + pub epoch: u64, +} + +/// Which kind of item a hint, job or result is about. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ItemKind { + /// An article's text. + Text, + /// A bounded media item delivered on its own address. + Media, + /// A media item embedded in a page or stylesheet. + Embedded, + /// A large media item, by its validator locator. + Large, +} + +/// One item a reader observed and found no published result for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ObservedItem { + /// What kind of item. + pub kind: ItemKind, + /// Its identity as the reader computed it from what it delivered. + pub identity: String, +} + +/// A configured target: a publisher path and an admitted variant. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Target { + /// The publisher's path, starting with `/`, without a query. + pub path: String, + /// The admitted variant, see [`VariantSchema::admit`], empty for none. + #[serde(default)] + pub variant: String, +} + +impl Target { + /// Whether the target is well formed: an absolute path within its + /// bound, without a query, fragment, `..` segment or control byte. + #[must_use] + pub fn is_well_formed(&self) -> bool { + self.path.starts_with('/') + && !self.path.starts_with("//") + && self.path.len() <= MAX_TARGET_PATH_BYTES + && self + .path + .bytes() + .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'?' | b'#' | b'\\')) + && !self.path.split('/').any(|segment| segment == "..") + && self.variant.len() <= MAX_TARGET_PATH_BYTES + } + + /// The path and query the delivery instance reads for this target. + #[must_use] + pub fn path_and_query(&self) -> String { + if self.variant.is_empty() { + self.path.clone() + } else { + format!("{}?{}", self.path, self.variant) + } + } +} + +/// A reader's hint that the items it delivered are not prepared. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignalRequest { + /// [`PREPARATION_PROTOCOL`]. + pub protocol: String, + /// The deployment the hint is for. + pub deployment_id: String, + /// The target the reader was served. + pub target: Target, + /// The items it found unprepared, cache misses first. + pub items: Vec, + /// How many more unprepared items did not fit the hint. + #[serde(default)] + pub unobserved: u32, +} + +impl SignalRequest { + /// The hint's JSON, holding as many of `items` as fit in + /// [`MAX_SIGNAL_BYTES`] in their order, and counting the rest as + /// unobserved. `None` where not even an empty hint fits. + #[must_use] + pub fn encode_bounded( + deployment_id: &str, + target: Target, + items: &[ObservedItem], + ) -> Option> { + let mut request = Self { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: deployment_id.to_owned(), + target, + items: Vec::with_capacity(items.len()), + unobserved: 0, + }; + let mut encoded = serde_json::to_vec(&request).ok()?; + if encoded.len() > MAX_SIGNAL_BYTES { + return None; + } + for (index, item) in items.iter().enumerate() { + request.items.push(item.clone()); + request.unobserved = u32::try_from(items.len() - index - 1).unwrap_or(u32::MAX); + let next = serde_json::to_vec(&request).ok()?; + if next.len() > MAX_SIGNAL_BYTES { + request.items.pop(); + request.unobserved = u32::try_from(items.len() - index).unwrap_or(u32::MAX); + return serde_json::to_vec(&request).ok(); + } + encoded = next; + } + Some(encoded) + } +} + +/// One variant parameter a deployment admits, with every value it may take. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct VariantParam { + /// The query parameter's name. + pub name: String, + /// Every value the parameter may take. + pub values: Vec, +} + +/// The bounded variant schema a deployment configures: which query +/// parameters name a variant, and with which values. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct VariantSchema(pub Vec); + +impl VariantSchema { + /// Whether the schema is within its bounds: at most + /// [`MAX_VARIANT_PARAMS`] distinct non-empty names, each with between + /// one and [`MAX_VARIANT_VALUES`] distinct values, every name and value + /// at most 64 URL-safe bytes. + #[must_use] + pub fn is_bounded(&self) -> bool { + let safe = |value: &str| { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }; + let mut names = std::collections::BTreeSet::new(); + self.0.len() <= MAX_VARIANT_PARAMS + && self.0.iter().all(|param| { + let mut values = std::collections::BTreeSet::new(); + safe(¶m.name) + && names.insert(param.name.as_str()) + && !param.values.is_empty() + && param.values.len() <= MAX_VARIANT_VALUES + && param + .values + .iter() + .all(|value| safe(value) && values.insert(value.as_str())) + }) + } + + /// The canonical variant a reader's `query` names, its admitted pairs + /// sorted by name, or `None` where it carries anything the schema does + /// not admit: an unknown name, an unlisted value, a name given twice. An + /// empty query is the empty variant. + #[must_use] + pub fn admit(&self, query: &str) -> Option { + if query.is_empty() { + return Some(String::new()); + } + let mut pairs = BTreeMap::new(); + for (name, value) in url::form_urlencoded::parse(query.as_bytes()) { + let param = self.0.iter().find(|param| param.name == name)?; + if !param.values.iter().any(|allowed| *allowed == value) { + return None; + } + if pairs + .insert(param.name.clone(), value.into_owned()) + .is_some() + { + return None; + } + } + Some( + pairs + .into_iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join("&"), + ) + } +} + +/// The key a snapshot capability is signed with: derived from the +/// publication secret, so the secret itself never signs anything a +/// request carries. +fn capability_key(publication_secret: &str, purpose: &str) -> HmacSha256 { + let mut derive = HmacSha256::new_from_slice(publication_secret.as_bytes()) + .expect("should key an HMAC with any length"); + derive.update(b"encypher-preparation/1:"); + derive.update(purpose.as_bytes()); + let key = derive.finalize().into_bytes(); + HmacSha256::new_from_slice(&key).expect("should key an HMAC with a derived key") +} + +fn mac_hex(mut mac: HmacSha256, message: &[u8]) -> String { + mac.update(message); + hex_lower(&mac.finalize().into_bytes()) +} + +/// The capability a snapshot route grants its own loopback read of +/// `path_and_query`, valid until `expires` with this `nonce`. +#[must_use] +pub fn snapshot_capability( + publication_secret: &str, + path_and_query: &str, + nonce: &str, + expires: u64, +) -> String { + let message = format!("{path_and_query}\n{nonce}\n{expires}"); + let mac = mac_hex( + capability_key(publication_secret, "capability"), + message.as_bytes(), + ); + format!("v1.{expires}.{nonce}.{mac}") +} + +/// The nonce of a capability valid for `path_and_query` at `now`, or `None` +/// for any other value: malformed, expired, too far in the future, or not +/// signed with the publication secret. Compared in constant time. +#[must_use] +pub fn verify_snapshot_capability( + publication_secret: &str, + path_and_query: &str, + capability: &str, + now: u64, +) -> Option { + let mut parts = capability.splitn(4, '.'); + if parts.next()? != "v1" { + return None; + } + let expires: u64 = parts.next()?.parse().ok()?; + let nonce = parts.next()?; + let mac = parts.next()?; + if now > expires + || expires.saturating_sub(now) > CAPABILITY_LIFETIME_SECONDS + || nonce.len() != 32 + || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return None; + } + let expected = snapshot_capability(publication_secret, path_and_query, nonce, expires); + let expected_mac = expected.rsplit('.').next()?; + bool::from(expected_mac.as_bytes().ct_eq(mac.as_bytes())).then(|| nonce.to_owned()) +} + +/// What the origin's own headers said about a snapshot, as the delivery +/// instance reports it, kept apart from the snapshot's own private +/// transport headers. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotOrigin { + /// The origin's status. + pub status: u16, + /// The origin marked its answer as one reader's. + pub private: bool, + /// The origin forbade transforming the body. + pub no_transform: bool, + /// The origin's own `Link` names a C2PA manifest. + pub manifest_link: bool, + /// The origin's `Content-Encoding`, empty for identity. + pub content_encoding: String, + /// The lower case host the source was fetched from. + pub origin_host: String, + /// SHA-256 of core's configured media-origin identity. Empty for HTML; + /// large-source admission requires a valid digest. + pub origin_identity_sha256: String, + /// The exact fetched address, where it carries no user information, + /// query or fragment. + pub source_reference: Option, +} + +/// The facts header for `origin`, authenticated for `nonce`. +#[must_use] +pub fn facts_header(publication_secret: &str, nonce: &str, origin: &SnapshotOrigin) -> String { + let body = serde_json::to_vec(origin).unwrap_or_default(); + let encoded = STANDARD.encode(&body); + let mut message = nonce.as_bytes().to_vec(); + message.push(b'\n'); + message.extend_from_slice(encoded.as_bytes()); + let mac = mac_hex(capability_key(publication_secret, "facts"), &message); + format!("v1.{encoded}.{mac}") +} + +/// The origin facts a header reports, where it is authenticated for +/// `nonce`; `None` for anything else, such as a header an origin wrote. +#[must_use] +pub fn verify_facts_header( + publication_secret: &str, + nonce: &str, + header: &str, +) -> Option { + let rest = header.strip_prefix("v1.")?; + let (encoded, mac) = rest.rsplit_once('.')?; + let mut message = nonce.as_bytes().to_vec(); + message.push(b'\n'); + message.extend_from_slice(encoded.as_bytes()); + let expected = mac_hex(capability_key(publication_secret, "facts"), &message); + if !bool::from(expected.as_bytes().ct_eq(mac.as_bytes())) { + return None; + } + serde_json::from_slice(&STANDARD.decode(encoded).ok()?).ok() +} + +/// Whether `presented` is `secret`, compared in constant time. An empty +/// secret authenticates nothing. +#[must_use] +pub fn token_matches(secret: &str, presented: &str) -> bool { + !secret.is_empty() && bool::from(secret.as_bytes().ct_eq(presented.as_bytes())) +} + +/// The idempotency key a text preparation sends, derived from its identity +/// and attempt generation so a retry of one operation replays it. +#[must_use] +pub fn operation_key(identity: &str, generation: u64) -> String { + domain_digest( + "encypher-preparation/1:operation", + &[identity.as_bytes(), generation.to_string().as_bytes()], + ) +} + +/// The response header carrying a snapshot's metadata, base64 JSON of +/// [`SnapshotMeta`]; the body is the snapshot's bytes. +pub const SNAPSHOT_META_HEADER: &str = "x-encypher-preparation-snapshot"; + +/// What a snapshot request reads. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] +pub enum SnapshotMode { + /// The whole bounded representation. + Whole, + /// Headers only, for a source too large to hold whole. + Head, + /// One byte range of the version `if_match` names. + Range { + /// The first byte. + first: u64, + /// The last byte, inclusive. + last: u64, + /// The strong entity tag the range must come from. + if_match: String, + }, +} + +/// The worker's request for a snapshot of one configured target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotRequest { + /// [`PREPARATION_PROTOCOL`]. + pub protocol: String, + /// The deployment. + pub deployment_id: String, + /// The target read, which must lie under a configured route with an + /// admitted variant. + pub target: Target, + /// 32 lower case hex characters the worker chose, which the facts the + /// snapshot reports are authenticated for. + pub nonce: String, + /// What is read. + pub read: SnapshotMode, +} + +/// What a snapshot answer reports besides its bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotMeta { + /// [`PREPARATION_PROTOCOL`]. + pub protocol: String, + /// The reader-facing address the snapshot was read at, without query. + pub page_url: String, + /// The representation's `Content-Type`. + pub content_type: String, + /// Its single strong entity tag, quoted, where it has one. + pub etag: Option, + /// Its total length, where known. + pub length: Option, + /// What the origin's own headers said, apart from the snapshot's own + /// private transport headers. + pub origin: SnapshotOrigin, +} + +/// One result the worker asks the delivery instance to verify and publish. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResultEnvelope { + /// [`PREPARATION_PROTOCOL`]. + pub protocol: String, + /// The deployment. + pub deployment_id: String, + /// The identity the result is published under; the delivery instance + /// recomputes it from the item and refuses any other. + pub identity: String, + /// The retirement epoch the result was prepared under. + pub retirement_epoch: u64, + /// The admitted variant the item was read under. + #[serde(default)] + pub variant: String, + /// The item, as the delivery instance recovers and checks it. + pub item: EnvelopeItem, +} + +/// An item a [`ResultEnvelope`] carries. Never a signed result the worker +/// asserts: the delivery instance recovers each receipt from the API itself. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum EnvelopeItem { + /// An article: the exact sign request and its idempotency key. + Text { + /// The exact sign request. + request: super::pipeline::TextSignRequest, + /// Its idempotency key. + idempotency_key: String, + }, + /// A bounded standalone item with an external manifest. + Media { + /// The verified plan. + planned: super::pipeline::PlannedMedia, + /// The source bytes, base64, which the delivery instance hashes. + source_b64: String, + }, + /// An embedded item. + Embedded { + /// The verified plan. + planned: super::pipeline::PlannedMedia, + /// The source bytes, base64. + source_b64: String, + /// The worker's output bytes, base64, which must be exactly the + /// output the delivery instance builds from the receipt. + output_b64: String, + }, + /// A large item, named by its strong validator. + Large { + /// The verified plan. + planned: super::pipeline::PlannedMedia, + /// The strong entity tag the worker read every byte under. + etag: String, + /// Configured-origin identity observed in authenticated source facts. + origin_identity_sha256: String, + }, +} + +/// A retirement marker the worker asks the delivery instance to publish. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MarkerEnvelope { + /// [`PREPARATION_PROTOCOL`]. + pub protocol: String, + /// The deployment. + pub deployment_id: String, + /// The identity retired. + pub identity: String, + /// The retirement epoch. + pub epoch: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + const SECRET: &str = "publication-secret-for-tests"; + + fn text(title: Option<&'static str>, variant: &'static str) -> TextIdentity<'static> { + TextIdentity { + credential_context: "0123456789abcdef", + policy_digest: "a", + page_url: "https://www.example.com/news/a", + variant, + text: "Harbor council votes", + document_title: title, + boundary_selector: "article", + } + } + + #[test] + fn identities_bind_every_part_and_never_collide_by_concatenation() { + let base = text(Some("Title"), "").digest(); + assert_eq!(base, text(Some("Title"), "").digest(), "should be stable"); + assert_ne!(base, text(None, "").digest(), "should bind title presence"); + assert_ne!( + base, + text(Some(""), "").digest(), + "should tell empty title from none" + ); + assert_ne!( + base, + text(Some("Title"), "edition=uk").digest(), + "should bind the variant" + ); + assert_ne!( + domain_digest("d", &[b"ab", b"c"]), + domain_digest("d", &[b"a", b"bc"]), + "length prefixes should separate parts" + ); + let media = MediaIdentity { + credential_context: "0123456789abcdef", + policy_digest: "a", + page_url: "https://www.example.com/news/a", + variant: "", + carrier: Carrier::Embedded, + mime: "image/png", + source_sha256: "b", + }; + let external = MediaIdentity { + carrier: Carrier::External, + ..media + }; + assert_ne!(media.digest(), external.digest(), "should bind the carrier"); + } + + #[test] + fn exact_request_text_identity_retires_the_coarse_cache_domain_only() { + let article = text(Some("Title"), ""); + let text_hash = sha256_hex(article.text.as_bytes()); + let parts = [ + article.credential_context.as_bytes(), + article.policy_digest.as_bytes(), + article.page_url.as_bytes(), + article.variant.as_bytes(), + TEXT_PROJECTION_VERSION.as_bytes(), + text_hash.as_bytes(), + &b"1"[..], + &b"Title"[..], + article.boundary_selector.as_bytes(), + ]; + assert_ne!( + article.digest(), + domain_digest("encypher-preparation/1:text", &parts), + "old coarse-cache prepared records must not satisfy a current text lookup" + ); + assert_eq!( + article.digest(), + domain_digest("encypher-preparation/2:text", &parts), + "the new domain versions the text cache contract, not the projection" + ); + let media = MediaIdentity { + credential_context: article.credential_context, + policy_digest: article.policy_digest, + page_url: article.page_url, + variant: article.variant, + carrier: Carrier::Embedded, + mime: "image/png", + source_sha256: "b", + }; + assert_eq!( + media.digest(), + domain_digest( + "encypher-preparation/1:media", + &[ + media.credential_context.as_bytes(), + media.policy_digest.as_bytes(), + media.page_url.as_bytes(), + media.variant.as_bytes(), + media.carrier.as_str().as_bytes(), + media.mime.as_bytes(), + media.source_sha256.as_bytes(), + ], + ), + "unchanged media must keep its existing prepared identity" + ); + } + + #[test] + fn capability_admits_only_its_own_path_secret_and_lifetime() { + let nonce = "0123456789abcdef0123456789abcdef"; + let capability = snapshot_capability(SECRET, "/news/a", nonce, 1_100); + assert_eq!( + verify_snapshot_capability(SECRET, "/news/a", &capability, 1_000).as_deref(), + Some(nonce), + "should admit a fresh capability for its path" + ); + assert!( + verify_snapshot_capability(SECRET, "/news/b", &capability, 1_000).is_none(), + "should refuse another path" + ); + assert!( + verify_snapshot_capability("other-secret", "/news/a", &capability, 1_000).is_none(), + "should refuse another secret" + ); + assert!( + verify_snapshot_capability(SECRET, "/news/a", &capability, 1_101).is_none(), + "should refuse an expired capability" + ); + let far = snapshot_capability(SECRET, "/news/a", nonce, 10_000); + assert!( + verify_snapshot_capability(SECRET, "/news/a", &far, 1_000).is_none(), + "should refuse a capability valid for longer than its lifetime" + ); + } + + #[test] + fn facts_header_verifies_only_for_its_nonce_and_secret() { + let origin = SnapshotOrigin { + status: 200, + private: true, + origin_host: "origin.example.com".to_owned(), + ..SnapshotOrigin::default() + }; + let header = facts_header(SECRET, "n1", &origin); + assert_eq!( + verify_facts_header(SECRET, "n1", &header), + Some(origin), + "should read back the facts" + ); + assert!( + verify_facts_header(SECRET, "n2", &header).is_none(), + "should refuse another nonce" + ); + assert!( + verify_facts_header("other", "n1", &header).is_none(), + "should refuse another secret, as an origin-written header" + ); + } + + #[test] + fn variant_schema_admits_only_bounded_listed_pairs() { + let schema = VariantSchema(vec![ + VariantParam { + name: "edition".to_owned(), + values: vec!["us".to_owned(), "uk".to_owned()], + }, + VariantParam { + name: "amp".to_owned(), + values: vec!["1".to_owned()], + }, + ]); + assert!(schema.is_bounded(), "should be within bounds"); + assert_eq!(schema.admit("").as_deref(), Some("")); + assert_eq!( + schema.admit("edition=uk&=1").as_deref(), + Some("amp=1&edition=uk"), + "should sort admitted pairs" + ); + assert!( + schema.admit("edition=fr").is_none(), + "should refuse an unlisted value" + ); + assert!( + schema.admit("utm_source=x").is_none(), + "should refuse an unknown name" + ); + assert!( + schema.admit("edition=us&edition=uk").is_none(), + "should refuse a repeated name" + ); + let unbounded = VariantSchema(vec![VariantParam { + name: "q".to_owned(), + values: Vec::new(), + }]); + assert!(!unbounded.is_bounded(), "should refuse an open parameter"); + } + + #[test] + fn bounded_signal_keeps_items_in_order_and_counts_the_rest() { + let items: Vec = (0..400) + .map(|index| ObservedItem { + kind: ItemKind::Embedded, + identity: format!("{index:064}"), + }) + .collect(); + let target = Target { + path: "/news/a".to_owned(), + variant: String::new(), + }; + let encoded = + SignalRequest::encode_bounded("deployment", target, &items).expect("should encode"); + assert!(encoded.len() <= MAX_SIGNAL_BYTES, "should fit the bound"); + let decoded: SignalRequest = serde_json::from_slice(&encoded).expect("should decode"); + assert!(!decoded.items.is_empty(), "should carry some items"); + assert_eq!( + decoded.items[0], items[0], + "should keep the first item first" + ); + assert_eq!( + decoded.items.len() + usize::try_from(decoded.unobserved).expect("should fit"), + items.len(), + "should count every item that did not fit" + ); + } + + #[test] + fn target_refuses_queries_traversal_and_relative_paths() { + let ok = Target { + path: "/news/a".to_owned(), + variant: String::new(), + }; + assert!(ok.is_well_formed()); + for path in [ + "news/a", + "//evil.example.com/a", + "/a?b=1", + "/a/../b", + "/a#b", + ] { + let target = Target { + path: path.to_owned(), + variant: String::new(), + }; + assert!(!target.is_well_formed(), "should refuse {path}"); + } + } +} diff --git a/crates/provenance/encypher/src/prepared_probe.rs b/crates/provenance/encypher/src/prepared_probe.rs index 2521442616..a4072a921d 100644 --- a/crates/provenance/encypher/src/prepared_probe.rs +++ b/crates/provenance/encypher/src/prepared_probe.rs @@ -189,7 +189,7 @@ impl ReleaseAuthority { } } - /// An authority over test keys. + /// An authority over test keys, for tests only. #[cfg(test)] pub(crate) fn pinned(keys: Vec, floor: u64, revoked: Vec) -> Self { Self { diff --git a/crates/provenance/encypher/src/processor.rs b/crates/provenance/encypher/src/processor.rs index 3604e0388f..69c560c41a 100644 --- a/crates/provenance/encypher/src/processor.rs +++ b/crates/provenance/encypher/src/processor.rs @@ -44,6 +44,7 @@ use trusted_server_core::streaming_processor::{AsyncFinish, StreamProcessor}; mod media; mod prepared; +mod published; pub(crate) use media::EncypherMedia; @@ -118,6 +119,9 @@ pub(crate) struct PageRequest { /// which is the address the article is signed for. On a host that /// serves several publishers by path it includes the publisher's prefix. pub(crate) page_url: String, + /// The reader's query, without its `?`, which only the asynchronous + /// mode reads, to admit a configured variant. + pub(crate) query: String, /// The path prefix request addressing removed, such as /// `/www.example.com`, or empty. The path gates read the publisher's own /// path, without it. @@ -217,6 +221,18 @@ pub(crate) enum Reason { /// The document's media made its most endpoint calls; the item is signed /// on a later view. Deferred, + /// No published result exists for the item's exact identity: it is + /// delivered unsigned and the worker may be hinted. + NotPrepared, + /// The identity's result was retired; it is never served again. + Retired, + /// The retirement marker could not be read, so no result is served. + MarkerUnavailable, + /// A published record does not match the identity or item it is for. + PublishedRecordInvalid, + /// The optimizer's output is never looked up in the asynchronous mode, + /// whose standalone results are external manifests over origin bytes. + OptimizerOutputUnsupported, } impl Reason { @@ -277,6 +293,11 @@ impl Reason { Self::SvgExistingProvenance => "svg_existing_provenance", Self::InlineSvgAltered => "inline_svg_altered", Self::Deferred => "deferred", + Self::NotPrepared => "not_prepared", + Self::Retired => "retired", + Self::MarkerUnavailable => "marker_unavailable", + Self::PublishedRecordInvalid => "published_record_invalid", + Self::OptimizerOutputUnsupported => "optimizer_output_unsupported", } } } @@ -407,16 +428,13 @@ impl Signer { crate::media_api::MEDIA_SIGN_PATH ); let policy_url = format!("{}{POLICY_PATH}", config.api_base_url.trim_end_matches('/')); - let context_hash = text::content_hash_hex(&format!( - "{}\n{}\n{}", - config.api_base_url, - config.domain, - config.worker_token.expose().trim() - )); - let cache_context = context_hash - .get(..16) - .unwrap_or(context_hash.as_str()) - .to_owned(); + // One credential context for every path, the preparation worker's + // identities included. + let cache_context = crate::preparation::protocol::credential_context( + &config.api_base_url, + &config.domain, + config.worker_token.expose(), + ); Self { config, max_document_bytes, @@ -578,16 +596,49 @@ impl StreamProcessor for DocumentProcessor { } /// The article found in a held document. -struct Article { - boundary: Boundary, +pub(crate) struct Article { + pub(crate) boundary: Boundary, /// Visible text sent for signing, truncated to the API limit. - text: String, + pub(crate) text: String, /// Unicode scalars in `text`. - scalars: usize, + pub(crate) scalars: usize, /// First structural closing-head offset in the original document. - head_close: Option, + pub(crate) head_close: Option, /// First completed structural title's inner byte range. - title_range: Option<(usize, usize)>, + pub(crate) title_range: Option<(usize, usize)>, +} + +/// Find the article and its signable text, with no I/O. The one +/// implementation every path uses: the reader's, synchronous or +/// asynchronous, and the preparation worker's. +pub(crate) fn extract_article( + html: &str, + article_selector: &str, + min_text_length: usize, +) -> Result { + let boundary = + boundary::find_article_boundary(html, Some(article_selector)).ok_or(Reason::NoBoundary)?; + let article = html + .get(boundary.start..boundary.end) + .ok_or(Reason::NoBoundary)?; + if embed::has_existing_markers(article) { + return Err(Reason::AlreadySigned); + } + let metadata = text::document_metadata(html); + if metadata.manifest_present { + return Err(Reason::ManifestPresent); + } + let (visible, scalars) = text::visible_text(article, api::MAX_TEXT_SCALARS); + if scalars < min_text_length { + return Err(Reason::TextTooShort); + } + Ok(Article { + boundary, + text: visible, + scalars, + head_close: metadata.head_close, + title_range: metadata.title_range, + }) } /// Time a document's resolve may still take, on runtimes with a timer. @@ -719,29 +770,11 @@ impl Signer { /// Find the article and its signable text, with no I/O. fn extract(&self, html: &str) -> Result { - let boundary = boundary::find_article_boundary(html, Some(&self.config.article_selector)) - .ok_or(Reason::NoBoundary)?; - let article = html - .get(boundary.start..boundary.end) - .ok_or(Reason::NoBoundary)?; - if embed::has_existing_markers(article) { - return Err(Reason::AlreadySigned); - } - let metadata = text::document_metadata(html); - if metadata.manifest_present { - return Err(Reason::ManifestPresent); - } - let (visible, scalars) = text::visible_text(article, api::MAX_TEXT_SCALARS); - if scalars < self.config.min_text_length { - return Err(Reason::TextTooShort); - } - Ok(Article { - boundary, - text: visible, - scalars, - head_close: metadata.head_close, - title_range: metadata.title_range, - }) + extract_article( + html, + &self.config.article_selector, + self.config.min_text_length, + ) } /// Unix time at which an entry written now with `ttl_seconds` expires. @@ -771,6 +804,13 @@ impl Signer { Duration::from_millis(self.config.max_hold_ms).saturating_sub(SKIP_WRITE_RESERVE), ); work.start().await; + // The asynchronous mode only looks up what the delivery instance + // published; it never signs here, and never falls back to signing. + if self.is_async() { + return self + .resolve_published_document(target, services, document, article, media, &mut work) + .await; + } let policy_digest = match self .policy_digest(&request.page_url, services, services.kv_store(), &mut work) .await @@ -1355,7 +1395,8 @@ mod tests { }; use trusted_server_core::platform::{ PlatformCacheIntent, PlatformError, PlatformHttpClient, PlatformPendingRequest, - PlatformResponse, PlatformSelectResult, + PlatformResponse, PlatformSelectResult, declare_platform_after_response_handoff, + take_after_response_requests, }; const TOKEN: &str = "cwk_test_worker_token_value"; @@ -1424,6 +1465,9 @@ mod tests { unavailable: bool, /// Every read fails with an error other than unavailability. fail_reads: bool, + /// Reads of keys with this prefix fail with an error other than + /// unavailability; every other read succeeds. + fail_reads_of: Option<&'static str>, /// Every read never completes. hung_reads: bool, /// Writes to keys with this prefix never complete. @@ -1543,7 +1587,11 @@ mod tests { if self.unavailable { return Err(KvError::Unavailable); } - if self.fail_reads { + if self.fail_reads + || self + .fail_reads_of + .is_some_and(|prefix| key.starts_with(prefix)) + { return Err(KvError::LimitExceeded { message: "test failure".to_owned(), }); @@ -1789,6 +1837,7 @@ mod tests { PageRequest { method: Method::GET, page_url: PAGE_URL.to_owned(), + query: String::new(), removed_prefix: String::new(), response_status: 200, origin_response_is_private: false, @@ -3302,4 +3351,381 @@ mod tests { ); } } + + // --- asynchronous prepared mode: reader delivery ------------------------ + + const SIGNAL_SECRET: &str = "signal-secret-for-tests"; + const CANONICAL_PAGE: &str = "https://publisher.example.com/news/harbor-vote"; + const SIGNALS_URL: &str = "https://prep.publisher.example.com/v1/preparation/signals"; + const CHANGED_ARTICLE: &str = "Harbor Council Votes\ +

Harbor council votes on the ferry schedule

\ +

The harbor council met on Wednesday evening and approved a revised ferry schedule \ + for the coming season.

Residents asked for earlier departures on weekdays.

\ +
Footer
"; + + fn async_factory(reader_trigger: bool) -> Arc { + let mut config = config_json(TOKEN); + config["execution"] = json!("async_prepared"); + config["preparation"] = json!({ + "worker_url": "https://prep.publisher.example.com", + "loopback_url": "https://publisher.example.com", + "deployment_id": "test", + "public_host": "publisher.example.com", + "signal_secret": SIGNAL_SECRET, + "publication_secret": "publication-secret-for-tests", + "reader_trigger": reader_trigger, + "routes": ["/news/"], + }); + factory_with(config, 5_000_000) + } + + /// The identity the worker publishes the article of `html` under, by + /// the one item extraction both sides run. + fn text_identity(factory: &Signer, html: &str) -> (String, usize) { + let article = extract_article( + html, + &factory.config.article_selector, + factory.config.min_text_length, + ) + .expect("should find the article"); + let context = crate::preparation::items::ItemContext { + credential_context: &factory.cache_context, + policy_digest: POLICY_DIGEST, + page_url: CANONICAL_PAGE, + variant: "", + article_selector: &factory.config.article_selector, + min_text_length: factory.config.min_text_length, + max_media_bytes: factory.config.max_media_bytes, + }; + let item = crate::preparation::items::text_item(&context, html, &article); + (item.identity, item.scalars) + } + + fn publish_text(kv: &MemoryKv, identity: &str, scalars: usize) { + kv.insert( + &crate::preparation::protocol::result_key(identity), + &json!({ + "protocol": crate::preparation::protocol::PREPARATION_PROTOCOL, + "identity": identity, + "retirement_epoch": 0, + "result": { + "kind": "text", + "plan": plan_json(MARKER), + "manifest_url": MANIFEST_URL, + "scalars": scalars, + }, + }), + ); + } + + fn retire_text(kv: &MemoryKv, identity: &str) { + kv.insert( + &crate::preparation::protocol::marker_key(identity), + &json!({"epoch": 1}), + ); + } + + /// The policy cached as a delivery instance holds it. + fn cache_policy(factory: &Signer, kv: &MemoryKv) { + kv.insert( + &factory.policy_key, + &json!({ + "kind": "digest", + "policy_digest": POLICY_DIGEST, + "expires_at": unix_now() + 3600, + }), + ); + } + + fn assert_no_signing_call(http: &StubHttpClient) { + for uri in http.recorded_request_uris() { + assert!( + !uri.contains("/cdn/sign") && !uri.contains("/media/") && !uri.contains("/hash/"), + "a reader should never call the signer: {uri}" + ); + } + } + + /// The hint the reader sent, decoded, with its headers. + fn sent_hint( + http: &StubHttpClient, + ) -> ( + crate::preparation::protocol::SignalRequest, + Vec<(String, String)>, + ) { + let uris = http.recorded_request_uris(); + let index = uris + .iter() + .position(|uri| uri == SIGNALS_URL) + .expect("should send a hint"); + let body = &http.recorded_request_bodies()[index]; + ( + serde_json::from_slice(body).expect("should send a signal request"), + http.recorded_request_headers()[index].clone(), + ) + } + + #[test] + fn async_reader_applies_a_published_result_without_any_signing_call() { + let factory = async_factory(true); + let kv = Arc::new(MemoryKv::default()); + let (identity, scalars) = text_identity(&factory, ARTICLE); + publish_text(&kv, &identity, scalars); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + + let output = view( + &factory, + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + + assert_signed(&output); + assert_eq!( + http.recorded_request_uris().len(), + 1, + "only the policy authority lookup should leave the instance" + ); + assert!(http.recorded_request_uris()[0].contains("/trusted-server/policy")); + assert_no_signing_call(&http); + } + + #[test] + fn async_reader_does_not_apply_a_result_from_the_old_text_cache_contract() { + use crate::preparation::protocol::{TEXT_PROJECTION_VERSION, domain_digest}; + + let factory = async_factory(true); + let article = extract_article( + ARTICLE, + &factory.config.article_selector, + factory.config.min_text_length, + ) + .expect("should find the article"); + let title = + text::title_from_range(ARTICLE, article.title_range).expect("should find the title"); + let text_hash = crate::media_api::sha256_hex(article.text.as_bytes()); + let old_identity = domain_digest( + "encypher-preparation/1:text", + &[ + factory.cache_context.as_bytes(), + POLICY_DIGEST.as_bytes(), + CANONICAL_PAGE.as_bytes(), + b"", + TEXT_PROJECTION_VERSION.as_bytes(), + text_hash.as_bytes(), + b"1", + title.as_bytes(), + article.boundary.selector.as_bytes(), + ], + ); + for reader_trigger in [false, true] { + let factory = async_factory(reader_trigger); + let kv = Arc::new(MemoryKv::default()); + publish_text(&kv, &old_identity, article.scalars); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + if reader_trigger { + http.push_response(202, b"{}".to_vec()); + } + let output = view( + &factory, + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + assert_eq!( + output, ARTICLE, + "an old text result must leave the current delivery unsigned" + ); + assert_no_signing_call(&http); + if reader_trigger { + let (hint, _) = sent_hint(&http); + assert_eq!(hint.items[0].identity, text_identity(&factory, ARTICLE).0); + assert_ne!(hint.items[0].identity, old_identity); + } else { + assert_eq!(http.recorded_request_uris().len(), 1); + } + } + } + + #[test] + fn async_reader_miss_delivers_the_current_bytes_unsigned_and_hints_within_its_budget() { + let factory = async_factory(true); + let kv = Arc::new(MemoryKv::default()); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + http.push_response(202, b"{}".to_vec()); + + let output = view( + &factory, + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!( + output, ARTICLE, + "should deliver the current version unsigned" + ); + assert_no_signing_call(&http); + let (hint, headers) = sent_hint(&http); + let (identity, _) = text_identity(&factory, ARTICLE); + assert_eq!(hint.deployment_id, "test"); + assert_eq!(hint.target.path, "/news/harbor-vote"); + assert_eq!(hint.target.variant, ""); + assert_eq!( + hint.items, + [crate::preparation::protocol::ObservedItem { + kind: crate::preparation::protocol::ItemKind::Text, + identity, + }], + "should name the identity of exactly the bytes delivered" + ); + assert!( + headers.iter().any(|(name, value)| { + name == crate::preparation::protocol::TOKEN_HEADER && value == SIGNAL_SECRET + }), + "should authenticate with the reader-signal scope only" + ); + assert!( + !headers + .iter() + .any(|(name, value)| value.contains(TOKEN) || name == "cookie"), + "should carry no worker credential or reader state" + ); + } + + #[test] + fn async_reader_never_applies_a_result_published_for_other_bytes() { + let factory = async_factory(true); + let kv = Arc::new(MemoryKv::default()); + let (old_identity, scalars) = text_identity(&factory, ARTICLE); + publish_text(&kv, &old_identity, scalars); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + http.push_response(202, b"{}".to_vec()); + + let output = run( + &factory, + Some(request()), + &timed_services(Arc::clone(&http) as Arc, &kv), + CHANGED_ARTICLE, + ); + + assert_eq!( + output, CHANGED_ARTICLE, + "the changed article should go unsigned" + ); + let (hint, _) = sent_hint(&http); + let (new_identity, _) = text_identity(&factory, CHANGED_ARTICLE); + assert_ne!(new_identity, old_identity); + assert_eq!( + hint.items[0].identity, new_identity, + "should hint the changed version" + ); + assert_no_signing_call(&http); + } + + #[test] + fn async_reader_refuses_a_retired_or_unreadable_marker_and_does_not_hint() { + let factory = async_factory(true); + let (identity, scalars) = text_identity(&factory, ARTICLE); + + let retired = Arc::new(MemoryKv::default()); + publish_text(&retired, &identity, scalars); + retire_text(&retired, &identity); + let unreadable = Arc::new(MemoryKv { + fail_reads_of: Some("encypher:prep:v1:retired:"), + ..MemoryKv::default() + }); + publish_text(&unreadable, &identity, scalars); + + for (name, kv) in [("retired", retired), ("unreadable", unreadable)] { + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + let output = view( + &factory, + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + assert_eq!( + output, ARTICLE, + "{name}: the published result should not be applied" + ); + assert!( + !http + .recorded_request_uris() + .iter() + .any(|uri| uri == SIGNALS_URL), + "{name}: a refused result is not an unprepared item" + ); + assert_no_signing_call(&http); + } + } + + #[test] + fn async_reader_hint_to_an_unresponsive_worker_is_bounded_by_its_budget() { + let factory = async_factory(true); + let kv = Arc::new(MemoryKv::default()); + cache_policy(&factory, &kv); + let http = Arc::new(HungHttpClient::default()); + + let output = view( + &factory, + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE, "the reader should get the current version"); + assert_eq!( + http.sends.load(Ordering::SeqCst), + 1, + "the one hint should be abandoned at its budget, never retried" + ); + } + + #[test] + fn async_reader_without_a_timer_hands_the_hint_over_after_the_response() { + let factory = async_factory(true); + let kv = Arc::new(MemoryKv::default()); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + declare_platform_after_response_handoff(); + drop(take_after_response_requests()); + + let output = view( + &factory, + &services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE); + assert!( + !http + .recorded_request_uris() + .iter() + .any(|uri| uri == SIGNALS_URL), + "nothing should be sent while the reader waits" + ); + let handed = take_after_response_requests(); + assert_eq!(handed.len(), 1, "should hand one hint over"); + assert_eq!(handed[0].request.uri().to_string(), SIGNALS_URL); + let hint: crate::preparation::protocol::SignalRequest = + serde_json::from_slice(handed[0].request.body()).expect("should be a signal"); + assert_eq!(hint.items[0].identity, text_identity(&factory, ARTICLE).0); + assert_no_signing_call(&http); + } + + #[test] + fn async_reader_without_reader_trigger_never_hints() { + let factory = async_factory(false); + let kv = Arc::new(MemoryKv::default()); + let http = Arc::new(StubHttpClient::new()); + http.push_response(200, policy_response(POLICY_DIGEST)); + + let output = view( + &factory, + &timed_services(Arc::clone(&http) as Arc, &kv), + ); + + assert_eq!(output, ARTICLE); + assert_eq!( + http.recorded_request_uris().len(), + 1, + "only the policy lookup" + ); + assert_no_signing_call(&http); + } } diff --git a/crates/provenance/encypher/src/processor/media.rs b/crates/provenance/encypher/src/processor/media.rs index 842acd75ec..eba05b7e2f 100644 --- a/crates/provenance/encypher/src/processor/media.rs +++ b/crates/provenance/encypher/src/processor/media.rs @@ -27,9 +27,10 @@ use bytes::Bytes; use edgezero_core::body::Body as EdgeBody; use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, header}; use serde::{Deserialize, Serialize}; +use trusted_server_core::integrations::IntegrationRequestState; use trusted_server_core::media_signing::{ - LargeMediaJob, LargeMediaSigning, MediaJob, MediaNamespacing, MediaSigner, MediaSigning, - MediaSigningFailure, SignedMedia as DeliveredMedia, + LargeLookup, LargeLookupJob, LargeMediaJob, LargeMediaSigning, MediaJob, MediaNamespacing, + MediaSigner, MediaSigning, MediaSigningFailure, SignedMedia as DeliveredMedia, SnapshotFacts, }; use trusted_server_core::middleware::ResponseHeader; use trusted_server_core::platform::RuntimeServices; @@ -830,6 +831,13 @@ impl MediaSigner for EncypherMedia { job: &'a LargeMediaJob<'a>, ) -> LargeMediaSigning<'a> { Box::pin(async move { + // The asynchronous mode never signs in delivery; core asks + // `lookup_large` instead and never reaches this. + if self.signer.is_async() { + return Err(MediaSigningFailure { + reason: Reason::NotPrepared.code(), + }); + } match self.signer.sign_large(services, job).await { Ok(signed) => { log_media(true, Reason::Signed); @@ -875,18 +883,67 @@ impl MediaSigner for EncypherMedia { services: &'a RuntimeServices, job: &'a MediaJob<'a>, ) -> MediaSigning<'a> { + Box::pin(async move { + // The asynchronous mode looks up only what was published, and + // never falls back to signing in delivery. + let result = if self.signer.is_async() { + self.signer.published_delivery(services, job).await + } else { + self.signer.sign_delivery(services, job).await + }; + result.map_err(|reason| { + log_media(false, reason); + MediaSigningFailure { + reason: reason.code(), + } + }) + }) + } + + /// In the asynchronous mode, delivery only looks up published results. + fn lookup_only(&self) -> bool { + self.signer.is_async() + } + + fn lookup_large<'a>( + &'a self, + services: &'a RuntimeServices, + job: &'a LargeLookupJob<'a>, + ) -> LargeLookup<'a> { Box::pin(async move { self.signer - .sign_delivery(services, job) + .published_large(services, job) .await - .map_err(|reason| { - log_media(false, reason); - MediaSigningFailure { - reason: reason.code(), - } + .map_err(|reason| MediaSigningFailure { + reason: reason.code(), }) }) } + + /// A request the module's preparer verified as a preparation snapshot. + fn observes_snapshot(&self, state: &IntegrationRequestState) -> bool { + self.signer.is_async() + && state + .get::(crate::INTEGRATION_ID) + .is_some() + } + + fn snapshot_headers( + &self, + state: &IntegrationRequestState, + facts: &SnapshotFacts, + ) -> Vec { + let (Some(preparation), Some(grant)) = ( + self.signer.preparation(), + state.get::(crate::INTEGRATION_ID), + ) else { + return Vec::new(); + }; + let origin = crate::preparation::edge::media_snapshot_origin(facts); + crate::preparation::edge::snapshot_facts(preparation, &grant, &origin) + .into_iter() + .collect() + } } #[cfg(test)] diff --git a/crates/provenance/encypher/src/processor/media/tests.rs b/crates/provenance/encypher/src/processor/media/tests.rs index fb8de7cbfa..91fa99c0aa 100644 --- a/crates/provenance/encypher/src/processor/media/tests.rs +++ b/crates/provenance/encypher/src/processor/media/tests.rs @@ -4,8 +4,9 @@ use std::sync::Mutex; use edgezero_core::key_value_store::KvPage; use futures::executor::block_on; use serde_json::json; +use sha2::{Digest as _, Sha256}; use trusted_server_core::platform::test_support::{ - StubHttpClient, build_services_with_http_client, + StoppedTimer, StubHttpClient, build_services_with_http_client, }; use trusted_server_core::platform::{KvError, PlatformHttpClient, PlatformKvStore}; @@ -529,6 +530,7 @@ fn page_request() -> PageRequest { PageRequest { method: Method::GET, page_url: PAGE_URL.to_owned(), + query: String::new(), removed_prefix: String::new(), response_status: 200, origin_response_is_private: false, @@ -848,6 +850,7 @@ fn job<'a>( ) -> MediaJob<'a> { MediaJob { reader_url, + reader_query: "", content_type: "image/png", source, source_sha256: sha256, @@ -1186,3 +1189,670 @@ fn a_warm_signed_result_is_not_delivered_under_a_revoked_credential() { "should neither sign again nor deliver the cached result" ); } + +// --- asynchronous prepared mode: lookups only ------------------------------ + +const READER: &str = "https://publisher.example.com/media/a.png"; +const LINK: &str = "; rel=\"c2pa-manifest\""; + +fn async_signer() -> Arc { + let config: EncypherConfig = serde_json::from_value(json!({ + "api_base_url": "https://api.example.com", + "domain": "publisher.example.com", + "worker_token": TOKEN, + "execution": "async_prepared", + "preparation": { + "worker_url": "https://prep.publisher.example.com", + "loopback_url": "https://publisher.example.com", + "deployment_id": "test", + "public_host": "publisher.example.com", + "signal_secret": "signal-secret-for-tests", + "publication_secret": "publication-secret-for-tests", + "routes": ["/media/"], + }, + })) + .expect("should parse config"); + Arc::new(Signer::new(config, 5_000_000)) +} + +fn async_variant_signer(reader_trigger: bool) -> Arc { + let mut config = async_signer().config().clone(); + let preparation = config + .preparation + .as_mut() + .expect("should configure preparation"); + preparation.reader_trigger = reader_trigger; + preparation.variants = serde_json::from_value(json!([ + {"name": "asset", "values": ["b"]}, + {"name": "edition", "values": ["uk", "us"]}, + ])) + .expect("should parse variant schema"); + Arc::new(Signer::new(config, 5_000_000)) +} + +fn policy_http() -> Arc { + let http = Arc::new(StubHttpClient::new()); + http.push_response( + 200, + serde_json::to_vec(&json!({"policy_digest": POLICY_DIGEST})).expect("should encode"), + ); + http +} + +fn standalone_identity(signer: &Signer, source: &[u8]) -> String { + crate::preparation::protocol::MediaIdentity { + credential_context: signer.credential_context(), + policy_digest: POLICY_DIGEST, + page_url: READER, + variant: "", + carrier: crate::preparation::protocol::Carrier::External, + mime: "image/png", + source_sha256: &sha256_hex(source), + } + .digest() +} + +fn publish_record(kv: &MemoryKv, identity: &str, result: &serde_json::Value) { + kv.set( + &crate::preparation::protocol::result_key(identity), + Bytes::from( + serde_json::to_vec(&json!({ + "protocol": crate::preparation::protocol::PREPARATION_PROTOCOL, + "identity": identity, + "retirement_epoch": 0, + "result": result, + })) + .expect("should encode"), + ), + ); +} + +fn assert_lookup_only(http: &StubHttpClient) { + let uris = http.recorded_request_uris(); + assert!( + uris.iter() + .all(|uri| uri.contains("/trusted-server/policy")), + "only the policy authority lookup should leave the instance: {uris:?}" + ); +} + +#[test] +fn async_standalone_media_serves_the_origin_bytes_with_the_published_link_or_goes_unsigned() { + let signer = async_signer(); + let media = EncypherMedia::new(Arc::clone(&signer)); + assert!( + media.lookup_only(), + "core should keep and load nothing for it" + ); + let source = Bytes::from_static(SOURCE); + let sha = sha256_hex(SOURCE); + let kv = Arc::new(MemoryKv::default()); + publish_record( + &kv, + &standalone_identity(&signer, SOURCE), + &json!({ + "kind": "external", + "source_sha256": sha, + "headers": [["link", LINK], ["x-encypher-manifest-instance-id", "xmp:iid:0001"]], + }), + ); + + let http = policy_http(); + let delivered = block_on(media.sign( + &services(&http, &kv), + &job(READER, &source, &sha, Some(READER), ORIGIN), + )) + .expect("should find the published result"); + assert_eq!( + delivered.bytes, source, + "should deliver the origin's own bytes" + ); + assert!( + delivered + .headers + .iter() + .any(|(name, value)| name == "link" && value.to_str().ok() == Some(LINK)), + "should carry the published manifest link" + ); + assert_lookup_only(&http); + + // A changed image has its own identity, which nothing was published for. + let changed = Bytes::from_static(SIGNED); + let changed_sha = sha256_hex(SIGNED); + let http = policy_http(); + let missed = block_on(media.sign( + &services(&http, &kv), + &job(READER, &changed, &changed_sha, Some(READER), ORIGIN), + )) + .expect_err("should find nothing for changed bytes"); + assert_eq!(missed.reason, "not_prepared"); + assert_lookup_only(&http); +} + +#[test] +fn async_standalone_media_selects_its_admitted_variant_not_the_base_publication() { + let signer = async_variant_signer(false); + let media = EncypherMedia::new(Arc::clone(&signer)); + let source = Bytes::from_static(SOURCE); + let sha = sha256_hex(SOURCE); + let kv = Arc::new(MemoryKv::default()); + for (variant, instance) in [("", "base"), ("asset=b&edition=uk", "variant")] { + let identity = crate::preparation::protocol::MediaIdentity { + credential_context: signer.credential_context(), + policy_digest: POLICY_DIGEST, + page_url: READER, + variant, + carrier: crate::preparation::protocol::Carrier::External, + mime: "image/png", + source_sha256: &sha, + } + .digest(); + publish_record( + &kv, + &identity, + &json!({ + "kind": "external", + "source_sha256": sha, + "headers": [["link", LINK], ["x-encypher-manifest-instance-id", instance]], + }), + ); + } + // Bounded delivery hashes the actual body, so query ordering aliases + // can use the canonical identity without trusting an origin validator. + for reader_query in ["asset=b&edition=uk", "edition=uk&asset=b"] { + let http = policy_http(); + let delivered = block_on(media.sign( + &services(&http, &kv), + &MediaJob { + reader_query, + ..job(READER, &source, &sha, None, ORIGIN) + }, + )) + .expect("should find the prepared variant"); + assert!( + delivered.headers.iter().any(|(name, value)| { + name == "x-encypher-manifest-instance-id" && value == "variant" + }), + "must select the variant's publication, not the identical-byte base record" + ); + assert_eq!(delivered.bytes, source); + assert_lookup_only(&http); + } +} + +#[test] +fn async_standalone_media_requests_only_admitted_missing_variants() { + let signer = async_variant_signer(true); + let media = EncypherMedia::new(Arc::clone(&signer)); + let source = Bytes::from_static(SOURCE); + let sha = sha256_hex(SOURCE); + let reader_url = "https://publisher.example.com/media/image.png"; + for (query, admitted) in [ + ("edition=uk&asset=b", true), + ("edition=uk&edition=us", false), + ("secret=private", false), + ] { + let http = policy_http(); + if admitted { + http.push_response(202, b"{}".to_vec()); + } + let kv = Arc::new(MemoryKv::default()); + let runtime = services(&http, &kv).with_timer(Arc::new(StoppedTimer)); + let missed = block_on(media.sign( + &runtime, + &MediaJob { + reader_query: query, + ..job(reader_url, &source, &sha, None, ORIGIN) + }, + )) + .expect_err("unprepared media must remain unsigned"); + assert_eq!(missed.reason, "not_prepared"); + if admitted { + assert_eq!(http.recorded_request_methods(), ["GET", "POST"]); + let bodies = http.recorded_request_bodies(); + let signal: crate::preparation::protocol::SignalRequest = + serde_json::from_slice(bodies.last().expect("should submit preparation")) + .expect("should encode the signal"); + assert_eq!(signal.target.path, "/media/image.png"); + assert_eq!(signal.target.variant, "asset=b&edition=uk"); + let identity = crate::preparation::protocol::MediaIdentity { + credential_context: signer.credential_context(), + policy_digest: POLICY_DIGEST, + page_url: reader_url, + variant: &signal.target.variant, + carrier: crate::preparation::protocol::Carrier::External, + mime: "image/png", + source_sha256: &sha, + } + .digest(); + assert_eq!(signal.items[0].identity, identity); + } else { + assert_lookup_only(&http); + } + } +} + +#[test] +fn async_standalone_media_refuses_a_record_for_other_bytes() { + let signer = async_signer(); + let media = EncypherMedia::new(Arc::clone(&signer)); + let source = Bytes::from_static(SOURCE); + let sha = sha256_hex(SOURCE); + let kv = Arc::new(MemoryKv::default()); + publish_record( + &kv, + &standalone_identity(&signer, SOURCE), + &json!({ + "kind": "external", + "source_sha256": sha256_hex(SIGNED), + "headers": [["link", LINK]], + }), + ); + let http = policy_http(); + let refused = block_on(media.sign( + &services(&http, &kv), + &job(READER, &source, &sha, Some(READER), ORIGIN), + )) + .expect_err("should refuse a record binding other bytes"); + assert_eq!(refused.reason, "published_record_invalid"); + assert_lookup_only(&http); +} + +#[test] +fn async_large_media_is_decorated_only_for_its_exact_published_version() { + let signer = async_signer(); + let media = EncypherMedia::new(Arc::clone(&signer)); + let digest = sha256_hex(b"every byte of the version"); + let locator = |etag: &'static str, length: u64| LargeLookupJob { + reader_url: "https://publisher.example.com/media/ferry.mp4", + reader_query: "", + origin_identity: "origin", + source_origin_host: "origin.example.com", + content_type: "video/mp4", + etag, + length, + }; + let origin_identity_sha256 = sha256_hex(b"origin"); + let identity = crate::preparation::protocol::LargeLocator { + credential_context: signer.credential_context(), + policy_digest: POLICY_DIGEST, + reader_url: "https://publisher.example.com/media/ferry.mp4", + variant: "", + origin_host: "origin.example.com", + origin_identity_sha256: &origin_identity_sha256, + mime: "video/mp4", + etag: "\"v1\"", + length: 20_000_000, + } + .digest(); + let kv = Arc::new(MemoryKv::default()); + publish_record( + &kv, + &identity, + &json!({ + "kind": "large", + "sha256": digest, + "length": 20_000_000_u64, + "headers": [["link", LINK]], + }), + ); + + let http = policy_http(); + let published = + block_on(media.lookup_large(&services(&http, &kv), &locator("\"v1\"", 20_000_000))) + .expect("should find the version's record"); + assert_eq!(published.length, 20_000_000); + assert_eq!( + published + .sha256 + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + digest, + "should carry the whole-source digest verified at publication" + ); + assert_lookup_only(&http); + + for (etag, length, reason) in [ + ("\"v2\"", 20_000_000, "not_prepared"), + ("\"v1\"", 20_000_001, "not_prepared"), + ] { + let http = policy_http(); + let failure = block_on(media.lookup_large(&services(&http, &kv), &locator(etag, length))) + .expect_err("another version should find nothing"); + assert_eq!(failure.reason, reason, "{etag} {length}"); + assert_lookup_only(&http); + } +} + +#[test] +fn async_large_media_never_reuses_a_publication_after_its_configured_source_changes() { + let signer = async_signer(); + let media = EncypherMedia::new(Arc::clone(&signer)); + let source_a = b"library A's ferry video"; + let length = u64::try_from(source_a.len()).expect("should fit source length"); + // Core's configured-origin identities for one route before and after an + // operator remaps it to another folder on the same origin host. The + // public URL, fetched host, MIME, strong ETag and length all stay equal: + // validators are scoped to each resource, not to the configuration. + let identity_a = "https://origin.example.com/library-a\0/media/"; + let identity_b = "https://origin.example.com/library-b\0/media/"; + let job_a = LargeLookupJob { + reader_url: "https://publisher.example.com/media/ferry.mp4", + reader_query: "", + origin_identity: identity_a, + source_origin_host: "origin.example.com", + content_type: "video/mp4", + etag: "\"revision-1\"", + length, + }; + let job_b = LargeLookupJob { + origin_identity: identity_b, + ..job_a + }; + // A's record as publication stored it for configured source A, whose + // identity core's authenticated facts reported as this digest. + let published_a = crate::preparation::protocol::LargeLocator { + credential_context: signer.credential_context(), + policy_digest: POLICY_DIGEST, + reader_url: job_a.reader_url, + variant: "", + origin_host: job_a.source_origin_host, + origin_identity_sha256: &sha256_hex(identity_a.as_bytes()), + mime: job_a.content_type, + etag: job_a.etag, + length, + } + .digest(); + let kv = Arc::new(MemoryKv::default()); + publish_record( + &kv, + &published_a, + &json!({ + "kind": "large", + "sha256": sha256_hex(source_a), + "length": length, + "headers": [["link", LINK]], + }), + ); + + let http = policy_http(); + let published = block_on(media.lookup_large(&services(&http, &kv), &job_a)) + .expect("should reuse A's publication while the route still serves source A"); + let digest: [u8; 32] = Sha256::digest(source_a).into(); + assert_eq!(published.sha256, digest, "should carry A's verified digest"); + assert_lookup_only(&http); + + let http = policy_http(); + let failure = block_on(media.lookup_large(&services(&http, &kv), &job_b)) + .expect_err("should not attach A's manifest and digest to remapped source B"); + assert_eq!(failure.reason, "not_prepared"); + assert_lookup_only(&http); +} + +#[test] +fn async_large_media_never_reuses_a_query_free_publication_for_another_resource() { + let signer = async_variant_signer(false); + let media = EncypherMedia::new(Arc::clone(&signer)); + let source_a = b"query-free resource A"; + let source_b = b"query-pick resource B"; + assert_eq!(source_a.len(), source_b.len()); + assert_ne!(source_a, source_b); + let length = u64::try_from(source_a.len()).expect("should fit source length"); + let reader_url = "https://publisher.example.com/media/ferry.mp4"; + let identity = crate::preparation::protocol::LargeLocator { + credential_context: signer.credential_context(), + policy_digest: POLICY_DIGEST, + reader_url, + variant: "", + origin_host: "origin.example.com", + origin_identity_sha256: &sha256_hex(b"origin"), + mime: "video/mp4", + etag: "\"revision-1\"", + length, + } + .digest(); + let kv = Arc::new(MemoryKv::default()); + publish_record( + &kv, + &identity, + &json!({ + "kind": "large", + "sha256": sha256_hex(source_a), + "length": length, + "headers": [["link", LINK]], + }), + ); + // The reader selected B with ?asset=b. Its origin is allowed to use the + // same strong revision tag as A: validators are scoped to each resource. + let job = LargeLookupJob { + reader_url, + reader_query: "asset=b", + origin_identity: "origin", + source_origin_host: "origin.example.com", + content_type: "video/mp4", + etag: "\"revision-1\"", + length, + }; + let http = policy_http(); + let failure = block_on(media.lookup_large(&services(&http, &kv), &job)) + .expect_err("should not attach A's manifest and digest to query-selected B"); + assert_eq!(failure.reason, "not_prepared"); + assert_lookup_only(&http); +} + +#[test] +fn async_large_media_reuses_only_its_admitted_variant_and_refuses_unsupported_queries() { + let signer = async_variant_signer(true); + let media = EncypherMedia::new(Arc::clone(&signer)); + let source_a = b"query-free resource A"; + let source_b = b"query-pick resource B"; + assert_eq!(source_a.len(), source_b.len()); + let length = u64::try_from(source_a.len()).expect("should fit source length"); + let job = LargeLookupJob { + reader_url: "https://publisher.example.com/media/ferry.mp4", + reader_query: "", + origin_identity: "origin", + source_origin_host: "origin.example.com", + content_type: "video/mp4", + etag: "\"revision-1\"", + length, + }; + let kv = Arc::new(MemoryKv::default()); + let link_b = "; rel=\"c2pa-manifest\""; + for (variant, source, link) in [ + ("", source_a, LINK), + ("asset=b&edition=uk", source_b, link_b), + ] { + let identity = crate::preparation::protocol::LargeLocator { + credential_context: signer.credential_context(), + policy_digest: POLICY_DIGEST, + reader_url: job.reader_url, + variant, + origin_host: job.source_origin_host, + origin_identity_sha256: &sha256_hex(job.origin_identity.as_bytes()), + mime: job.content_type, + etag: job.etag, + length, + } + .digest(); + publish_record( + &kv, + &identity, + &json!({ + "kind": "large", + "sha256": sha256_hex(source), + "length": length, + "headers": [["link", link]], + }), + ); + } + for (query, source, link) in [ + ("", source_a, LINK), + ("asset=b&edition=uk", source_b, link_b), + ] { + let http = policy_http(); + let published = block_on(media.lookup_large( + &services(&http, &kv), + &LargeLookupJob { + reader_query: query, + ..job + }, + )) + .expect("should reuse this resource's own publication"); + let digest: [u8; 32] = Sha256::digest(source).into(); + assert_eq!(published.sha256, digest, "{query}"); + assert_eq!(published.length, length); + assert!( + published + .headers + .iter() + .any(|(name, value)| { name == "link" && value.to_str().ok() == Some(link) }), + "{query}: should select this resource's own manifest" + ); + assert_lookup_only(&http); + } + for query in [ + // Raw query order and encoding can select different origin resources. + // Equal per-resource validators cannot establish alias equivalence. + "edition=uk&asset=b", + "asset=%62&edition=uk", + "unknown=b", + "asset=c", + "asset=b&asset=b", + "asset=b&asset=c", + "asset=b&%61sset=b", + "asset=b&edition=unknown", + ] { + let http = policy_http(); + let runtime = services(&http, &kv).with_timer(Arc::new(StoppedTimer)); + let failure = block_on(media.lookup_large( + &runtime, + &LargeLookupJob { + reader_query: query, + ..job + }, + )) + .expect_err("should refuse an unsupported query rather than fall back to A or B"); + assert_eq!(failure.reason, "not_prepared", "{query}"); + assert!( + http.recorded_request_uris().is_empty(), + "{query}: should neither check policy nor hint an unadmitted target" + ); + } +} + +#[test] +fn async_large_media_hints_the_exact_admitted_variant_on_a_miss() { + let signer = async_variant_signer(true); + let media = EncypherMedia::new(Arc::clone(&signer)); + let job = LargeLookupJob { + reader_url: "https://publisher.example.com/media/ferry.mp4", + reader_query: "asset=b&edition=uk", + origin_identity: "origin", + source_origin_host: "origin.example.com", + content_type: "video/mp4", + etag: "\"revision-1\"", + length: 20_000_000, + }; + let identity = crate::preparation::protocol::LargeLocator { + credential_context: signer.credential_context(), + policy_digest: POLICY_DIGEST, + reader_url: job.reader_url, + variant: "asset=b&edition=uk", + origin_host: job.source_origin_host, + origin_identity_sha256: &sha256_hex(job.origin_identity.as_bytes()), + mime: job.content_type, + etag: job.etag, + length: job.length, + } + .digest(); + let http = policy_http(); + http.push_response(202, b"{}".to_vec()); + let kv = Arc::new(MemoryKv::default()); + let runtime = services(&http, &kv).with_timer(Arc::new(StoppedTimer)); + let failure = block_on(media.lookup_large(&runtime, &job)) + .expect_err("should leave the unprepared variant unsigned"); + assert_eq!(failure.reason, "not_prepared"); + assert_eq!(http.recorded_request_methods(), ["GET", "POST"]); + let bodies = http.recorded_request_bodies(); + let signal: crate::preparation::protocol::SignalRequest = + serde_json::from_slice(bodies.last().expect("should send a hint")) + .expect("should encode the signal protocol"); + assert_eq!(signal.target.path, "/media/ferry.mp4"); + assert_eq!(signal.target.variant, "asset=b&edition=uk"); + assert_eq!(signal.items.len(), 1); + assert_eq!(signal.items[0].identity, identity); + assert_eq!( + signal.items[0].kind, + crate::preparation::protocol::ItemKind::Large + ); +} + +#[test] +fn only_a_granted_snapshot_read_is_observed_and_its_facts_are_authenticated() { + let signer = async_signer(); + let media = EncypherMedia::new(Arc::clone(&signer)); + let mut request = Request::builder() + .uri(READER) + .body(EdgeBody::empty()) + .expect("should build"); + assert!( + !media.observes_snapshot(&IntegrationRequestState::of(&request)), + "an ordinary reader's request is never a snapshot" + ); + let nonce = "0123456789abcdef0123456789abcdef"; + IntegrationRequestState::observe( + &mut request, + crate::INTEGRATION_ID, + crate::preparation::edge::SnapshotGrant { + nonce: nonce.to_owned(), + }, + ); + let state = IntegrationRequestState::of(&request); + assert!(media.observes_snapshot(&state)); + assert!( + !EncypherMedia::new(signer_synchronous()).observes_snapshot(&state), + "the synchronous mode observes nothing" + ); + + let origin_identity_sha256 = sha256_hex(b"https://origin.example.com\0/media/"); + let facts = SnapshotFacts { + status: StatusCode::OK, + private: true, + no_transform: false, + manifest_link: false, + content_encoding: String::new(), + origin_host: "origin.example.com".to_owned(), + origin_identity_sha256: origin_identity_sha256.clone(), + source_reference: Some("https://origin.example.com/a.png".to_owned()), + }; + let headers = media.snapshot_headers(&state, &facts); + let (name, value) = headers.first().expect("should write the facts header"); + assert_eq!(name.as_str(), crate::preparation::protocol::FACTS_HEADER); + let reported = crate::preparation::protocol::verify_facts_header( + "publication-secret-for-tests", + nonce, + value.to_str().expect("should be text"), + ) + .expect("should verify for the grant's nonce"); + assert!(reported.private, "should report the origin's own privacy"); + assert_eq!(reported.origin_host, "origin.example.com"); + assert_eq!( + reported.origin_identity_sha256, origin_identity_sha256, + "should authenticate core's configured-source digest with the facts" + ); + assert!( + crate::preparation::protocol::verify_facts_header( + "publication-secret-for-tests", + "fedcba9876543210fedcba9876543210", + value.to_str().expect("should be text"), + ) + .is_none(), + "should verify for no other read" + ); +} + +fn signer_synchronous() -> Arc { + signer() +} diff --git a/crates/provenance/encypher/src/processor/published.rs b/crates/provenance/encypher/src/processor/published.rs new file mode 100644 index 0000000000..1bce7200c9 --- /dev/null +++ b/crates/provenance/encypher/src/processor/published.rs @@ -0,0 +1,661 @@ +//! Reader delivery in the asynchronous prepared mode: published results +//! only, never a signing call. +//! +//! Each item's identity is computed from the exact projection or bytes this +//! reader is about to receive, through the same functions the preparation +//! worker runs on its snapshot. A result is used only when the delivery +//! instance published one under exactly that identity and no retirement +//! marker stands for it; a marker that cannot be read refuses the result. +//! The lookup is a plain read of the runtime KV store: it never starts or +//! joins a loader, and never waits for preparation. +//! +//! Every item without a result is delivered as it is, unsigned, and on a +//! deployment with `reader_trigger` the worker is handed one bounded hint: +//! within `admission_ms` on a platform with a timer, or after the response +//! on a platform that declared that lifetime. A hint is acknowledged only +//! when the worker answers that it committed it; it is never durable on +//! the edge, and publication events, warming and reconciliation stay the +//! recovery path for a hint that was lost. + +use std::time::Duration; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use futures::future::{Either, select}; +use http::{HeaderName, HeaderValue, Method, Request, StatusCode, header}; +use trusted_server_core::integrations::ensure_integration_backend_with_timeout; +use trusted_server_core::media_signing::{LargeLookupJob, MediaJob, PublishedLarge, SignedMedia}; +use trusted_server_core::middleware::ResponseHeader; +use trusted_server_core::platform::{ + AfterResponseRequest, KvError, PlatformHttpRequest, PlatformKvStore, RuntimeServices, + hand_over_after_response, platform_offers_after_response_handoff, +}; + +use super::{ + Article, Deadline, Reason, SKIP_WRITE_RESERVE, Signer, Surface, Target, embedded, + is_excluded_prefix, log_outcome, render, render_reason, +}; +use crate::INTEGRATION_ID; +use crate::api; +use crate::media_api::{canonical_media_type, sha256_hex, valid_origin_host}; +use crate::preparation::items::{ + DocumentKind, EmbeddedItem, ItemContext, embedded_edit, embedded_items, text_item, +}; +use crate::preparation::protocol::{ + Carrier, ItemKind, LargeLocator, MediaIdentity, ObservedItem, PREPARATION_PROTOCOL, + PublishedRecord, PublishedResult, SIGNALS_PATH, SignalRequest, TOKEN_HEADER, Target as Goal, + marker_key, result_key, +}; +use crate::preparation::{Execution, PreparationConfig}; + +/// What one lookup found. +enum Lookup { + /// A published result for exactly the identity. + Found(PublishedResult), + /// No result: the item is unprepared. + Missing, +} + +impl Signer { + /// The module's settings. + pub(crate) fn config(&self) -> &crate::EncypherConfig { + &self.config + } + + /// The host prepared addresses name, see + /// [`PreparationConfig::public_host`]. + pub(crate) fn public_host(&self) -> &str { + self.preparation() + .map_or(self.config.domain.as_str(), |preparation| { + preparation.public_host.as_str() + }) + } + + /// Whether the module runs in the asynchronous prepared mode. + pub(crate) fn is_async(&self) -> bool { + self.config.execution == Execution::AsyncPrepared && self.config.preparation.is_some() + } + + /// The asynchronous mode's settings. + pub(crate) fn preparation(&self) -> Option<&PreparationConfig> { + self.config + .preparation + .as_ref() + .filter(|_| self.config.execution == Execution::AsyncPrepared) + } + + /// The credential context identities bind. + pub(crate) fn credential_context(&self) -> &str { + &self.cache_context + } + + /// The published result for `identity`, after its retirement marker is + /// checked: present refuses it, unreadable refuses it. + async fn published( + &self, + kv: &dyn PlatformKvStore, + identity: &str, + work: &mut Deadline<'_>, + ) -> Result { + match work.race(kv.get_bytes(&marker_key(identity))).await { + None => return Err(Reason::Timeout), + Some(Err(KvError::Unavailable)) => return Err(Reason::KvUnavailable), + Some(Err(_)) => return Err(Reason::MarkerUnavailable), + Some(Ok(Some(_))) => return Err(Reason::Retired), + Some(Ok(None)) => {} + } + let bytes = match work.race(kv.get_bytes(&result_key(identity))).await { + None => return Err(Reason::Timeout), + Some(Err(KvError::Unavailable)) => return Err(Reason::KvUnavailable), + Some(Err(_)) => return Err(Reason::KvError), + Some(Ok(None)) => return Ok(Lookup::Missing), + Some(Ok(Some(bytes))) => bytes, + }; + let record: PublishedRecord = + serde_json::from_slice(&bytes).map_err(|_| Reason::PublishedRecordInvalid)?; + if record.protocol != PREPARATION_PROTOCOL || record.identity != identity { + return Err(Reason::PublishedRecordInvalid); + } + Ok(Lookup::Found(record.result)) + } + + /// The admitted variant of a reader's `query`, or `None` where it names + /// anything the schema does not admit. + fn admitted_variant(&self, query: &str) -> Option { + self.preparation() + .and_then(|preparation| preparation.variants.admit(query)) + } + + /// Looks up every item of a held document and applies what was + /// published: the article's plan first, then embedded media, each by + /// its own identity, so one unprepared item leaves its siblings signed. + /// Returns the bytes to emit. + pub(super) async fn resolve_published_document( + &self, + target: &Target<'_>, + services: &RuntimeServices, + document: &str, + article: Option
, + media: embedded::Scan, + work: &mut Deadline<'_>, + ) -> Vec { + let request = target.request; + let kv = services.kv_store(); + let Some(page_url) = self.canonical_page_url(&request.page_url) else { + return document.as_bytes().to_vec(); + }; + let variant = self.admitted_variant(&request.query); + // A query outside the schema is looked up as the plain address: + // every identity binds the exact text or bytes, so a result found + // is exactly this content's. Only a hint needs an admitted variant. + let lookup_variant = variant.as_deref().unwrap_or_default(); + let mut misses: Vec = Vec::new(); + let mut output = document.to_owned(); + + if let Some(article) = &article { + let policy_digest = match self.policy_digest(&page_url, services, kv, work).await { + Ok(policy_digest) => Some(policy_digest), + Err(reason) => { + log_outcome(false, reason); + None + } + }; + if let Some(policy_digest) = policy_digest { + let context = self.item_context(&page_url, &policy_digest, lookup_variant); + let item = text_item(&context, document, article); + match self.published(kv, &item.identity, work).await { + Ok(Lookup::Found(PublishedResult::Text { + plan, + manifest_url, + scalars, + })) if scalars == article.scalars + && api::validate_plan(&plan, manifest_url.as_deref(), article.scalars) + .is_ok() => + { + match render( + document, + article, + &plan, + manifest_url.as_deref(), + self.max_document_bytes, + ) { + Ok(signed) => { + log_outcome(true, Reason::CacheHit); + output = signed; + } + Err(error) => log_outcome(false, render_reason(error)), + } + } + Ok(Lookup::Found(_)) => log_outcome(false, Reason::PublishedRecordInvalid), + Ok(Lookup::Missing) => { + log_outcome(false, Reason::NotPrepared); + misses.push(ObservedItem { + kind: ItemKind::Text, + identity: item.identity, + }); + } + Err(reason) => log_outcome(false, reason), + } + } + } + + if !media.is_empty() { + // Media is looked up only under the policy the service answers + // now, as in the synchronous mode, so a revoked credential stops + // every published copy. + match self.request_policy(&page_url, services, work).await { + Ok(policy_digest) => { + let context = self.item_context(&page_url, &policy_digest, lookup_variant); + let kind = match target.surface { + Surface::Page => DocumentKind::Page, + Surface::Stylesheet => DocumentKind::Stylesheet, + }; + let (items, refused) = embedded_items(&context, &output, kind, target.policies); + for reason in refused { + log::info!("encypher: media outcome=unsigned reason={reason}"); + } + let mut edits = Vec::new(); + for item in &items { + match self.published_embedded(kv, &output, item, work).await { + Ok(Some(edit)) => { + log::info!("encypher: media outcome=signed reason=cache_hit"); + edits.push(edit); + } + Ok(None) => { + log::info!("encypher: media outcome=unsigned reason=not_prepared"); + misses.push(ObservedItem { + kind: ItemKind::Embedded, + identity: item.identity.clone(), + }); + } + Err(reason) => log::info!( + "encypher: media outcome=unsigned reason={}", + reason.code() + ), + } + } + if !edits.is_empty() { + match embedded::splice(&output, &edits, self.max_document_bytes) { + Some(spliced) => output = spliced, + None => log::info!( + "encypher: media outcome=unsigned reason=output_too_large" + ), + } + } + } + Err(reason) => super::media::log_unsigned(&media, reason), + } + } + + self.hint(services, &page_url, variant.as_deref(), &misses) + .await; + output.into_bytes() + } + + /// The address identities name for a reader's `page_url` on a claimed + /// host: the configured public host and the path, `https`, without the + /// query. `None` for an address outside the claim. + pub(crate) fn canonical_page_url(&self, page_url: &str) -> Option { + let parsed = url::Url::parse(page_url).ok()?; + let host = parsed.host_str()?; + if !self.claims_host(host) { + return None; + } + Some(format!("https://{}{}", self.public_host(), parsed.path())) + } + + /// What every identity of a document at `page_url` binds. + fn item_context<'a>( + &'a self, + page_url: &'a str, + policy_digest: &'a str, + variant: &'a str, + ) -> ItemContext<'a> { + ItemContext { + credential_context: &self.cache_context, + policy_digest, + page_url, + variant, + article_selector: &self.config.article_selector, + min_text_length: self.config.min_text_length, + max_media_bytes: self.config.max_media_bytes, + } + } + + /// The edit an embedded item's published output makes, `None` where + /// none is published. The output must be exactly the one its record + /// names, made from exactly this item's source. + async fn published_embedded( + &self, + kv: &dyn PlatformKvStore, + document: &str, + item: &EmbeddedItem, + work: &mut Deadline<'_>, + ) -> Result, Reason> { + match self.published(kv, &item.identity, work).await? { + Lookup::Missing => Ok(None), + Lookup::Found(PublishedResult::Embedded { + source_sha256, + output_sha256, + content_type, + output_b64, + .. + }) => { + if source_sha256 != item.sha256 || content_type != item.mime { + return Err(Reason::PublishedRecordInvalid); + } + let output = STANDARD + .decode(output_b64) + .map_err(|_| Reason::PublishedRecordInvalid)?; + if sha256_hex(&output) != output_sha256 { + return Err(Reason::PublishedRecordInvalid); + } + if item.mime == "image/svg+xml" { + embedded::verify_svg_carrier(&item.source, &output)?; + } + embedded_edit(document, item, &output) + .map(Some) + .ok_or(Reason::PublishedRecordInvalid) + } + Lookup::Found(_) => Err(Reason::PublishedRecordInvalid), + } + } + + /// The published representation of one captured standalone item: the + /// origin's own bytes, which the record binds by digest, with the + /// manifest `Link` and ids it names. Never a signing call. + pub(super) async fn published_delivery( + &self, + services: &RuntimeServices, + job: &MediaJob<'_>, + ) -> Result { + let reader_url = url::Url::parse(job.reader_url).map_err(|_| Reason::OutsideClaim)?; + let host = reader_url.host_str().ok_or(Reason::OutsideClaim)?; + if !matches!(reader_url.scheme(), "https" | "http") + || !self.claims_host(host) + || is_excluded_prefix(reader_url.path()) + { + return Err(Reason::OutsideClaim); + } + let page_url = self + .canonical_page_url(job.reader_url) + .ok_or(Reason::OutsideClaim)?; + if job.optimizer_output { + return Err(Reason::OptimizerOutputUnsupported); + } + if job.source.len() > self.config.max_media_bytes { + return Err(Reason::MediaTooLarge); + } + let sha256 = sha256_hex(job.source); + if sha256 != job.source_sha256 { + return Err(Reason::SourceDigestMismatch); + } + let variant = self.admitted_variant(job.reader_query); + let mut work = Deadline::new( + services.timer(), + Duration::from_millis(self.config.max_hold_ms).saturating_sub(SKIP_WRITE_RESERVE), + ); + work.start().await; + let policy_digest = self.request_policy(&page_url, services, &mut work).await?; + let mime = canonical_media_type(job.content_type); + let identity = MediaIdentity { + credential_context: &self.cache_context, + policy_digest: &policy_digest, + page_url: &page_url, + variant: variant.as_deref().unwrap_or_default(), + carrier: Carrier::External, + mime: &mime, + source_sha256: &sha256, + } + .digest(); + match self + .published(services.kv_store(), &identity, &mut work) + .await? + { + Lookup::Found(PublishedResult::External { + source_sha256, + headers, + }) if source_sha256 == sha256 => Ok(SignedMedia { + bytes: job.source.clone(), + content_type: HeaderValue::from_str(job.content_type) + .map_err(|_| Reason::InvalidSignedResponse)?, + headers: response_headers(&headers)?, + namespace: self.kept_namespace(&policy_digest), + }), + Lookup::Found(_) => Err(Reason::PublishedRecordInvalid), + Lookup::Missing => { + self.hint( + services, + &page_url, + variant.as_deref(), + &[ObservedItem { + kind: ItemKind::Media, + identity, + }], + ) + .await; + Err(Reason::NotPrepared) + } + } + } + + /// The published headers of one large version, by its validator + /// locator. The record carries the whole-source digest the worker and + /// the delivery instance verified for that version. + pub(super) async fn published_large( + &self, + services: &RuntimeServices, + job: &LargeLookupJob<'_>, + ) -> Result { + let reader_url = url::Url::parse(job.reader_url).map_err(|_| Reason::OutsideClaim)?; + let host = reader_url.host_str().ok_or(Reason::OutsideClaim)?; + if !self.claims_host(host) || is_excluded_prefix(reader_url.path()) { + return Err(Reason::OutsideClaim); + } + let page_url = self + .canonical_page_url(job.reader_url) + .ok_or(Reason::OutsideClaim)?; + let variant = self + .admitted_variant(job.reader_query) + .ok_or(Reason::NotPrepared)?; + // The origin saw the raw query. Ordering and encoding aliases need + // not name the same resource, even when their strong ETags match. + if variant != job.reader_query { + return Err(Reason::NotPrepared); + } + // Core names the configured source as well as its host: the same + // host can serve another source under equal validators. + if !valid_origin_host(job.source_origin_host) || job.origin_identity.is_empty() { + return Err(Reason::InvalidOriginHost); + } + let mut work = Deadline::new( + services.timer(), + Duration::from_millis(self.config.max_hold_ms).saturating_sub(SKIP_WRITE_RESERVE), + ); + work.start().await; + let policy_digest = self.request_policy(&page_url, services, &mut work).await?; + let mime = canonical_media_type(job.content_type); + let origin_identity_sha256 = sha256_hex(job.origin_identity.as_bytes()); + let identity = LargeLocator { + credential_context: &self.cache_context, + policy_digest: &policy_digest, + reader_url: &page_url, + variant: &variant, + origin_host: job.source_origin_host, + origin_identity_sha256: &origin_identity_sha256, + mime: &mime, + etag: job.etag, + length: job.length, + } + .digest(); + match self + .published(services.kv_store(), &identity, &mut work) + .await? + { + Lookup::Found(PublishedResult::Large { + sha256, + length, + headers, + }) => { + let digest = decode_digest(&sha256).ok_or(Reason::PublishedRecordInvalid)?; + if job.length != 0 && length != job.length { + return Err(Reason::PublishedRecordInvalid); + } + Ok(PublishedLarge { + headers: response_headers(&headers)?, + sha256: digest, + length, + }) + } + Lookup::Found(_) => Err(Reason::PublishedRecordInvalid), + Lookup::Missing => { + self.hint( + services, + &page_url, + Some(&variant), + &[ObservedItem { + kind: ItemKind::Large, + identity, + }], + ) + .await; + Err(Reason::NotPrepared) + } + } + } + + /// Hands the worker one bounded hint about `items` of the target at + /// `page_url`, where the deployment asked for reader hints and the + /// target is a configured route with an admitted variant. Never awaited + /// past `admission_ms`, never retried, never a signing call. + async fn hint( + &self, + services: &RuntimeServices, + page_url: &str, + variant: Option<&str>, + items: &[ObservedItem], + ) { + let Some(preparation) = self.preparation().filter(|config| config.reader_trigger) else { + return; + }; + if items.is_empty() { + return; + } + let Some(variant) = variant else { + log::info!("encypher: hint outcome=not_sent reason=variant_not_admitted"); + return; + }; + let Some(path) = url::Url::parse(page_url) + .ok() + .map(|url| url.path().to_owned()) + .filter(|path| preparation.admits_path(path)) + else { + log::info!("encypher: hint outcome=not_sent reason=route_not_configured"); + return; + }; + let target = Goal { + path, + variant: variant.to_owned(), + }; + if !target.is_well_formed() { + log::info!("encypher: hint outcome=not_sent reason=target_invalid"); + return; + } + let Some(body) = SignalRequest::encode_bounded(&preparation.deployment_id, target, items) + else { + log::info!("encypher: hint outcome=not_sent reason=hint_too_large"); + return; + }; + let budget = Duration::from_millis(preparation.admission_ms); + let Ok(backend) = ensure_integration_backend_with_timeout( + services, + &preparation.worker_url, + INTEGRATION_ID, + budget, + ) else { + log::info!("encypher: hint outcome=not_sent reason=backend"); + return; + }; + let Some(request) = signal_request(preparation, Bytes::from(body)) else { + log::info!("encypher: hint outcome=not_sent reason=request_build"); + return; + }; + if let Some(timer) = services.timer() { + let (parts, body) = request.into_parts(); + let request = Request::from_parts(parts, edgezero_core::body::Body::from_bytes(body)); + let send = services.http_client().send( + PlatformHttpRequest::new(request, backend) + .with_cache_bypass() + .with_max_response_body_bytes(1024), + ); + let outcome = match select(Box::pin(send), timer.sleep(budget)).await { + Either::Left((Ok(answer), _)) => match answer.response.status() { + StatusCode::ACCEPTED | StatusCode::OK => "admitted", + StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE => { + "refused_retryable" + } + _ => "refused", + }, + Either::Left((Err(_), _)) => "unavailable", + // No acknowledgement within the budget: the hint may or may + // not have been committed, and a later identical one is safe. + Either::Right(_) => "admission_unknown", + }; + log::info!("encypher: hint outcome={outcome} items={}", items.len()); + } else if platform_offers_after_response_handoff() { + let handed = hand_over_after_response(AfterResponseRequest { + request, + backend_name: backend, + }); + log::info!( + "encypher: hint outcome={} items={}", + if handed { + "handed_over_after_response" + } else { + "handoff_full" + }, + items.len() + ); + } else { + log::info!("encypher: hint outcome=not_sent reason=reader_trigger_unavailable"); + } + } +} + +/// The hint request, authenticated with the reader-signal scope only. +fn signal_request(preparation: &PreparationConfig, body: Bytes) -> Option> { + let mut token = HeaderValue::from_str(preparation.signal_secret.expose().trim()).ok()?; + token.set_sensitive(true); + Request::builder() + .method(Method::POST) + .uri(format!( + "{}{SIGNALS_PATH}", + preparation.worker_url.trim_end_matches('/') + )) + .header(header::CONTENT_TYPE, "application/json") + .header(TOKEN_HEADER, token) + .body(body) + .ok() +} + +/// A record's headers as response headers, refusing any that does not +/// parse. +fn response_headers(headers: &[(String, String)]) -> Result, Reason> { + headers + .iter() + .map(|(name, value)| { + Ok(( + HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| Reason::PublishedRecordInvalid)?, + HeaderValue::from_str(value).map_err(|_| Reason::PublishedRecordInvalid)?, + )) + }) + .collect() +} + +/// A lower case hex SHA-256 as its 32 bytes. +fn decode_digest(hex: &str) -> Option<[u8; 32]> { + if !crate::preparation::protocol::is_digest(hex) { + return None; + } + let mut digest = [0_u8; 32]; + for (index, slot) in digest.iter_mut().enumerate() { + *slot = u8::from_str_radix(hex.get(index * 2..index * 2 + 2)?, 16).ok()?; + } + Some(digest) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn published_digest_and_headers_refuse_anything_malformed() { + let hex = "ab".repeat(32); + assert_eq!( + decode_digest(&hex), + Some([0xab; 32]), + "should decode a lower case digest" + ); + assert!( + decode_digest(&"AB".repeat(32)).is_none(), + "should refuse upper case" + ); + assert!( + decode_digest("abc").is_none(), + "should refuse a short digest" + ); + let headers = response_headers(&[( + "link".to_owned(), + "; rel=\"c2pa-manifest\"".to_owned(), + )]) + .expect("should parse a valid header"); + assert_eq!(headers.len(), 1); + assert_eq!( + response_headers(&[("bad name".to_owned(), "v".to_owned())]), + Err(Reason::PublishedRecordInvalid), + "a record with a header that does not parse is refused whole" + ); + } +} diff --git a/crates/provenance/encypher/src/tests.rs b/crates/provenance/encypher/src/tests.rs index bd2ddb588d..9b1ccb86e4 100644 --- a/crates/provenance/encypher/src/tests.rs +++ b/crates/provenance/encypher/src/tests.rs @@ -1715,3 +1715,334 @@ fn publisher_refuses_an_oversized_protected_original_instead_of_serving_rewritte ); } } + +// --- asynchronous prepared mode through the publisher path ---------------- + +const PUBLICATION: &str = "publication-secret-for-tests"; +const SNAPSHOT_NONCE: &str = "0123456789abcdef0123456789abcdef"; + +fn async_table(reader_trigger: bool) -> String { + format!( + "{}execution = \"async_prepared\"\n\n[provenance.encypher.preparation]\n\ + worker_url = \"https://prep.publisher.example.com\"\n\ + loopback_url = \"https://publisher.example.com\"\ndeployment_id = \"test\"\n\ + public_host = \"publisher.example.com\"\n\ + signal_secret = \"signal-secret-for-tests\"\n\ + publication_secret = \"{PUBLICATION}\"\nreader_trigger = {reader_trigger}\n\ + routes = [\"/news/\"]\n", + table() + ) +} + +fn async_placed() -> Settings { + page_settings(&module_settings(&async_table(false), true)) +} + +fn async_config(value: serde_json::Value) -> EncypherConfig { + let mut config = config_value(); + config["execution"] = json!("async_prepared"); + config["preparation"] = value; + serde_json::from_value(config).expect("should parse") +} + +fn preparation_value(reader_trigger: bool, public_host: &str) -> serde_json::Value { + json!({ + "worker_url": "https://prep.publisher.example.com", + "loopback_url": "https://publisher.example.com", + "deployment_id": "test", + "public_host": public_host, + "signal_secret": "signal-secret-for-tests", + "publication_secret": PUBLICATION, + "reader_trigger": reader_trigger, + "routes": ["/news/"], + }) +} + +#[test] +fn async_mode_needs_its_table_a_claimed_public_host_and_a_real_hint_lifetime() { + let mut without_table = config_value(); + without_table["execution"] = json!("async_prepared"); + assert!( + parsed(&without_table).is_err(), + "async_prepared without a preparation table would never sign anything" + ); + + let elsewhere = async_config(preparation_value(false, "cdn.other.example")); + assert!( + require_claimed_public_host(&elsewhere).is_err(), + "a public host off the claim would name addresses the claim does not cover" + ); + for host in ["publisher.example.com", "www.publisher.example.com"] { + assert!(require_claimed_public_host(&async_config(preparation_value(false, host))).is_ok()); + } + + let hinting = async_config(preparation_value(true, "publisher.example.com")); + let neither = HintLifetimes { + timer: false, + after_response: false, + }; + let refusal = require_reader_trigger_capability(&hinting, neither) + .expect_err("should refuse reader hints with neither lifetime"); + assert!(format!("{refusal:?}").contains("reader_trigger")); + for lifetimes in [ + HintLifetimes { + timer: true, + after_response: false, + }, + HintLifetimes { + timer: false, + after_response: true, + }, + ] { + assert!(require_reader_trigger_capability(&hinting, lifetimes).is_ok()); + } + let warming_only = async_config(preparation_value(false, "publisher.example.com")); + assert!( + require_reader_trigger_capability(&warming_only, neither).is_ok(), + "warming, publication events and reconciliation need no hint lifetime" + ); +} + +fn snapshot_request(capability: Option<&str>) -> Request { + let mut builder = Request::builder() + .method(Method::GET) + .uri("https://publisher.example.com/news/harbor-vote") + .header(header::HOST, "publisher.example.com"); + if let Some(capability) = capability { + builder = builder.header(preparation::protocol::CAPABILITY_HEADER, capability); + } + builder + .body(EdgeBody::empty()) + .expect("should build the snapshot request") +} + +fn capability_for(secret: &str, path_and_query: &str) -> String { + preparation::protocol::snapshot_capability( + secret, + path_and_query, + SNAPSHOT_NONCE, + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .expect("should read the clock") + .as_secs() + + 60, + ) +} + +fn reported_facts( + response: &http::Response, +) -> Option { + response + .headers() + .get(preparation::protocol::FACTS_HEADER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + preparation::protocol::verify_facts_header(PUBLICATION, SNAPSHOT_NONCE, value) + }) +} + +fn forwarded_capability(stub: &StubHttpClient) -> bool { + stub.recorded_request_headers().iter().any(|headers| { + headers + .iter() + .any(|(name, _)| name == preparation::protocol::CAPABILITY_HEADER) + }) +} + +#[test] +fn a_snapshot_takes_the_reader_path_unsigned_and_reports_the_origin_s_own_privacy() { + for streaming in [false, true] { + let stub = origin_stub("public, max-age=60"); + let capability = capability_for(PUBLICATION, "/news/harbor-vote"); + let response = served_response( + &async_placed(), + &services_for(&stub), + snapshot_request(Some(&capability)), + streaming, + ); + + let facts = reported_facts(&response).expect("should report authenticated origin facts"); + assert!( + !facts.private, + "streaming={streaming}: a public page is not private" + ); + assert_eq!(facts.status, 200); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, no-store"), + "streaming={streaming}: the snapshot's own transport stays private" + ); + let body = block_on(response.into_body().into_bytes_bounded(16 * 1024 * 1024)) + .expect("should read the body"); + let reader_stub = origin_stub("public, max-age=60"); + let reader = served_response( + &async_placed(), + &services_for(&reader_stub), + snapshot_request(None), + streaming, + ); + let reader_body = block_on(reader.into_body().into_bytes_bounded(16 * 1024 * 1024)) + .expect("should read the unsigned reader's transformed body"); + assert_eq!( + body.as_ref(), + reader_body.as_ref(), + "streaming={streaming}: the snapshot is the page as readers get it, unsigned" + ); + assert_eq!( + stub.recorded_request_methods(), + ["GET"], + "streaming={streaming}: the origin only, no policy, sign or lookup" + ); + assert!( + !forwarded_capability(&stub), + "streaming={streaming}: the capability never reaches the origin" + ); + } + + let stub = origin_stub("private, max-age=0"); + let capability = capability_for(PUBLICATION, "/news/harbor-vote"); + let response = served_response( + &async_placed(), + &services_for(&stub), + snapshot_request(Some(&capability)), + false, + ); + // Either the facts say the page is one reader's, or core never handed + // a reader's own page to the middleware and no facts exist; the snapshot + // route refuses both, as `source_private` and `snapshot_unobserved`. + if let Some(facts) = reported_facts(&response) { + assert!( + facts.private, + "a private origin page should be reported private" + ); + } + assert!(!forwarded_capability(&stub)); +} + +#[test] +fn a_reader_cannot_set_snapshot_state_and_its_capability_header_never_travels() { + for (name, capability) in [ + ( + "forged secret", + capability_for("a-reader-guessing", "/news/harbor-vote"), + ), + ("another path", capability_for(PUBLICATION, "/news/other")), + ] { + let stub = origin_stub("public, max-age=60"); + stub.push_response( + 200, + serde_json::to_vec(&json!({ "policy_digest": POLICY_DIGEST })).expect("should encode"), + ); + let response = served_response( + &async_placed(), + &services_for(&stub), + snapshot_request(Some(&capability)), + false, + ); + assert!( + response + .headers() + .get(preparation::protocol::FACTS_HEADER) + .is_none(), + "{name}: an ordinary read reports no snapshot facts" + ); + assert!(!forwarded_capability(&stub), "{name}: never forwarded"); + assert!( + !stub + .recorded_request_methods() + .iter() + .any(|method| method == "POST"), + "{name}: the asynchronous reader never signs" + ); + } + + // The synchronous mode removes the header too, and signs as before. + let stub = stub("public, max-age=60"); + let capability = capability_for(PUBLICATION, "/news/harbor-vote"); + let page = served( + &placed(), + &services_for(&stub), + snapshot_request(Some(&capability)), + false, + ); + assert!(page.contains(MARKER), "the synchronous mode still signs"); + assert!(!forwarded_capability(&stub)); +} + +#[test] +fn the_asynchronous_publisher_path_applies_a_published_plan_and_never_signs() { + let config = parsed(&config_value()).expect("should parse"); + let article = + processor::extract_article(ARTICLE, &config.article_selector, config.min_text_length) + .expect("should find the article"); + let context_hash = preparation::protocol::credential_context( + "https://api.example.com", + "publisher.example.com", + TOKEN, + ); + let context = preparation::items::ItemContext { + credential_context: &context_hash, + policy_digest: POLICY_DIGEST, + page_url: "https://publisher.example.com/news/harbor-vote", + variant: "", + article_selector: &config.article_selector, + min_text_length: config.min_text_length, + max_media_bytes: config.max_media_bytes, + }; + let item = preparation::items::text_item(&context, ARTICLE, &article); + + for (published, expect_signed) in [(true, true), (false, false)] { + let kv = Arc::new(MemoryKv::default()); + if published { + block_on( + kv.put_bytes( + &preparation::protocol::result_key(&item.identity), + bytes::Bytes::from( + serde_json::to_vec(&json!({ + "protocol": preparation::protocol::PREPARATION_PROTOCOL, + "identity": item.identity, + "retirement_epoch": 0, + "result": { + "kind": "text", + "plan": { + "index_unit": "codepoint", + "operations": [{ "insert_after_index": 5, "marker": MARKER }], + }, + "manifest_url": MANIFEST_URL, + "scalars": item.scalars, + }, + })) + .expect("should encode"), + ), + ), + ) + .expect("should store"); + } + let stub = origin_stub("public, max-age=60"); + stub.push_response( + 200, + serde_json::to_vec(&json!({ "policy_digest": POLICY_DIGEST })).expect("should encode"), + ); + + let page = served( + &async_placed(), + &services_for_kv(&stub, kv), + page_request(Method::GET), + false, + ); + + assert_eq!( + page.contains(MARKER), + expect_signed, + "published={published}: only a published plan for these exact bytes is applied" + ); + assert_eq!( + stub.recorded_request_methods(), + ["GET", "GET"], + "published={published}: the origin and the policy authority, never a sign call" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 60b58f6246..7c7d2e5bfc 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -52,6 +52,7 @@ trusted-server-framework-nextjs = { workspace = true } trusted-server-headers-geo = { workspace = true } trusted-server-identity-lockr = { workspace = true } trusted-server-provenance-encypher = { workspace = true } +trusted-server-preparation-worker = { workspace = true } trusted-server-loading-cache-51degrees = { workspace = true } trusted-server-shared-aws = { workspace = true } trusted-server-permission-signal-gpc = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 819b76a878..95a565e7a1 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -300,6 +300,9 @@ fn build_state_with_registrations_and_services( integrations: &[IntegrationBuilder], services: Option, ) -> Result, Report> { + // Every request's services carry the Tokio timer, see + // `crate::platform::build_runtime_services`. + trusted_server_core::platform::declare_platform_timer(); // The modules this adapter links come first and the deployment's own // follow, which is the order their hooks run in. let integrations = module_builders_with(integrations); diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index bdfec9b1d6..b9966e55d1 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -30,7 +30,25 @@ fn main() { eprintln!("warning: logger init failed: {e}"); } - let config = match port_from_env() { + // The publisher's preparation worker role: a separate long-lived + // process with its own exclusively owned store, never a task of the + // delivery server. `trusted-server-axum preparation-worker --config + // `. + let arguments: Vec = std::env::args().skip(1).collect(); + if arguments.first().map(String::as_str) == Some("preparation-worker") { + if arguments.len() != 3 || arguments[1] != "--config" || arguments[2].is_empty() { + eprintln!("usage: trusted-server-axum preparation-worker --config "); + std::process::exit(2); + } + let path = &arguments[2]; + if let Err(err) = trusted_server_preparation_worker::run(Path::new(path)) { + log::error!("{err}"); + std::process::exit(1); + } + return; + } + + let mut config = match port_from_env() { // When PORT is set, bind to a specific address so integration tests // can allocate a fresh OS port each run and avoid TIME_WAIT flakiness. Some(port) => AxumDevServerConfig { @@ -40,6 +58,18 @@ fn main() { // Normal development path: read bind address from axum.toml. None => AxumDevServerConfig::default(), }; + // An explicit listener address supports a publisher's private control + // network and local multi-process staging without changing PORT's + // existing loopback-only behavior. + if let Some(address) = std::env::var_os("TS_LISTEN_ADDR") { + match address.to_str().and_then(|address| address.parse().ok()) { + Some(address) => config.addr = address, + None => { + log::error!("TS_LISTEN_ADDR must be a socket address"); + std::process::exit(2); + } + } + } log::info!("Listening on http://{}", config.addr); let router = TrustedServerApp::routes(); diff --git a/crates/trusted-server-adapter-axum/tests/preparation_snapshot.rs b/crates/trusted-server-adapter-axum/tests/preparation_snapshot.rs new file mode 100644 index 0000000000..50e816bdac --- /dev/null +++ b/crates/trusted-server-adapter-axum/tests/preparation_snapshot.rs @@ -0,0 +1,677 @@ +//! The Encypher preparation routes' source reads over the dev server's real +//! HTTP client, with the source body held open. +//! +//! Each test builds the full application router from settings, so the +//! snapshot and publication routes run as deployed and send their loopback +//! reads through `AxumPlatformHttpClient` over a real socket. A local origin +//! stands in for the reader pipeline at the configured loopback address: it +//! answers with origin facts authenticated for the read's nonce, exactly as +//! the module's media hook writes them, and then writes no more of the body +//! until the client leaves. +//! +//! A route that collects the whole source before deciding would wait on +//! that body forever. Each answer must instead arrive while the body is +//! still open, and the unfinished read must then be dropped. No test +//! allocates more than a few kilobytes of source, whatever length the +//! origin declares. + +use std::io::{Read as _, Write as _}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use edgezero_core::body::Body as EdgeBody; +use edgezero_core::http::{Request, Response, request_builder}; +use edgezero_core::router::RouterService; +use edgezero_core::store_registry::KvRegistry; +use serde_json::{Value, json}; +use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::platform::open_kv_handle; +use trusted_server_core::settings::Settings; +use trusted_server_core::test_support::tests::crate_test_settings_str; +use trusted_server_provenance_encypher::preparation::pipeline::{ + PREPARED_PROTOCOL, PlannedMedia, PrepareRequest, PreparedSourceRef, ProbeRelease, + ProbeSelection, StructureChunk, +}; +use trusted_server_provenance_encypher::preparation::protocol::{ + CAPABILITY_HEADER, EnvelopeItem, FACTS_HEADER, LargeLocator, PREPARATION_PROTOCOL, + RESULTS_PREFIX, ResultEnvelope, SNAPSHOT_META_HEADER, SNAPSHOT_PATH, SnapshotMeta, + SnapshotMode, SnapshotOrigin, SnapshotRequest, TOKEN_HEADER, Target, credential_context, + facts_header, +}; + +const HOST: &str = "test-publisher.com"; +const API_BASE_URL: &str = "https://api.example.com"; +const WORKER_TOKEN: &str = "cwk_test_worker_token_value"; +const PUBLICATION: &str = "publication-secret-for-tests"; +const NONCE: &str = "0123456789abcdef0123456789abcdef"; +const SOURCE_PATH: &str = "/media/ferry.mp4"; +const ORIGIN_HOST: &str = "origin.test-publisher.com"; +/// SHA-256 of core's configured-origin identity for the media route, +/// `"https://origin.test-publisher.com\0/media/"`, as the reader +/// pipeline's facts report it. +const ORIGIN_IDENTITY_SHA256: &str = + "81b7719358847724cc3023237b51295905f20fe6bb6047494fde364673764afd"; +const ETAG: &str = "\"v1\""; +/// The snapshot bound these settings give: the larger of the document and +/// media limits. +const SNAPSHOT_LIMIT: usize = 4096; +/// A declared source length far past every bound, never sent. +const DECLARED_LENGTH: u64 = 1_073_741_824; +/// How long an answer may take while the source body is held open. +const PROMPT: Duration = Duration::from_secs(5); + +// --- the held origin ------------------------------------------------------- + +type Answer = dyn Fn(&str) -> Vec + Send + Sync; + +/// A local origin that writes each request's answer and then holds the +/// connection open, its body unfinished, until the client leaves. +struct HeldOrigin { + address: SocketAddr, + requests: Arc>>, + reader_left: Arc, +} + +impl HeldOrigin { + fn start(answer: impl Fn(&str) -> Vec + Send + Sync + 'static) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind a local origin"); + let address = listener + .local_addr() + .expect("should read the local origin's address"); + let requests = Arc::new(Mutex::new(Vec::new())); + let reader_left = Arc::new(AtomicBool::new(false)); + let answer: Arc = Arc::new(answer); + let accepted_requests = Arc::clone(&requests); + let accepted_left = Arc::clone(&reader_left); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { + return; + }; + let answer = Arc::clone(&answer); + let requests = Arc::clone(&accepted_requests); + let reader_left = Arc::clone(&accepted_left); + std::thread::spawn(move || { + answer_and_hold(stream, answer.as_ref(), &requests, &reader_left); + }); + } + }); + Self { + address, + requests, + reader_left, + } + } + + fn requests(&self) -> Vec { + self.requests + .lock() + .expect("should read the recorded requests") + .clone() + } + + /// Whether the client closed its connection within [`PROMPT`]. + async fn reader_left(&self) -> bool { + tokio::time::timeout(PROMPT, async { + while !self.reader_left.load(Ordering::SeqCst) { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .is_ok() + } +} + +/// Reads one request head, writes its answer, then waits for the client to +/// close the connection without writing anything more. +fn answer_and_hold( + mut stream: TcpStream, + answer: &Answer, + requests: &Mutex>, + reader_left: &AtomicBool, +) { + let mut head = Vec::new(); + let mut byte = [0_u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + if head.len() > 16 * 1024 || !matches!(stream.read(&mut byte), Ok(1)) { + return; + } + head.push(byte[0]); + } + let head = String::from_utf8_lossy(&head).into_owned(); + let response = answer(&head); + requests + .lock() + .expect("should record the request") + .push(head); + if stream.write_all(&response).is_ok() && stream.flush().is_ok() { + let mut scratch = [0_u8; 1024]; + while matches!(stream.read(&mut scratch), Ok(read) if read > 0) {} + } + reader_left.store(true, Ordering::SeqCst); +} + +/// The value of header `name` in a raw request head. +fn header_in(head: &str, name: &str) -> Option { + head.lines().skip(1).find_map(|line| { + let (found, value) = line.split_once(':')?; + found + .trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_owned()) + }) +} + +/// A response head with `status` and `headers`. +fn response_head(status: &str, headers: &[(&str, String)]) -> Vec { + let mut head = String::from("HTTP/1.1 "); + head.push_str(status); + head.push_str("\r\n"); + for (name, value) in headers { + head.push_str(name); + head.push_str(": "); + head.push_str(value); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + head.into_bytes() +} + +/// The origin facts the reader pipeline reports for a public source. +fn public_facts(status: u16) -> SnapshotOrigin { + SnapshotOrigin { + status, + origin_host: ORIGIN_HOST.to_owned(), + origin_identity_sha256: ORIGIN_IDENTITY_SHA256.to_owned(), + ..SnapshotOrigin::default() + } +} + +/// The facts header a read carries. +enum Facts { + /// Authenticated for the read's nonce under the publication secret. + Genuine(SnapshotOrigin), + /// Written by someone without the publication secret. + Forged(SnapshotOrigin), + /// None at all: the module did not observe the read. + Missing, +} + +/// A held media answer's headers: its type and validator, the facts and +/// `extra`. +fn media_headers<'a>( + facts: &Facts, + nonce: &str, + extra: &[(&'a str, String)], +) -> Vec<(&'a str, String)> { + let mut headers = vec![ + ("content-type", "video/mp4".to_owned()), + ("etag", ETAG.to_owned()), + ]; + match facts { + Facts::Genuine(origin) => { + headers.push((FACTS_HEADER, facts_header(PUBLICATION, nonce, origin))); + } + Facts::Forged(origin) => headers.push(( + FACTS_HEADER, + facts_header("not-the-publication-secret", nonce, origin), + )), + Facts::Missing => {} + } + headers.extend_from_slice(extra); + headers +} + +/// One chunk of a chunked body carrying `bytes`, with no final chunk after +/// it, so the body never ends. +fn unfinished_chunked_body(bytes: &[u8]) -> Vec { + let length = bytes.len(); + let mut body = format!("{length:x}\r\n").into_bytes(); + body.extend_from_slice(bytes); + body.extend_from_slice(b"\r\n"); + body +} + +/// A `200` media answer declaring [`DECLARED_LENGTH`], with only its first +/// sixteen bytes written. +fn declared_oversized_answer(facts: Facts) -> impl Fn(&str) -> Vec + Send + Sync + 'static { + move |_| { + let mut answer = response_head( + "200 OK", + &media_headers( + &facts, + NONCE, + &[("content-length", DECLARED_LENGTH.to_string())], + ), + ); + answer.extend_from_slice(&[7_u8; 16]); + answer + } +} + +// --- the application ------------------------------------------------------- + +/// The asynchronous mode's settings, reading snapshots from `loopback`. +fn settings(loopback: SocketAddr) -> Settings { + Settings::from_toml(&format!( + "{base}\n[provenance]\nmodule = \"encypher\"\n\n[provenance.encypher]\n\ + api_base_url = \"{API_BASE_URL}\"\ndomain = \"{HOST}\"\n\ + worker_token = \"{WORKER_TOKEN}\"\nexecution = \"async_prepared\"\n\ + max_document_bytes = {SNAPSHOT_LIMIT}\nmax_media_bytes = {SNAPSHOT_LIMIT}\n\ + max_large_media_bytes = 16777216\n\n\ + [provenance.encypher.preparation]\n\ + worker_url = \"https://prep.{HOST}\"\n\ + loopback_url = \"http://{loopback}\"\ndeployment_id = \"test\"\n\ + public_host = \"{HOST}\"\nsignal_secret = \"signal-secret-for-tests\"\n\ + publication_secret = \"{PUBLICATION}\"\nroutes = [\"/media/\"]\n\n\ + [[serve]]\nmedia_type = \"text/html\"\nmiddleware = [\"provenance.encypher\"]\n", + base = crate_test_settings_str(), + )) + .expect("should load the asynchronous prepared settings") +} + +fn router(origin: &HeldOrigin) -> RouterService { + TrustedServerApp::routes_with_settings(settings(origin.address)) + .expect("should build the application router") +} + +/// `router`'s answer to `request`, which must arrive within [`PROMPT`]. +async fn answer_within(router: &RouterService, request: Request) -> Response { + tokio::time::timeout(PROMPT, router.oneshot(request)) + .await + .expect("should answer while the source body is still open") + .expect("the router should answer") +} + +/// The worker's snapshot request for the source, in `mode`. +fn snapshot_request(mode: SnapshotMode) -> Request { + let body = serde_json::to_vec(&SnapshotRequest { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: "test".to_owned(), + target: Target { + path: SOURCE_PATH.to_owned(), + variant: String::new(), + }, + nonce: NONCE.to_owned(), + read: mode, + }) + .expect("should encode the snapshot request"); + request_builder() + .method("POST") + .uri(format!("https://{HOST}{SNAPSHOT_PATH}")) + .header("host", HOST) + .header(TOKEN_HEADER, PUBLICATION) + .body(EdgeBody::from(body)) + .expect("should build the snapshot request") +} + +async fn snapshot(origin: &HeldOrigin, mode: SnapshotMode) -> Response { + answer_within(&router(origin), snapshot_request(mode)).await +} + +async fn body_of(response: Response) -> Vec { + response + .into_body() + .into_bytes_bounded(1024 * 1024) + .await + .expect("should read the route's answer") + .to_vec() +} + +/// The status and refusal code of `response`. +async fn refusal(response: Response) -> (u16, String) { + let status = response.status().as_u16(); + let body: Value = + serde_json::from_slice(&body_of(response).await).expect("a refusal should be JSON"); + let code = body["error"]["code"] + .as_str() + .expect("a refusal should name its code") + .to_owned(); + (status, code) +} + +fn snapshot_meta(response: &Response) -> SnapshotMeta { + response + .headers() + .get(SNAPSHOT_META_HEADER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| STANDARD.decode(value).ok()) + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .expect("should report the snapshot's metadata") +} + +// --- snapshots ------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_whole_snapshot_declared_past_its_bound_is_refused_while_the_body_is_open() { + let origin = HeldOrigin::start(declared_oversized_answer(Facts::Genuine(public_facts(200)))); + + let response = snapshot(&origin, SnapshotMode::Whole).await; + + assert_eq!( + refusal(response).await, + (413, "snapshot_too_large".to_owned()), + "a large public source should be refused as too large, which sends the worker to its \ + head-only fallback, rather than fail the read" + ); + assert!( + origin.reader_left().await, + "the snapshot should drop its unfinished source read" + ); + assert_eq!(origin.requests().len(), 1, "should read the source once"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_whole_snapshot_of_unknown_length_is_refused_once_it_passes_its_bound() { + let body = unfinished_chunked_body(&[b'x'; SNAPSHOT_LIMIT + 1]); + let origin = HeldOrigin::start(move |_| { + let mut answer = response_head( + "200 OK", + &media_headers( + &Facts::Genuine(public_facts(200)), + NONCE, + &[("transfer-encoding", "chunked".to_owned())], + ), + ); + // One chunk past the bound, and never the final chunk. + answer.extend_from_slice(&body); + answer + }); + + let response = snapshot(&origin, SnapshotMode::Whole).await; + + assert_eq!( + refusal(response).await, + (413, "snapshot_too_large".to_owned()), + "a body without a declared length should be refused as soon as it passes the bound" + ); + assert!( + origin.reader_left().await, + "the snapshot should drop its unfinished source read" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_head_snapshot_reports_a_large_source_while_its_body_is_open() { + let origin = HeldOrigin::start(declared_oversized_answer(Facts::Genuine(public_facts(200)))); + + let response = snapshot(&origin, SnapshotMode::Head).await; + + assert_eq!(response.status().as_u16(), 200, "should answer the head"); + assert_eq!( + response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("private, no-store"), + "the snapshot's own transport should stay private" + ); + let meta = snapshot_meta(&response); + assert_eq!(meta.page_url, format!("https://{HOST}{SOURCE_PATH}")); + assert_eq!(meta.content_type, "video/mp4"); + assert_eq!(meta.etag.as_deref(), Some(ETAG)); + assert_eq!( + meta.length, + Some(DECLARED_LENGTH), + "should report the declared length without reading the body" + ); + assert_eq!( + meta.origin, + public_facts(200), + "should report the origin's own authenticated facts" + ); + assert!( + body_of(response).await.is_empty(), + "a head snapshot carries no bytes" + ); + assert!( + origin.reader_left().await, + "the head snapshot should drop the source body unread" + ); + assert_eq!(origin.requests().len(), 1, "should read the source once"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn private_and_unobserved_sources_are_refused_while_their_bodies_are_open() { + let private = SnapshotOrigin { + private: true, + ..public_facts(200) + }; + let cases = [ + ( + SnapshotMode::Whole, + Facts::Genuine(private.clone()), + (403, "source_private"), + ), + ( + SnapshotMode::Head, + Facts::Genuine(private), + (403, "source_private"), + ), + ( + SnapshotMode::Whole, + Facts::Forged(public_facts(200)), + (422, "snapshot_unobserved"), + ), + ( + SnapshotMode::Head, + Facts::Forged(public_facts(200)), + (422, "snapshot_unobserved"), + ), + ( + SnapshotMode::Whole, + Facts::Missing, + (422, "snapshot_unobserved"), + ), + ( + SnapshotMode::Head, + Facts::Missing, + (422, "snapshot_unobserved"), + ), + ]; + for (mode, facts, (status, code)) in cases { + let origin = HeldOrigin::start(declared_oversized_answer(facts)); + + let response = snapshot(&origin, mode.clone()).await; + + assert_eq!( + refusal(response).await, + (status, code.to_owned()), + "a {mode:?} snapshot should refuse a source its facts do not admit, before any size \ + answer and without its body" + ); + assert!( + origin.reader_left().await, + "a refused {mode:?} snapshot should drop its unfinished source read" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_whole_snapshot_within_its_bound_still_returns_every_byte() { + let source: Vec = (0..=255_u8).cycle().take(SNAPSHOT_LIMIT).collect(); + let served = source.clone(); + let origin = HeldOrigin::start(move |_| { + let mut answer = response_head( + "200 OK", + &media_headers( + &Facts::Genuine(public_facts(200)), + NONCE, + &[("content-length", served.len().to_string())], + ), + ); + answer.extend_from_slice(&served); + answer + }); + + let response = snapshot(&origin, SnapshotMode::Whole).await; + + assert_eq!( + response.status().as_u16(), + 200, + "should answer the snapshot" + ); + let meta = snapshot_meta(&response); + assert_eq!( + meta.length, + Some(u64::try_from(source.len()).expect("should fit")) + ); + assert_eq!(meta.origin, public_facts(200)); + assert_eq!( + body_of(response).await, + source, + "a source exactly at the bound should arrive whole" + ); +} + +// --- pinned publication reads ------------------------------------------------ + +/// The production-signed probe release the publication runs. +fn signed_release() -> ProbeRelease { + serde_json::from_slice(include_bytes!( + "../../provenance/encypher/src/fixtures/prepared/descriptor.json" + )) + .expect("should parse the production-signed descriptor") +} + +/// Keeps the signed release's artifact in `kv`, as a store that loaded it +/// before does, so publication runs it without asking the API. +async fn keep_probe(kv: &edgezero_core::key_value_store::KvHandle) { + kv.put_bytes( + &format!("encypher:probe:v1:{}", signed_release().sha256), + bytes::Bytes::from_static(include_bytes!( + "../../provenance/encypher/src/fixtures/prepared/probe.wasm" + )), + ) + .await + .expect("should keep the probe artifact"); +} + +/// A large envelope for a `length`-byte source at [`SOURCE_PATH`]. +fn large_envelope(length: u64) -> ResultEnvelope { + let release = signed_release(); + let page_url = format!("https://{HOST}{SOURCE_PATH}"); + let policy = "a".repeat(64); + let source_sha256 = "c".repeat(64); + let identity = LargeLocator { + credential_context: &credential_context(API_BASE_URL, HOST, WORKER_TOKEN), + policy_digest: &policy, + reader_url: &page_url, + variant: "", + origin_host: ORIGIN_HOST, + origin_identity_sha256: ORIGIN_IDENTITY_SHA256, + mime: "video/mp4", + etag: ETAG, + length, + } + .digest(); + let request = PrepareRequest { + contract_version: 2, + carrier_requirement: "external_allowed".to_owned(), + page_url: page_url.clone(), + expected_policy_digest: policy, + protocol: PREPARED_PROTOCOL.to_owned(), + probe: ProbeSelection { + sha256: release.sha256.clone(), + planner_id: release.planner_id.clone(), + }, + source: PreparedSourceRef { + reference: page_url, + origin_host: ORIGIN_HOST.to_owned(), + mime: "video/mp4".to_owned(), + sha256: source_sha256.clone(), + byte_length: length, + reference_kind: "origin".to_owned(), + }, + structure: vec![StructureChunk { + offset: 0, + b64: "AAAA".to_owned(), + }], + }; + ResultEnvelope { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: "test".to_owned(), + identity, + retirement_epoch: 0, + variant: String::new(), + item: EnvelopeItem::Large { + planned: PlannedMedia { + request, + release, + plan_id: "0f8fad5b-d9cb-469f-a165-70867728950e".to_owned(), + geometry: json!({"disposition": "external"}), + digest: source_sha256, + idempotency_key: "f".repeat(64), + }, + etag: ETAG.to_owned(), + origin_identity_sha256: ORIGIN_IDENTITY_SHA256.to_owned(), + }, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pinned_range_longer_than_it_names_is_refused_while_its_body_is_open() { + const LENGTH: u64 = 1024; + let origin = HeldOrigin::start(|head| { + let nonce = header_in(head, CAPABILITY_HEADER) + .and_then(|capability| capability.split('.').nth(2).map(str::to_owned)) + .unwrap_or_default(); + // The range's headers are exact, but its body runs past the range + // and never ends. + let mut answer = response_head( + "206 Partial Content", + &media_headers( + &Facts::Genuine(public_facts(206)), + &nonce, + &[ + ("content-range", format!("bytes 0-{}/{LENGTH}", LENGTH - 1)), + ("transfer-encoding", "chunked".to_owned()), + ], + ), + ); + answer.extend_from_slice(&unfinished_chunked_body(&[b'x'; 1025])); + answer + }); + let directory = tempfile::tempdir().expect("should create a KV directory"); + let kv = open_kv_handle(&directory.path().join("kv.redb")).expect("should open the KV store"); + keep_probe(&kv).await; + let envelope = large_envelope(LENGTH); + let mut request = request_builder() + .method("PUT") + .uri(format!( + "https://{HOST}{RESULTS_PREFIX}{}", + envelope.identity + )) + .header("host", HOST) + .header(TOKEN_HEADER, PUBLICATION) + .body(EdgeBody::from( + serde_json::to_vec(&envelope).expect("should encode the envelope"), + )) + .expect("should build the publication request"); + request + .extensions_mut() + .insert(KvRegistry::single_id("default".to_owned(), kv)); + + let response = answer_within(&router(&origin), request).await; + + assert_eq!( + refusal(response).await, + (422, "source_range_invalid".to_owned()), + "a pinned range whose body runs past the range should be refused as soon as it does" + ); + assert!( + origin.reader_left().await, + "the publication should drop its unfinished pinned read" + ); + let requests = origin.requests(); + assert_eq!(requests.len(), 1, "should stop at the first invalid range"); + assert_eq!( + header_in(&requests[0], "range").as_deref(), + Some("bytes=0-1023"), + "should read the pinned range" + ); +} diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index bc9d22ae3d..2f604b44bd 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -295,6 +295,11 @@ fn build_state_with_registrations_and_services( services: Option, ) -> Result, Report> { trusted_server_core::platform::declare_platform_cache_headers(&[CDN_CACHE_CONTROL_HEADER]); + // Declared from the timer every request's services carry, so a build + // without the Workers event loop declares none. + if crate::platform::runtime_timer().is_some() { + trusted_server_core::platform::declare_platform_timer(); + } // The modules this adapter links come first and the deployment's own // follow, which is the order their hooks run in. let integrations = module_builders_with(integrations); @@ -2081,4 +2086,48 @@ mod tests { "should put the deployment's builder last" ); } + + /// The settings of an asynchronous prepared deployment, with or without + /// reader hints. + fn async_prepared_settings(reader_trigger: bool) -> Settings { + Settings::from_toml(&format!( + "{}\n[provenance]\nmodule = \"encypher\"\n\n[provenance.encypher]\n\ + api_base_url = \"https://api.example.com\"\ndomain = \"test-publisher.com\"\n\ + worker_token = \"cwk_test_worker_token_value\"\nexecution = \"async_prepared\"\n\n\ + [provenance.encypher.preparation]\n\ + worker_url = \"https://prep.test-publisher.com\"\n\ + loopback_url = \"https://test-publisher.com\"\ndeployment_id = \"test\"\n\ + public_host = \"test-publisher.com\"\nsignal_secret = \"signal-secret\"\n\ + publication_secret = \"publication-secret\"\nreader_trigger = {reader_trigger}\n\ + routes = [\"/news/\"]\n\n\ + [[serve]]\nmedia_type = \"text/html\"\nmiddleware = [\"provenance.encypher\"]\n", + trusted_server_core::test_support::tests::crate_test_settings_str(), + )) + .expect("should load the asynchronous prepared settings") + } + + /// Reader hints are admitted only where the build carries the timer + /// that bounds them. The native host build has no Workers event loop, + /// so it declares no timer and refuses them at startup, while the same + /// deployment preparing through warming alone starts. + #[test] + fn reader_hints_follow_the_timer_this_build_actually_carries() { + assert!( + crate::platform::runtime_timer().is_none(), + "the native host build carries no Workers timer" + ); + + let refused = build_state_with_settings(async_prepared_settings(true)); + + assert!( + refused.is_err(), + "reader hints without a real timer or after-response lifetime should be refused" + ); + assert!( + !trusted_server_core::platform::platform_offers_timer(), + "no timer should be declared without one" + ); + build_state_with_settings(async_prepared_settings(false)) + .expect("warming-only asynchronous preparation should start"); + } } diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 7885edef65..8aee97e351 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -724,7 +724,7 @@ impl PlatformTimer for CloudflareTimer { /// The Workers timer on wasm32; none on the native host, which has no /// Workers event loop. -fn runtime_timer() -> Option> { +pub(crate) fn runtime_timer() -> Option> { #[cfg(target_arch = "wasm32")] let timer: Option> = Some(Arc::new(CloudflareTimer)); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 709eef8204..384fdad299 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -346,6 +346,10 @@ pub(crate) fn declare_platform() { core_platform::declare_platform_tls_headers(&[FASTLY_TLS_HEADER]); core_platform::declare_platform_spoofable_headers(&[FASTLY_CLIENT_IP_HEADER]); core_platform::declare_platform_internal_headers(&[FASTLY_DEBUG_POP_HEADER]); + // The instance keeps running after its response is sent, and + // `crate::send_after_response_handoffs` sends what modules handed over + // then. Compute offers no timer, so none is declared. + core_platform::declare_platform_after_response_handoff(); } /// Build the application state from explicit settings, composing the modules diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 1142f0697a..6d981ec1c1 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -34,7 +34,10 @@ use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; -use trusted_server_core::platform::{RuntimeServices, UnavailableKvStore}; +use trusted_server_core::platform::{ + PlatformHttpClient as _, PlatformHttpRequest, RuntimeServices, UnavailableKvStore, + take_after_response_requests, +}; use trusted_server_core::prefix::Prefixes; use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; use trusted_server_core::response_privacy::TerminalPrivateResponse; @@ -823,6 +826,9 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re // Dispatch directly through the EdgeZero router without an intermediate // fastly::Response conversion. That preserves duplicate header values such // as multiple Set-Cookie headers. + // A request a module handed over for an earlier response that never went + // out belongs to that request, never to this one. + drop(take_after_response_requests()); let mut response = match into_addressed_core_request(req, removed_prefix) { Ok(mut core_req) => { core_req.extensions_mut().insert(config_store); @@ -878,6 +884,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re counters.as_ref(), ); run_edgezero_pull_sync_after_send(settings, &partner_registry, &ec_state); + send_after_response_handoffs(); return; } Err(e) => { @@ -905,6 +912,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re &partner_registry, &ec_state, ); + send_after_response_handoffs(); return; } Err(e) => { @@ -922,6 +930,30 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re } send_edgezero_response(response, request_filter_effects.as_ref(), counters.as_ref()); + send_after_response_handoffs(); +} + +/// Sends the bounded requests modules handed over while the response was +/// made, now the reader has it, so no reader waits on them. Best effort, as +/// the pull sync after it is: a failed send is logged and dropped, and the +/// module that handed it over has another path to the same outcome. +fn send_after_response_handoffs() { + for handed in take_after_response_requests() { + let (parts, body) = handed.request.into_parts(); + let request = HttpRequest::from_parts(parts, EdgeBody::from_bytes(body)); + let sent = futures::executor::block_on( + crate::platform::FastlyPlatformHttpClient + .send(PlatformHttpRequest::new(request, handed.backend_name)), + ); + match sent { + Ok(answer) => log::debug!( + "after-response handoff answered status={}", + answer.response.status().as_u16() + ), + // The platform's error may name the address, so it is not logged. + Err(_) => log::debug!("after-response handoff failed to send"), + } + } } fn edge_error_response(error: EdgeError) -> HttpResponse { diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 2b64aaa968..45d52b2ec8 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -222,6 +222,11 @@ fn build_state_with_registrations_and_services( integrations: &[IntegrationBuilder], services: Option, ) -> Result, Report> { + // Declared from the timer every request's services carry, so a native + // build without the WASI clock declares none. + if crate::platform::runtime_timer().is_some() { + trusted_server_core::platform::declare_platform_timer(); + } // The modules this adapter links come first and the deployment's own // follow, which is the order their hooks run in. let integrations = module_builders_with(integrations); @@ -2434,4 +2439,48 @@ mod tests { "should put the deployment's builder last" ); } + + /// The settings of an asynchronous prepared deployment, with or without + /// reader hints. + fn async_prepared_settings(reader_trigger: bool) -> Settings { + Settings::from_toml(&format!( + "{}\n[provenance]\nmodule = \"encypher\"\n\n[provenance.encypher]\n\ + api_base_url = \"https://api.example.com\"\ndomain = \"test-publisher.com\"\n\ + worker_token = \"cwk_test_worker_token_value\"\nexecution = \"async_prepared\"\n\n\ + [provenance.encypher.preparation]\n\ + worker_url = \"https://prep.test-publisher.com\"\n\ + loopback_url = \"https://test-publisher.com\"\ndeployment_id = \"test\"\n\ + public_host = \"test-publisher.com\"\nsignal_secret = \"signal-secret\"\n\ + publication_secret = \"publication-secret\"\nreader_trigger = {reader_trigger}\n\ + routes = [\"/news/\"]\n\n\ + [[serve]]\nmedia_type = \"text/html\"\nmiddleware = [\"provenance.encypher\"]\n", + trusted_server_core::test_support::tests::crate_test_settings_str(), + )) + .expect("should load the asynchronous prepared settings") + } + + /// Reader hints are admitted only where the build carries the timer + /// that bounds them. The native host build has no WASI clock, so it + /// declares no timer and refuses them at startup, while the same + /// deployment preparing through warming alone starts. + #[test] + fn reader_hints_follow_the_timer_this_build_actually_carries() { + assert!( + crate::platform::runtime_timer().is_none(), + "the native host build carries no WASI timer" + ); + + let refused = build_state_with_settings(async_prepared_settings(true)); + + assert!( + refused.is_err(), + "reader hints without a real timer or after-response lifetime should be refused" + ); + assert!( + !trusted_server_core::platform::platform_offers_timer(), + "no timer should be declared without one" + ); + build_state_with_settings(async_prepared_settings(false)) + .expect("warming-only asynchronous preparation should start"); + } } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index e16091b385..c7cf250d25 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -873,7 +873,7 @@ impl PlatformTimer for SpinPlatformTimer { } /// The WASI timer in the Spin component; none on the native host. -fn runtime_timer() -> Option> { +pub(crate) fn runtime_timer() -> Option> { #[cfg(all(feature = "spin", target_arch = "wasm32"))] let timer: Option> = Some(Arc::new(SpinPlatformTimer)); #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs index 7bc4851e8e..8f08d0982e 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/ca.rs @@ -305,6 +305,7 @@ mod tests { #[test] fn leaf_cache_returns_same_arc_for_same_host() { + crate::tls::install_crypto_provider(); let dir = tempfile::tempdir().expect("should create tempdir"); let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate"); let a = ca @@ -322,6 +323,7 @@ mod tests { #[test] fn leaf_cache_normalizes_dns_case() { + crate::tls::install_crypto_provider(); let dir = tempfile::tempdir().expect("should create tempdir"); let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate"); let lower = ca @@ -335,6 +337,7 @@ mod tests { #[test] fn mints_leaf_for_ip_literal_host() { + crate::tls::install_crypto_provider(); // An IP-literal host must mint successfully (IP-type SAN, not DNS) — spec §8.3. let dir = tempfile::tempdir().expect("should create tempdir"); let ca = CertAuthority::load_or_generate(dir.path()).expect("should generate"); diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/upstream/connect.rs b/crates/trusted-server-cli/src/commands/dev/proxy/upstream/connect.rs index dd876b3164..9ea1800ce1 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/upstream/connect.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/upstream/connect.rs @@ -373,6 +373,7 @@ mod tests { #[test] fn client_configs_are_cached_by_verification_and_http1_only() { + crate::tls::install_crypto_provider(); let secure = client_config(VerifyMode::Secure); let secure_again = client_config(VerifyMode::Secure); let insecure = client_config(VerifyMode::Insecure); diff --git a/crates/trusted-server-cli/src/commands/mod.rs b/crates/trusted-server-cli/src/commands/mod.rs index 2cdc395b0a..1625940d51 100644 --- a/crates/trusted-server-cli/src/commands/mod.rs +++ b/crates/trusted-server-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod cache; pub(crate) mod config; pub(crate) mod keys; pub(crate) mod pbs; +pub(crate) mod preparation; // `dev` is `pub` so the macOS-gated `tests/proxy_e2e.rs` suite can reach // `commands::dev::proxy`, and `origin` is `pub` so `tests/origin_probe.rs` can drive the // shareability probe against a local fixture; the other command modules are diff --git a/crates/trusted-server-cli/src/commands/preparation.rs b/crates/trusted-server-cli/src/commands/preparation.rs new file mode 100644 index 0000000000..f510d8a868 --- /dev/null +++ b/crates/trusted-server-cli/src/commands/preparation.rs @@ -0,0 +1,256 @@ +//! `ts preparation` — operator control of the publisher's preparation +//! worker: warm targets, retire an identity, read status. +//! +//! Every command goes through the worker's own authenticated HTTP routes, +//! never through a second open of its store, with the operator scope's +//! secret from [`OPERATOR_SECRET_VARIABLE`]. + +use std::io::Write; +use std::time::Duration; + +use clap::Subcommand; +use serde_json::{Value, json}; + +/// The environment variable holding the operator scope's secret. +pub(crate) const OPERATOR_SECRET_VARIABLE: &str = "TS_PREPARATION_OPERATOR_SECRET"; + +/// The header the worker authenticates with. +const TOKEN_HEADER: &str = "x-encypher-preparation-token"; + +/// Subcommands under `ts preparation`. +#[derive(Debug, Subcommand)] +pub(crate) enum PreparationCommand { + /// Queue configured targets for preparation now, as a publication event. + Warm(WarmArgs), + /// Retire one prepared identity: its edge marker is published until the + /// delivery instance acknowledges it. + Retire(RetireArgs), + /// Print the worker's queue, artifact, retirement and readiness state. + Status(WorkerArgs), +} + +/// The worker's address. +#[derive(Debug, clap::Args)] +pub(crate) struct WorkerArgs { + /// The worker's origin, such as `http://127.0.0.1:8112`. + #[arg(long, value_name = "URL")] + pub(crate) worker_url: String, +} + +/// Arguments for `ts preparation warm`. +#[derive(Debug, clap::Args)] +pub(crate) struct WarmArgs { + #[command(flatten)] + pub(crate) worker: WorkerArgs, + /// A target path, such as `/news/harbor-vote`; repeat for several. + #[arg(long = "target", value_name = "PATH", required = true)] + pub(crate) targets: Vec, + /// The admitted variant query of every target, such as `edition=uk`. + #[arg(long, default_value = "")] + pub(crate) variant: String, + /// Also register the targets for periodic reconciliation. + #[arg(long)] + pub(crate) register: bool, + /// Clear the targets' damped outcomes: an explicit operator retry. + #[arg(long)] + pub(crate) retry: bool, +} + +/// Arguments for `ts preparation retire`. +#[derive(Debug, clap::Args)] +pub(crate) struct RetireArgs { + #[command(flatten)] + pub(crate) worker: WorkerArgs, + /// The identity retired, 64 lower case hex characters. + #[arg(long, value_name = "IDENTITY")] + pub(crate) identity: String, +} + +/// The method, path and body one command sends. +fn request( + command: &PreparationCommand, +) -> (&WorkerArgs, reqwest::Method, &'static str, Option) { + match command { + PreparationCommand::Warm(args) => ( + &args.worker, + reqwest::Method::POST, + "/v1/preparation/warm", + Some(json!({ + "targets": args + .targets + .iter() + .map(|path| json!({"path": path, "variant": args.variant})) + .collect::>(), + "register": args.register, + "retry": args.retry, + })), + ), + PreparationCommand::Retire(args) => ( + &args.worker, + reqwest::Method::POST, + "/v1/preparation/retire", + Some(json!({"identity": args.identity})), + ), + PreparationCommand::Status(args) => { + (args, reqwest::Method::GET, "/v1/preparation/status", None) + } + } +} + +/// Runs a `ts preparation` subcommand, printing the worker's answer. +/// +/// # Errors +/// +/// A message where the secret is unset, the worker cannot be reached or it +/// refuses the command. +pub(crate) fn run(command: &PreparationCommand, out: &mut impl Write) -> Result<(), String> { + let secret = std::env::var(OPERATOR_SECRET_VARIABLE) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| format!("{OPERATOR_SECRET_VARIABLE} is not set"))?; + run_with_secret(command, &secret, out) +} + +fn run_with_secret( + command: &PreparationCommand, + secret: &str, + out: &mut impl Write, +) -> Result<(), String> { + let (worker, method, path, body) = request(command); + let origin = + reqwest::Url::parse(&worker.worker_url).map_err(|_| "worker URL is invalid".to_owned())?; + let secure = origin.scheme() == "https" + || (origin.scheme() == "http" + && matches!(origin.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"))); + if !secure + || origin.host_str().is_none() + || !origin.username().is_empty() + || origin.password().is_some() + || !matches!(origin.path(), "" | "/") + || origin.query().is_some() + || origin.fragment().is_some() + { + return Err( + "worker URL must be an HTTPS origin (HTTP is allowed only on loopback)".to_owned(), + ); + } + let url = format!("{}{path}", worker.worker_url.trim_end_matches('/')); + crate::tls::install_crypto_provider(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())?; + let (status, answer) = runtime.block_on(async { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|error| error.to_string())?; + let mut builder = client.request(method, &url).header(TOKEN_HEADER, secret); + if let Some(body) = body { + builder = builder.json(&body); + } + let mut response = builder + .send() + .await + .map_err(|_| format!("the worker at {} did not answer", worker.worker_url))?; + let status = response.status(); + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "worker response read failed".to_owned())? + { + if body.len().saturating_add(chunk.len()) > 8 * 1024 * 1024 { + return Err("worker response exceeds 8 MiB".to_owned()); + } + body.extend_from_slice(&chunk); + } + let text = + String::from_utf8(body).map_err(|_| "worker response is not UTF-8".to_owned())?; + Ok::<_, String>((status, text)) + })?; + writeln!(out, "{answer}").map_err(|error| error.to_string())?; + if status.is_success() { + let value: Value = + serde_json::from_str(&answer).map_err(|_| "worker response is not JSON".to_owned())?; + match command { + PreparationCommand::Retire(args) => { + if value.get("identity").and_then(Value::as_str) != Some(args.identity.as_str()) + || value.get("epoch").and_then(Value::as_u64).is_none() + || !matches!( + value.get("marker").and_then(Value::as_str), + Some("pending" | "acknowledged") + ) + { + return Err("worker retirement acknowledgement is malformed".to_owned()); + } + } + PreparationCommand::Warm(args) => { + if value.get("protocol").and_then(Value::as_str) != Some("encypher-preparation/1") { + return Err("worker preparation protocol does not match".to_owned()); + } + let targets = value + .get("targets") + .and_then(Value::as_array) + .filter(|targets| targets.len() == args.targets.len()) + .ok_or_else(|| "worker omitted target outcomes".to_owned())?; + let mut refused = 0; + for target in targets { + if target.get("outcome").and_then(Value::as_str) == Some("not_admitted") { + refused += 1; + continue; + } + match target.pointer("/admission/outcome").and_then(Value::as_str) { + Some("queued" | "coalesced" | "damped") => {} + Some("queue_full") => refused += 1, + _ => return Err("worker returned an unknown admission outcome".to_owned()), + } + } + if refused > 0 { + return Err(format!( + "the worker refused {refused} target(s); see outcomes above" + )); + } + } + PreparationCommand::Status(_) => { + if value.get("protocol").and_then(Value::as_str) != Some("encypher-preparation/1") + || value.get("ready").and_then(Value::as_bool).is_none() + || !value.get("counts").is_some_and(Value::is_object) + { + return Err("worker status response is malformed or incompatible".to_owned()); + } + } + } + Ok(()) + } else { + Err(format!("the worker refused the command: {status}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insecure_or_credentialed_worker_origins_refuse_before_network() { + for url in [ + "http://worker.example.com", + "https://user:password@worker.example.com", + "https://worker.example.com/path", + "https://worker.example.com?token=secret", + ] { + assert!( + run_with_secret( + &PreparationCommand::Status(WorkerArgs { + worker_url: url.to_owned() + }), + "operator", + &mut Vec::new() + ) + .is_err() + ); + } + } +} diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index d11649065f..f2ffac9f3d 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -59,6 +59,10 @@ enum Command { /// Questions about a publisher origin's behaviour. #[command(subcommand)] Origin(crate::commands::origin::OriginCommand), + /// Operator control of the publisher's preparation worker, over its + /// authenticated HTTP routes. + #[command(subcommand)] + Preparation(crate::commands::preparation::PreparationCommand), } #[derive(Debug, Subcommand)] @@ -201,6 +205,11 @@ fn dispatch(args: Args) -> Result { let mut out = stdout.lock(); crate::commands::origin::run(command, &mut out).map(|()| RunOutcome::Success) } + Command::Preparation(command) => { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + crate::commands::preparation::run(&command, &mut out).map(|()| RunOutcome::Success) + } } } diff --git a/crates/trusted-server-cli/tests/preparation.rs b/crates/trusted-server-cli/tests/preparation.rs new file mode 100644 index 0000000000..6470ae635c --- /dev/null +++ b/crates/trusted-server-cli/tests/preparation.rs @@ -0,0 +1,88 @@ +//! A fresh CLI process must reach the worker and preserve per-target refusal. + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::process::Command; +use std::time::Duration; + +#[test] +fn fresh_cli_reports_queue_refusal_without_panicking_or_disclosing_credentials() { + let listener = TcpListener::bind("127.0.0.1:0").expect("should bind worker boundary"); + let url = format!( + "http://{}", + listener.local_addr().expect("should read address") + ); + let mut child = Command::new(env!("CARGO_BIN_EXE_ts")) + .args([ + "preparation", + "warm", + "--worker-url", + &url, + "--target", + "/a", + "--register", + ]) + .env_clear() + .env("TS_PREPARATION_OPERATOR_SECRET", "operator-boundary-secret") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("should start CLI"); + listener.set_nonblocking(true).expect("should bound accept"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if child.try_wait().expect("should inspect CLI").is_some() + || std::time::Instant::now() >= deadline + { + let _ = child.kill(); + let output = child.wait_with_output().expect("should reap CLI"); + panic!( + "CLI did not reach worker: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("worker accept failed: {error}"), + } + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("should bound request"); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + loop { + let read = stream.read(&mut buffer).expect("should read request"); + assert!(read > 0, "request should complete"); + request.extend_from_slice(&buffer[..read]); + if let Some(end) = request.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]).to_ascii_lowercase(); + let length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .expect("should declare length") + .parse::() + .expect("should parse length"); + if request.len() >= end + 4 + length { + break; + } + } + } + let body = r#"{"protocol":"encypher-preparation/1","targets":[{"target":{"path":"/a","variant":""},"admission":{"outcome":"queue_full"}}]}"#; + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).expect("should answer"); + drop(stream); + let output = child.wait_with_output().expect("should reap CLI"); + assert!( + !output.status.success(), + "a refused target must fail the command" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("1 target"), "{stderr}"); + assert!(!stderr.contains("operator-boundary-secret")); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("queue_full")); + assert!(!stdout.contains("operator-boundary-secret")); +} diff --git a/crates/trusted-server-cli/tests/support/mod.rs b/crates/trusted-server-cli/tests/support/mod.rs index 6c464b0158..e9138649cf 100644 --- a/crates/trusted-server-cli/tests/support/mod.rs +++ b/crates/trusted-server-cli/tests/support/mod.rs @@ -18,6 +18,10 @@ use tokio::net::{TcpListener, TcpStream}; use tokio_rustls::{TlsAcceptor, TlsConnector}; use trusted_server_cli::commands::dev::proxy::{ca, config, server}; +// Reuse process startup's installer without exposing a production API for tests. +#[path = "../../src/tls.rs"] +mod tls; + /// The production hostname the matched rule rewrites from (and preserves). pub const FROM_HOST: &str = "www.example-publisher.com"; pub const ALT_FROM_HOST: &str = "shop.example-publisher.com"; @@ -97,6 +101,7 @@ pub struct ObservedCert { /// The tempdir is leaked so the CA files outlive the test (they are tiny and /// the test process is short-lived). pub fn dev_ca() -> ca::CertAuthority { + tls::install_crypto_provider(); let dir = tempfile::tempdir().expect("should create tempdir"); let ca = ca::CertAuthority::load_or_generate(dir.path()).expect("should generate dev CA"); // Keep the directory alive for the duration of the process. @@ -237,6 +242,7 @@ fn upstream_identity() -> (Vec>, PrivateKeyDer<'static>) } fn upstream_tls_acceptor() -> TlsAcceptor { + tls::install_crypto_provider(); let (chain, key) = upstream_identity(); let mut config = rustls::ServerConfig::builder() .with_no_client_auth() @@ -1003,6 +1009,7 @@ impl ServerCertVerifier for AcceptAny { } fn accept_any_connector() -> TlsConnector { + tls::install_crypto_provider(); let mut config = rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(AcceptAny)) @@ -1660,6 +1667,7 @@ impl ServerCertVerifier for CapturingVerifier { } fn capturing_connector(slot: Arc>>) -> TlsConnector { + tls::install_crypto_provider(); let mut config = rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(CapturingVerifier { diff --git a/crates/trusted-server-core/src/inspect/config/tests.rs b/crates/trusted-server-core/src/inspect/config/tests.rs index 47a87b6e59..0585ee37b7 100644 --- a/crates/trusted-server-core/src/inspect/config/tests.rs +++ b/crates/trusted-server-core/src/inspect/config/tests.rs @@ -851,8 +851,6 @@ fn the_page_and_the_json_carry_the_view() { &request(CONFIG_JSON_PATH), ))) .expect("a JSON body"); - let keys: Vec<&String> = json.as_object().expect("an object").keys().collect(); - assert_eq!(keys, ["masked", "settings", "version"]); assert_eq!(json["version"], env!("CARGO_PKG_VERSION")); assert_eq!( json["settings"]["publisher"]["domain"], @@ -867,20 +865,6 @@ fn the_page_and_the_json_carry_the_view() { ); } -fn assert_keys_sorted(value: &Value) { - match value { - Value::Object(map) => { - let keys: Vec<&String> = map.keys().collect(); - let mut sorted = keys.clone(); - sorted.sort_unstable(); - assert_eq!(keys, sorted, "keys should be sorted"); - map.values().for_each(assert_keys_sorted); - } - Value::Array(items) => items.iter().for_each(assert_keys_sorted), - _ => {} - } -} - /// Two loads of one document build hash maps of their own, each iterating /// in its own order, and the view has to come out the same. #[test] @@ -900,7 +884,6 @@ fn the_same_settings_give_the_same_view() { assert_eq!(first_json, second_json); let parsed: Value = serde_json::from_str(&first_json).expect("JSON"); - assert_keys_sorted(&parsed); assert_eq!( parsed["settings"]["response_headers"] .as_object() diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 01b0220613..65ac116aab 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -194,15 +194,23 @@ impl IntegrationDocumentState { /// response with a body, or another response a `[[serve]]` entry covers, is /// sent `private, no-store`. A fetch middleware is handed none of these /// values. +/// +/// An observation, left with [`IntegrationRequestState::observe`], is read +/// the same way but changes nothing about how the request is answered: a +/// request carrying only observations keeps the shared template and media +/// paths every reader takes. It is for a module that has to see, not +/// change, what readers are served. #[derive(Clone, Default)] pub struct IntegrationRequestState { values: IntegrationDocumentStateMap, + observed: IntegrationDocumentStateMap, } impl std::fmt::Debug for IntegrationRequestState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("IntegrationRequestState") .field("keys", &self.values.keys().collect::>()) + .field("observed", &self.observed.keys().collect::>()) .finish() } } @@ -243,36 +251,55 @@ impl IntegrationRequestState { .unwrap_or_default() } - /// The value of type `T` left for `integration_id`. + /// Leaves `value` on `request` as an observation of `integration_id`, + /// which reads like a value but leaves the request on every reader's + /// path, see [`IntegrationRequestState`]. + pub fn observe(request: &mut Request, integration_id: &'static str, value: T) + where + T: Any + Send + Sync + 'static, + { + let mut state = request + .extensions_mut() + .remove::() + .unwrap_or_default(); + state + .observed + .insert((integration_id, TypeId::of::()), Arc::new(value)); + request.extensions_mut().insert(state); + } + + /// The value or observation of type `T` left for `integration_id`. #[must_use] pub fn get(&self, integration_id: &'static str) -> Option> where T: Any + Send + Sync + 'static, { - let value = self.values.get(&(integration_id, TypeId::of::()))?; + let key = (integration_id, TypeId::of::()); + let value = self.values.get(&key).or_else(|| self.observed.get(&key))?; Arc::clone(value).downcast::().ok() } - /// Whether no module left a value. + /// Whether no module left a value. Observations do not count. #[must_use] pub fn is_empty(&self) -> bool { self.values.is_empty() } - /// Copies every value into the state of a document that is starting. + /// Copies every value and observation into the state of a document that + /// is starting. /// /// # Panics /// /// Panics if the document state's lock is poisoned. pub(crate) fn seed(&self, document: &IntegrationDocumentState) { - if self.values.is_empty() { + if self.values.is_empty() && self.observed.is_empty() { return; } let mut guard = document .inner .lock() .expect("should lock integration document state"); - for (key, value) in &self.values { + for (key, value) in self.observed.iter().chain(&self.values) { guard.insert(*key, Arc::clone(value)); } } diff --git a/crates/trusted-server-core/src/media_signing.rs b/crates/trusted-server-core/src/media_signing.rs index 82c7c7ff4f..e0bbae2c96 100644 --- a/crates/trusted-server-core/src/media_signing.rs +++ b/crates/trusted-server-core/src/media_signing.rs @@ -95,6 +95,7 @@ use http::{HeaderMap, HeaderName, HeaderValue, Method, Response, StatusCode, hea use sha2::{Digest as _, Sha256}; use web_time::{SystemTime, UNIX_EPOCH}; +use crate::integrations::IntegrationRequestState; use crate::middleware::{ResponseHeader, media_type_of}; use crate::platform::{CacheSpec, LoadError, LoadedValue, RuntimeServices, StoreKey}; use crate::response_privacy::cache_control_forbids_shared_storage; @@ -185,6 +186,119 @@ pub trait MediaSigner: Send + Sync { }) }) } + + /// Whether delivery only looks up results the signer already holds, so + /// core never runs a signing load for a reader. Core then keeps no + /// signed representation and reads none it kept, never starts or joins + /// a large-lane load, and asks [`lookup_large`](Self::lookup_large) for + /// a large version instead. [`sign`](Self::sign) is still asked for a + /// captured body, and must itself only look up. `false` by default. + fn lookup_only(&self) -> bool { + false + } + + /// The published headers of one large version, for a signer that + /// answers [`lookup_only`](Self::lookup_only). Core decorates the + /// origin's own answer with them where the length matches. The default + /// finds nothing. + fn lookup_large<'a>( + &'a self, + _services: &'a RuntimeServices, + _job: &'a LargeLookupJob<'a>, + ) -> LargeLookup<'a> { + Box::pin(async { + Err(MediaSigningFailure { + reason: "large_lookup_unsupported", + }) + }) + } + + /// Whether the request the module's hooks left `state` on reads a + /// representation for the signer's own preparation rather than for a + /// reader. Core then signs and looks up nothing, keeps nothing, and + /// sends the origin's answer as it came with + /// [`snapshot_headers`](Self::snapshot_headers) added. `false` by + /// default. + fn observes_snapshot(&self, _state: &IntegrationRequestState) -> bool { + false + } + + /// Headers written on the origin's answer to a request + /// [`observes_snapshot`](Self::observes_snapshot) admitted, reporting + /// what the origin's own headers said about it. None by default. + fn snapshot_headers( + &self, + _state: &IntegrationRequestState, + _facts: &SnapshotFacts, + ) -> Vec { + Vec::new() + } +} + +/// What [`MediaSigner::lookup_large`] returns. +pub type LargeLookup<'a> = + Pin> + 'a>>; + +/// One large version a lookup-only signer is asked about, named by the +/// origin's own validators. An entity tag is not cryptographic evidence: +/// the signer relies on the origin honoring strong validator semantics. +#[derive(Debug, Clone, Copy)] +pub struct LargeLookupJob<'a> { + /// The address the reader asked for, `scheme://host/path`, without its + /// query. + pub reader_url: &'a str, + /// The reader's actual query, without `?`, empty where absent. The + /// signer must admit it before using it as a source-version variant. + pub reader_query: &'a str, + /// What tells this origin apart from another at one address. + pub origin_identity: &'a str, + /// The lower case host the source is fetched from. + pub source_origin_host: &'a str, + /// The origin's `Content-Type`, as it sent it. + pub content_type: &'a str, + /// The single strong entity tag the origin named, quoted. + pub etag: &'a str, + /// The version's total length. + pub length: u64, +} + +/// A large version's published result: the headers its answers carry and +/// the whole-source digest and length it was verified at. +#[derive(Debug, Clone)] +pub struct PublishedLarge { + /// Headers written on every answer of the version, as + /// [`LargeSignedMedia::headers`]. + pub headers: Vec, + /// SHA-256 of every byte of the version, written as its `Repr-Digest`. + pub sha256: [u8; 32], + /// The version's length. + pub length: u64, +} + +/// What the origin's own headers said about the answer a preparation +/// snapshot read, read before core changed any of them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotFacts { + /// The origin's status. + pub status: StatusCode, + /// The origin marked the answer as one reader's, by a cookie, a + /// `Cache-Control` that forbids shared storage or a `Vary` on the + /// reader. + pub private: bool, + /// The origin forbade transforming the body. + pub no_transform: bool, + /// The origin's own `Link` names a C2PA manifest. + pub manifest_link: bool, + /// The origin's `Content-Encoding`, empty for identity. + pub content_encoding: String, + /// The lower case host the source was fetched from. + pub origin_host: String, + /// Lowercase SHA-256 hex of core's configured-origin identity, + /// distinguishing configured sources even at the same host. + pub origin_identity_sha256: String, + /// The exact fetched address, where it carries no user information, + /// query or fragment. + pub source_reference: Option, } /// What [`MediaSigner::sign`] returns. @@ -201,6 +315,9 @@ pub struct MediaJob<'a> { /// The address the reader asked for, `scheme://host/path`, without its /// query. pub reader_url: &'a str, + /// The reader's raw query, without `?`. A lookup-only signer admits it + /// through its configured variant schema before requesting preparation. + pub reader_query: &'a str, /// The origin's `Content-Type`, as it sent it. pub content_type: &'a str, /// The exact body core received, captured before anything else read @@ -512,9 +629,8 @@ pub(crate) struct VariantIdentity<'a> { pub origin_identity: &'a str, /// The address core fetches, exactly as fetched. pub fetched_url: &'a str, - /// The reader's request carried no query, so - /// [`reader_url`](Self::reader_url) is the whole address it asked for. - pub reader_query_free: bool, + /// The reader's actual query, without `?`, empty where absent. + pub reader_query: &'a str, } /// Independent remaining browser and shared-cache lifetimes. @@ -636,6 +752,9 @@ pub(crate) struct MediaClaim { /// representation it delivered, see /// [`answer_unsigned`](Self::answer_unsigned). conditions_answered: AtomicBool, + /// The module state of a request the signer reads for its own + /// preparation, see [`MediaSigner::observes_snapshot`]. + snapshot: Option, } impl MediaClaim { @@ -682,14 +801,73 @@ impl MediaClaim { shared, reads_kept: shared && looks_like_media + && !signer.lookup_only() && !crate::publisher::request_asks_for_revalidation(headers), looks_like_media, names_signed_bytes, optimizer_output: false, conditions_answered: AtomicBool::new(false), + snapshot: None, }) } + /// The same claim on a request whose module hooks left `state`: where + /// the signer reads it for its own preparation, nothing is signed, + /// looked up or kept, and the origin's answer goes as it came with the + /// signer's snapshot headers, see [`MediaSigner::observes_snapshot`]. + pub(crate) fn observing(mut self, state: &IntegrationRequestState) -> Self { + if self.signer.observes_snapshot(state) { + self.reads_kept = false; + self.snapshot = Some(state.clone()); + } + self + } + + /// The origin's answer to a snapshot request, as it came, with what its + /// own headers said written by the signer. + fn snapshot_answer( + &self, + state: &IntegrationRequestState, + identity: &VariantIdentity<'_>, + facts: &OriginFacts, + mut response: Response, + ) -> Response { + let headers = response.headers(); + let encoding = header_text(headers, &header::CONTENT_ENCODING).trim(); + let snapshot = SnapshotFacts { + status: response.status(), + private: facts.private || headers.contains_key(header::SET_COOKIE), + no_transform: facts.no_transform, + manifest_link: carries_manifest_link(headers), + content_encoding: if encoding.eq_ignore_ascii_case("identity") { + String::new() + } else { + encoding.to_ascii_lowercase() + }, + origin_host: url::Url::parse(identity.fetched_url) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + .unwrap_or_default(), + origin_identity_sha256: hex::encode(Sha256::digest( + identity.origin_identity.as_bytes(), + )), + source_reference: source_reference(identity.fetched_url).map(str::to_owned), + }; + for (name, value) in self.signer.snapshot_headers(state, &snapshot) { + if !is_reserved_header(&name) { + response.headers_mut().insert(name, value); + } + } + // The snapshot's own transport is internal: no cache may keep it + // for a reader. The origin's privacy was read above, before this + // header replaced its own, and travels in the signer's facts. + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + response + } + /// The same claim on a request the platform's image optimizer answers. /// /// The optimizer reads the origin itself, so core never holds its @@ -791,6 +969,9 @@ impl MediaClaim { Answered::Kept => return Delivery::Send(self.respond_kept(response)), Answered::Origin(facts) => facts, }; + if let Some(state) = &self.snapshot { + return Delivery::Send(self.snapshot_answer(state, identity, &facts, response)); + } if self.optimizer_output { return Delivery::Send( self.deliver_optimized(services, identity, &facts, response) @@ -1084,6 +1265,7 @@ impl MediaClaim { .and_then(|url| url.host_str().map(str::to_ascii_lowercase)); let job = MediaJob { reader_url: identity.reader_url, + reader_query: identity.reader_query, content_type: header_text(headers, &header::CONTENT_TYPE), source, source_sha256: &source_sha256, @@ -1175,7 +1357,7 @@ impl MediaClaim { representation.insert(name, value); } } - let kept = match keep_for.filter(|_| self.shared) { + let kept = match keep_for.filter(|_| self.shared && !self.signer.lookup_only()) { Some(lifetime) => { let value = encode_kept(&KeptRecord { kept_at: unix_seconds(), @@ -1656,7 +1838,7 @@ fn varies_by_reader(headers: &HeaderMap) -> bool { /// Whether the origin's own `Link` already names a C2PA manifest, so the /// representation carries provenance core must keep as it came rather /// than sign again or compete with. -fn carries_manifest_link(headers: &HeaderMap) -> bool { +pub(crate) fn carries_manifest_link(headers: &HeaderMap) -> bool { headers .get_all(header::LINK) .iter() @@ -1769,7 +1951,7 @@ fn is_fragmented_stream_part(bytes: &[u8]) -> bool { } /// Whether a `Cache-Control` line names `directive`. -fn cache_control_has(headers: &HeaderMap, directive: &str) -> bool { +pub(crate) fn cache_control_has(headers: &HeaderMap, directive: &str) -> bool { headers .get_all(header::CACHE_CONTROL) .iter() @@ -2092,3 +2274,6 @@ pub(crate) mod test_support; #[cfg(test)] mod tests; + +#[cfg(test)] +mod lookup_tests; diff --git a/crates/trusted-server-core/src/media_signing/large.rs b/crates/trusted-server-core/src/media_signing/large.rs index 07e2ae355e..ba0e4ec80e 100644 --- a/crates/trusted-server-core/src/media_signing/large.rs +++ b/crates/trusted-server-core/src/media_signing/large.rs @@ -865,8 +865,9 @@ impl LargeReferences { .filter(|reference| reference.starts_with("https://")) .map(str::to_owned) }; - let reader = (identity.reader_query_free && identity.reader_url.starts_with("https://")) - .then(|| identity.reader_url.to_owned()); + let reader = (identity.reader_query.is_empty() + && identity.reader_url.starts_with("https://")) + .then(|| identity.reader_url.to_owned()); Self { source, reader } } @@ -1048,9 +1049,16 @@ impl MediaClaim { if !self.shared { return Err("large_request_not_shared"); } + // A lookup-only signer keeps nothing here and starts no load, so + // neither the outcome's lifetime nor the streaming transport, budget + // and timer a load needs decide whether its result is looked up. + let lookup_only = self.signer.lookup_only(); // An outcome that cannot be kept would be worked out again for // every request, each range of one playback included. - if facts.keep_for.is_none() || !services.loading_cache_for(ASSETS_CACHE).keeps_values() { + if !lookup_only + && (facts.keep_for.is_none() + || !services.loading_cache_for(ASSETS_CACHE).keeps_values()) + { return Err("large_outcome_not_kept"); } let Some(length) = total else { @@ -1059,14 +1067,16 @@ impl MediaClaim { if length > ceiling { return Err("exceeds_large_limit"); } - if !services.http_client().supports_streaming_responses() { - return Err("no_streaming_transport"); - } - if self.signer.max_large_media_duration().is_zero() { - return Err("no_large_media_budget"); - } - if services.timer().is_none() { - return Err("no_timer"); + if !lookup_only { + if !services.http_client().supports_streaming_responses() { + return Err("no_streaming_transport"); + } + if self.signer.max_large_media_duration().is_zero() { + return Err("no_large_media_budget"); + } + if services.timer().is_none() { + return Err("no_timer"); + } } SourceVersion::of(headers, length).ok_or("no_strong_etag") } @@ -1120,6 +1130,11 @@ impl MediaClaim { answer: LargeAnswer, ) -> Response { let signer_id = self.signer.signer_id(); + if self.signer.lookup_only() { + return self + .deliver_published_large(services, identity, origin, answer) + .await; + } let budget = LargeBudget { started: Instant::now(), total: self.signer.max_large_media_duration(), @@ -1377,6 +1392,26 @@ impl MediaClaim { { return response; } + if self.signer.lookup_only() { + let Some(version) = SourceVersion::of(response.headers(), 0) else { + return response; + }; + let Some(origin_host) = url::Url::parse(identity.fetched_url) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + else { + return response; + }; + let Some(record) = self + .published_large(services, identity, &origin_host, &response, &version, None) + .await + else { + return response; + }; + let (mut parts, body) = response.into_parts(); + decorate(&mut parts.headers, &record); + return Response::from_parts(parts, body); + } let Some(version) = SourceVersion::of(response.headers(), 0) else { return response; }; @@ -1417,6 +1452,95 @@ impl MediaClaim { Response::from_parts(parts, body) } + /// Answers the reader from an origin answer admitted to the large lane + /// for a lookup-only signer: the origin's own answer, decorated with + /// the version's published headers where the signer holds a result for + /// exactly this version and length, else as it came. Nothing is hashed, + /// signed, kept or loaded, and no other caller's work is joined. + async fn deliver_published_large( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + origin: PinnedOrigin<'_>, + answer: LargeAnswer, + ) -> Response { + let LargeAnswer { + response, version, .. + } = answer; + let origin_host = origin.url.host_str().map(str::to_ascii_lowercase); + let Some(origin_host) = origin_host else { + return response; + }; + let Some(record) = self + .published_large( + services, + identity, + &origin_host, + &response, + &version, + Some(version.length()), + ) + .await + else { + return response; + }; + let (mut parts, body) = response.into_parts(); + decorate(&mut parts.headers, &record); + Response::from_parts(parts, body) + } + + /// The lookup-only signer's published record for `version`, checked + /// against `length` where the answer states it. + async fn published_large( + &self, + services: &RuntimeServices, + identity: &VariantIdentity<'_>, + origin_host: &str, + response: &Response, + version: &SourceVersion, + length: Option, + ) -> Option { + let job = super::LargeLookupJob { + reader_url: identity.reader_url, + reader_query: identity.reader_query, + origin_identity: identity.origin_identity, + source_origin_host: origin_host, + content_type: header_text(response.headers(), &header::CONTENT_TYPE), + etag: version.etag(), + length: length.unwrap_or(0), + }; + let signer_id = self.signer.signer_id(); + match self.signer.lookup_large(services, &job).await { + Ok(published) + if length.is_none_or(|length| published.length == length) + && published.headers.len() <= LARGE_MAX_HEADERS + && !published + .headers + .iter() + .any(|(name, _)| is_reserved_header(name)) => + { + log::debug!("media signing: lane=large signer={signer_id} from=published"); + Some(LargeRecord { + length: published.length, + sha256: published.sha256, + headers: published.headers, + }) + } + Ok(_) => { + log::warn!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason=published_record_rejected" + ); + None + } + Err(failure) => { + log::info!( + "media signing: outcome=unsigned lane=large signer={signer_id} reason={failure}" + ); + None + } + } + } + /// Answers the reader's preconditions and range against the pinned, /// signed version, reading the bytes it needs by pinned reads, on /// `parts`, the origin's headers as core prepared them, and adds the diff --git a/crates/trusted-server-core/src/media_signing/large/tests.rs b/crates/trusted-server-core/src/media_signing/large/tests.rs index d6e72274f7..2820d743ed 100644 --- a/crates/trusted-server-core/src/media_signing/large/tests.rs +++ b/crates/trusted-server-core/src/media_signing/large/tests.rs @@ -49,7 +49,7 @@ fn identity() -> VariantIdentity<'static> { reader_url: READER_URL, origin_identity: ORIGIN_IDENTITY, fetched_url: FETCHED_URL, - reader_query_free: true, + reader_query: "", } } @@ -1712,7 +1712,7 @@ fn a_source_no_public_address_names_is_never_hashed() { let queried = TestOrigin { identity: VariantIdentity { fetched_url: QUERY_FETCHED_URL, - reader_query_free: false, + reader_query: "v=2", ..identity() }, ..TestOrigin::plain() @@ -1822,7 +1822,7 @@ fn a_signed_origin_address_is_never_named_and_its_reads_carry_no_reader_header() let queried_reader = TestOrigin { identity: VariantIdentity { - reader_query_free: false, + reader_query: "v=2", ..identity() }, auth: Some(OriginSigning::new(Arc::new(TestOriginSigner), None)), diff --git a/crates/trusted-server-core/src/media_signing/lookup_tests.rs b/crates/trusted-server-core/src/media_signing/lookup_tests.rs new file mode 100644 index 0000000000..bd77bbc2cc --- /dev/null +++ b/crates/trusted-server-core/src/media_signing/lookup_tests.rs @@ -0,0 +1,454 @@ +//! Core with a lookup-only signer, as the asynchronous prepared mode runs +//! it, and with a request its module hooks marked as a preparation +//! snapshot. +//! +//! A lookup-only signer is never given work core keeps or shares: core +//! reads and writes no kept signed representation, so no reader starts or +//! joins a load. A snapshot read gets the origin's answer exactly as it +//! came, with only the signer's facts and a private transport header added. + +use std::sync::Arc; + +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use edgezero_core::body::Body as EdgeBody; +use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, header}; +use sha2::{Digest as _, Sha256}; + +use super::test_support::{Behavior, StubSigner}; +use super::*; +use crate::integrations::IntegrationRequestState; +use crate::platform::test_support::{MemoryLoadingCache, noop_services}; +use crate::test_support::tests::create_test_settings; + +const SOURCE: &[u8] = b"origin-image-bytes"; +const PATH: &str = "/media/42"; +const FACTS: &str = "x-test-snapshot-facts"; +const VARIANT_SOURCE: &[u8] = b"variant-image-data"; +const BASE_LINK: &str = "; rel=\"c2pa-manifest\""; +const VARIANT_LINK: &str = "; rel=\"c2pa-manifest\""; + +/// What a module's request preparer leaves on a snapshot read. +struct SnapshotMark; + +/// [`StubSigner`] as a lookup-only signer that observes marked snapshots. +struct LookupOnly(Arc); + +impl MediaSigner for LookupOnly { + fn signer_id(&self) -> &'static str { + self.0.signer_id() + } + + fn handles_path(&self, path: &str) -> bool { + self.0.handles_path(path) + } + + fn handles_media_type(&self, media_type: &str) -> bool { + self.0.handles_media_type(media_type) + } + + fn max_source_bytes(&self) -> usize { + self.0.max_source_bytes() + } + + fn max_large_source_bytes(&self) -> u64 { + 1_000_000 + } + + fn namespace<'a>(&'a self, services: &'a RuntimeServices) -> MediaNamespacing<'a> { + self.0.namespace(services) + } + + fn sign<'a>( + &'a self, + services: &'a RuntimeServices, + job: &'a MediaJob<'a>, + ) -> MediaSigning<'a> { + self.0.sign(services, job) + } + + fn lookup_only(&self) -> bool { + true + } + + fn lookup_large<'a>( + &'a self, + _services: &'a RuntimeServices, + job: &'a LargeLookupJob<'a>, + ) -> LargeLookup<'a> { + Box::pin(async move { + let (source, link) = match job.reader_query { + "" => (SOURCE, BASE_LINK), + "asset=b" => (VARIANT_SOURCE, VARIANT_LINK), + _ => { + return Err(MediaSigningFailure { + reason: "not_prepared", + }); + } + }; + Ok(PublishedLarge { + headers: vec![(header::LINK, HeaderValue::from_static(link))], + sha256: Sha256::digest(source).into(), + length: u64::try_from(source.len()).expect("should fit source length"), + }) + }) + } + + fn observes_snapshot(&self, state: &IntegrationRequestState) -> bool { + state.get::("testing").is_some() + } + + fn snapshot_headers( + &self, + _state: &IntegrationRequestState, + facts: &SnapshotFacts, + ) -> Vec { + let value = format!( + "private={};encoding={};host={};link={}", + facts.private, facts.content_encoding, facts.origin_host, facts.manifest_link + ); + vec![ + ( + HeaderName::from_static(FACTS), + HeaderValue::from_str(&value).expect("should be a header value"), + ), + ( + HeaderName::from_static("x-test-snapshot-origin-identity"), + HeaderValue::from_str(&facts.origin_identity_sha256) + .expect("should encode the configured-source digest"), + ), + ] + } +} + +fn identity() -> VariantIdentity<'static> { + VariantIdentity { + reader_url: "https://www.example.com/media/42", + origin_identity: "https://origin.example.com\0origin.example.com", + fetched_url: "https://origin.example.com/media/42", + reader_query: "", + } +} + +fn image_request() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("sec-fetch-dest", HeaderValue::from_static("image")); + headers +} + +fn origin(extra: &[(HeaderName, &str)], body: Vec) -> Response { + let mut builder = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "image/png") + .header(header::ETAG, "\"origin-etag\"") + .header(header::CACHE_CONTROL, "public, max-age=600"); + for (name, value) in extra { + builder = builder.header(name.clone(), *value); + } + builder + .body(EdgeBody::from(body)) + .expect("should build an origin answer") +} + +fn services_with(signer: Arc, cache: &Arc) -> RuntimeServices { + noop_services() + .with_media_signer(Some(signer)) + .with_loading_cache(cache.clone()) +} + +fn claim(services: &RuntimeServices) -> MediaClaim { + MediaClaim::of_request( + &create_test_settings(), + services, + &Method::GET, + PATH, + &image_request(), + true, + ) + .expect("should claim a media path") +} + +fn deliver( + claim: &MediaClaim, + services: &RuntimeServices, + response: Response, +) -> Delivery { + let facts = OriginFacts::of(response.headers()); + futures::executor::block_on(claim.deliver( + services, + &identity(), + Answered::Origin(facts), + response, + )) +} + +fn sent(delivery: Delivery) -> (Response, Bytes) { + let Delivery::Send(response) = delivery else { + panic!("should send a response"); + }; + let (parts, body) = response.into_parts(); + let bytes = futures::executor::block_on(body.into_bytes_bounded(1024 * 1024)) + .expect("should read the body"); + (Response::from_parts(parts, EdgeBody::empty()), bytes) +} + +#[test] +fn a_lookup_only_signer_never_reads_or_keeps_a_shared_signed_copy() { + // The same deployment with an ordinary signer keeps what it signs. + let ordinary = StubSigner::new(Behavior::Sign, 1024); + let ordinary_cache = Arc::new(MemoryLoadingCache::default()); + let ordinary_services = services_with(ordinary, &ordinary_cache); + let _ = sent(deliver( + &claim(&ordinary_services), + &ordinary_services, + origin(&[], SOURCE.to_vec()), + )); + assert!( + !ordinary_cache.calls().is_empty(), + "an ordinary signer's result is kept for other readers" + ); + + let stub = StubSigner::new(Behavior::Sign, 1024); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services_with(Arc::new(LookupOnly(Arc::clone(&stub))), &cache); + let claim = claim(&services); + assert!( + futures::executor::block_on(claim.kept(&services, &identity())).is_none(), + "nothing kept is looked up" + ); + let (response, body) = sent(deliver(&claim, &services, origin(&[], SOURCE.to_vec()))); + assert_eq!(response.status(), StatusCode::OK); + assert!( + body.starts_with(SOURCE), + "should deliver what the signer looked up for these bytes" + ); + assert_eq!(stub.jobs().len(), 1, "the signer is asked once, to look up"); + assert!( + cache.calls().is_empty(), + "no load is started, joined or kept: {:?}", + cache.calls() + ); +} + +#[test] +fn a_snapshot_read_gets_the_origin_answer_as_it_came_with_the_signer_s_facts() { + let stub = StubSigner::new(Behavior::Sign, 1024); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services_with(Arc::new(LookupOnly(Arc::clone(&stub))), &cache); + let mut request = Request::builder() + .uri(PATH) + .body(EdgeBody::empty()) + .expect("should build"); + IntegrationRequestState::observe(&mut request, "testing", SnapshotMark); + let snapshot = claim(&services).observing(&IntegrationRequestState::of(&request)); + + let (response, body) = sent(deliver( + &snapshot, + &services, + origin(&[(header::SET_COOKIE, "session=reader")], SOURCE.to_vec()), + )); + + assert_eq!(body.as_ref(), SOURCE, "the origin's exact bytes"); + assert!(stub.jobs().is_empty(), "nothing is signed or looked up"); + assert!(cache.calls().is_empty(), "nothing is kept"); + assert_eq!( + response + .headers() + .get(FACTS) + .and_then(|value| value.to_str().ok()), + Some("private=true;encoding=;host=origin.example.com;link=false"), + "the origin's own privacy is reported, read before core changed anything" + ); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("private, no-store"), + "the snapshot's own transport is private, apart from the origin's facts" + ); + assert_eq!( + response + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()), + Some("\"origin-etag\""), + "the origin's validators pass unchanged" + ); + + // An ordinary reader's request on the same deployment is no snapshot. + let reader = Request::builder() + .uri(PATH) + .body(EdgeBody::empty()) + .expect("should build"); + let ordinary = claim(&services).observing(&IntegrationRequestState::of(&reader)); + let (response, _) = sent(deliver(&ordinary, &services, origin(&[], SOURCE.to_vec()))); + assert!(response.headers().get(FACTS).is_none()); + assert_eq!( + stub.jobs().len(), + 1, + "a reader's answer goes to the signer's lookup" + ); +} + +#[test] +fn snapshot_origin_digest_tracks_configured_source_not_transport_host_or_validators() { + let stub = StubSigner::new(Behavior::Sign, 1024); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services_with(Arc::new(LookupOnly(Arc::clone(&stub))), &cache); + let mut request = Request::builder() + .uri(PATH) + .body(EdgeBody::empty()) + .expect("should build a snapshot request"); + IntegrationRequestState::observe(&mut request, "testing", SnapshotMark); + let snapshot = claim(&services).observing(&IntegrationRequestState::of(&request)); + let mut digests = Vec::new(); + for configured in [ + "https://origin.example.com/old/\0origin.example.com", + "https://origin.example.com/new/\0origin.example.com", + "https://origin.example.com/new/\0virtual.example.com", + "https://origin.example.com/new/\0/assets/\0virtual.example.com", + ] { + let identity = VariantIdentity { + origin_identity: configured, + ..identity() + }; + let response = origin(&[(header::CONTENT_LENGTH, "18")], SOURCE.to_vec()); + let facts = OriginFacts::of(response.headers()); + let delivery = futures::executor::block_on(snapshot.deliver( + &services, + &identity, + Answered::Origin(facts), + response, + )); + let (response, body) = sent(delivery); + assert_eq!(body.as_ref(), SOURCE); + assert_eq!( + response + .headers() + .get(header::ETAG) + .expect("should retain the origin validator"), + "\"origin-etag\"" + ); + let digest = response + .headers() + .get("x-test-snapshot-origin-identity") + .expect("should expose configured-source digest to the signer") + .to_str() + .expect("should be lowercase hex"); + assert_eq!(digest, hex::encode(Sha256::digest(configured.as_bytes()))); + assert_eq!(digest.len(), 64); + assert!(!digests.iter().any(|previous| previous == digest)); + digests.push(digest.to_owned()); + } + assert!(stub.jobs().is_empty()); + assert!(cache.calls().is_empty()); +} + +#[test] +fn a_lookup_only_signer_needs_no_timer_or_streaming_to_look_up_a_large_version() { + let stub = StubSigner::new(Behavior::Sign, 4); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services_with(Arc::new(LookupOnly(Arc::clone(&stub))), &cache); + assert!(services.timer().is_none(), "the services carry no timer"); + + let delivery = deliver( + &claim(&services), + &services, + origin(&[(header::CONTENT_LENGTH, "18")], SOURCE.to_vec()), + ); + + assert!( + matches!(delivery, Delivery::Large(_)), + "a large version is admitted for a lookup by its strong validator" + ); + assert!( + stub.jobs().is_empty(), + "nothing is signed while it is admitted" + ); + assert!(cache.calls().is_empty(), "no load is started or joined"); +} + +#[test] +fn large_reader_delivery_preserves_the_query_selected_resource_and_its_own_provenance() { + assert_eq!(SOURCE.len(), VARIANT_SOURCE.len()); + assert_ne!(SOURCE, VARIANT_SOURCE); + let stub = StubSigner::new(Behavior::Sign, 4); + let cache = Arc::new(MemoryLoadingCache::default()); + let services = services_with(Arc::new(LookupOnly(Arc::clone(&stub))), &cache); + let claim = claim(&services); + for (query, source, expected_link) in [ + ("", SOURCE, Some(BASE_LINK)), + ("asset=b", VARIANT_SOURCE, Some(VARIANT_LINK)), + ("unknown=b", VARIANT_SOURCE, None), + ("asset=b&asset=b", VARIANT_SOURCE, None), + ] { + let fetched_url = format!("https://origin.example.com/media/42?{query}"); + let identity = VariantIdentity { + reader_query: query, + fetched_url: &fetched_url, + ..identity() + }; + let response = origin(&[(header::CONTENT_LENGTH, "18")], source.to_vec()); + let facts = OriginFacts::of(response.headers()); + let delivery = futures::executor::block_on(claim.deliver( + &services, + &identity, + Answered::Origin(facts), + response, + )); + let Delivery::Large(answer) = delivery else { + panic!("should admit this source for large lookup"); + }; + let url = url::Url::parse(&fetched_url).expect("should parse source URL"); + let headers = HeaderMap::new(); + let response = futures::executor::block_on(claim.deliver_large( + &services, + &identity, + PinnedOrigin { + url: &url, + headers: &headers, + backend_name: "origin", + auth: None, + unsigned_headers: &[], + response_header_rewrite: None, + }, + answer, + )); + let (response, body) = sent(Delivery::Send(response)); + assert_eq!( + body.as_ref(), + source, + "{query}: should preserve origin bytes" + ); + assert_eq!( + response + .headers() + .get(header::LINK) + .and_then(|value| value.to_str().ok()), + expected_link, + "{query}: should select only this query resource's manifest" + ); + if expected_link.is_some() { + let digest = format!("sha-256=:{}:", STANDARD.encode(Sha256::digest(source))); + assert_eq!( + response + .headers() + .get("repr-digest") + .and_then(|value| value.to_str().ok()), + Some(digest.as_str()), + "{query}: should carry this resource's own digest" + ); + } else { + assert!( + response.headers().get("repr-digest").is_none(), + "{query}: should not carry another resource's digest" + ); + } + } + assert!(stub.jobs().is_empty(), "no source is signed during lookup"); + assert!( + cache.calls().is_empty(), + "no signed load is started or joined" + ); +} diff --git a/crates/trusted-server-core/src/media_signing/tests.rs b/crates/trusted-server-core/src/media_signing/tests.rs index da92c92291..b9fc42fc5c 100644 --- a/crates/trusted-server-core/src/media_signing/tests.rs +++ b/crates/trusted-server-core/src/media_signing/tests.rs @@ -24,7 +24,7 @@ fn identity() -> VariantIdentity<'static> { reader_url: READER_URL, origin_identity: ORIGIN_IDENTITY, fetched_url: FETCHED_URL, - reader_query_free: true, + reader_query: "", } } diff --git a/crates/trusted-server-core/src/middleware.rs b/crates/trusted-server-core/src/middleware.rs index a0a072eb4d..e6365fc48f 100644 --- a/crates/trusted-server-core/src/middleware.rs +++ b/crates/trusted-server-core/src/middleware.rs @@ -170,7 +170,10 @@ pub struct MiddlewareContext<'a> { /// a middleware whose change has to respect a header such as /// `Content-Security-Policy`. `None` where core cannot say, being the /// fetch phase of a page that may be stored and a page replayed from the - /// store, which a middleware treats as headers it does not know. + /// store, which a middleware treats as headers it does not know. A page + /// replayed from the store is never one whose origin forbade transforming + /// it or named a C2PA manifest in its `Link`: core keeps such a page out of + /// the store, so it is always read with its origin's own headers. pub response_headers: Option<&'a HeaderMap>, /// What the adapter reports about the platform the deployment runs on, /// as the attestation evidence carries it. @@ -585,6 +588,15 @@ pub trait Middleware: Send + Sync { false } + /// Runs its stream processor after every ordinary processor in this chain. + /// + /// Use this for a seal or signature that must cover the final transformed + /// document. At most one middleware may produce a finalizing processor + /// for a document; handlers and inserts still run before all processors. + fn finalizes_document(&self) -> bool { + false + } + /// Classifies source bytes that must survive all document transformations /// unchanged. Default off. Only middleware in the applicable chains may /// request this hold, including serve middleware before a template is built. @@ -1003,13 +1015,16 @@ impl MiddlewareChain { /// Checks each handler's selector, refusing the document when one does /// not parse. On a media type other than HTML, an action that decides /// about HTML is refused the same way. + /// Ordinary stream processors keep their declared order. One document + /// finalizer runs last, after every other processor and HTML edit. /// /// # Errors /// - /// A message naming the middleware and the selector that does not parse, - /// or the HTML decision it made about a document that is not HTML. + /// A message naming an invalid selector, an HTML decision on a non-HTML + /// document, an inadmissible response header, or competing finalizers. pub fn plan(&self, context: &MiddlewareContext<'_>) -> Result { let mut plan = MiddlewarePlan::default(); + let mut final_processor = None; for middleware in &self.middleware { let action = middleware.create(context); action.validate_selectors().map_err(|problem| { @@ -1043,9 +1058,25 @@ impl MiddlewareChain { plan.body_open_inserts.extend(action.body_open_inserts); plan.element_handlers.extend(action.element_handlers); plan.text_handlers.extend(action.text_handlers); - plan.processors.extend(action.stream); + if let Some(processor) = action.stream { + if middleware.finalizes_document() { + if let Some((other, _)) = &final_processor { + return Err(format!( + "the {} middleware `{}` and `{other}` both finalize the document", + self.phase, + middleware.middleware_id() + )); + } + final_processor = Some((middleware.middleware_id(), processor)); + } else { + plan.processors.push(processor); + } + } plan.response_headers.extend(action.response_headers); } + if let Some((_, processor)) = final_processor { + plan.processors.push(processor); + } Ok(plan) } } @@ -1068,6 +1099,7 @@ pub struct MiddlewarePlan { /// Text handlers, in the order the middleware were named. pub text_handlers: Vec>, /// Stream processors, each handed what the one before it produced. + /// Ordinary processors keep their declared order; a finalizer runs last. pub processors: Vec>, /// Headers for the reader's response, in the order the middleware were /// named, a later one replacing an earlier of the same name. diff --git a/crates/trusted-server-core/src/middleware/tests.rs b/crates/trusted-server-core/src/middleware/tests.rs index 6275024e64..e5c7619559 100644 --- a/crates/trusted-server-core/src/middleware/tests.rs +++ b/crates/trusted-server-core/src/middleware/tests.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use serde_json::json; +use sha2::{Digest, Sha256}; use super::*; use crate::html_processor::{ @@ -1611,6 +1612,84 @@ fn a_serve_chain_s_stream_processor_runs_over_the_reader_s_copy() { assert!(page.contains("Reader") && page.ends_with("")); } +struct DocumentSeal(Sha256); + +impl crate::streaming_processor::StreamProcessor for DocumentSeal { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> io::Result> { + self.0.update(chunk); + let mut output = chunk.to_vec(); + if is_last { + output.extend_from_slice(format!("\nsha256:{:x}", self.0.finalize_reset()).as_bytes()); + } + Ok(output) + } +} + +struct SealingMiddleware(&'static str); + +impl Middleware for SealingMiddleware { + fn middleware_id(&self) -> &'static str { + self.0 + } + + fn phases(&self) -> &[MiddlewarePhase] { + &[MiddlewarePhase::Serve] + } + + fn finalizes_document(&self) -> bool { + true + } + + fn create(&self, _context: &MiddlewareContext<'_>) -> MiddlewareAction { + MiddlewareAction { + stream: Some(Box::new(DocumentSeal(Sha256::new()))), + ..MiddlewareAction::pass() + } + } +} + +#[test] +fn finalizing_document_sees_every_transform_even_when_registered_first() { + let chain = serve_chain_of(vec![ + Arc::new(SealingMiddleware("example.seal")), + middleware("example.first", |_| MiddlewareAction { + stream: Some(Box::new(Append("\nfirst".to_owned()))), + ..MiddlewareAction::pass() + }), + middleware("example.second", |_| MiddlewareAction { + stream: Some(Box::new(Append("\nsecond".to_owned()))), + ..MiddlewareAction::pass() + }), + head("example.head", ""), + ]); + for chunk_size in [1, 8192] { + let output = served_with( + &chain, + &IntegrationRequestState::default(), + "Article", + chunk_size, + ); + let (document, seal) = output + .rsplit_once("\nsha256:") + .expect("should carry a seal"); + assert_eq!(seal, format!("{:x}", Sha256::digest(document.as_bytes()))); + assert!(document.ends_with("\nfirst\nsecond")); + assert!(document.contains("")); + } +} + +#[test] +fn competing_finalizing_documents_are_refused_before_delivery() { + let chain = serve_chain_of(vec![ + Arc::new(SealingMiddleware("example.first-seal")), + Arc::new(SealingMiddleware("example.second-seal")), + ]); + assert!( + create_serve_processor(&chain, &reader(&IntegrationRequestState::default())).is_err(), + "two finalizers must not produce a document whose first seal is invalid" + ); +} + #[test] fn a_serve_middleware_reads_what_the_request_left() { let chain = serve_chain_of(vec![middleware("example.reads", |context| { diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index c6ee170c8d..bda5f2b38e 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -1043,15 +1043,7 @@ impl PermissionState { /// let state = PermissionState::new( /// PermissionSet::none().with(Permission::StoreOnDevice), /// ); - /// assert_eq!( - /// state.page_json(), - /// r#"{"awaiting":[],"set":["necessary.operations.storage"],"signals":[],"tdls":[]}"# - /// ); - /// - /// assert_eq!( - /// PermissionState::default().page_json(), - /// r#"{"awaiting":[],"set":[],"signals":[],"tdls":[]}"# - /// ); + /// let page_json = state.page_json(); /// ``` #[must_use] pub fn page_json(&self) -> String { diff --git a/crates/trusted-server-core/src/platform/after_response.rs b/crates/trusted-server-core/src/platform/after_response.rs new file mode 100644 index 0000000000..4c00a3d080 --- /dev/null +++ b/crates/trusted-server-core/src/platform/after_response.rs @@ -0,0 +1,145 @@ +//! Small requests a module hands to the platform to send after the reader's +//! response has gone out, on a platform whose adapter offers that lifetime. +//! +//! A platform without a timer cannot bound a request made while a reader +//! waits, but one whose instance keeps running after it sent the response +//! can send it then, so the reader never waits for it. Its adapter declares +//! the handoff once, before it serves, and sends what was handed over after +//! each response. Nothing here is durable: a request that is not sent, or +//! whose answer is lost, is lost, and the module that handed it over must +//! have another path to the same outcome. +//! +//! The handoff is bounded: at most [`MAX_AFTER_RESPONSE_REQUESTS`] requests +//! of at most [`MAX_AFTER_RESPONSE_BODY_BYTES`] each wait at a time, and a +//! request over either bound is refused. One instance serves one request at +//! a time on every platform that declares the handoff, so what waits belongs +//! to the request being served. An adapter drops what is left before a new +//! request starts. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, PoisonError}; + +use bytes::Bytes; + +/// The most requests that wait for one response. +pub const MAX_AFTER_RESPONSE_REQUESTS: usize = 8; + +/// The largest body one waiting request carries. +pub const MAX_AFTER_RESPONSE_BODY_BYTES: usize = 8 * 1024; + +static SUPPORTED: AtomicBool = AtomicBool::new(false); +static TIMER: AtomicBool = AtomicBool::new(false); +static WAITING: Mutex> = Mutex::new(Vec::new()); + +/// Declares that every request's services carry a real +/// [`PlatformTimer`](super::PlatformTimer), so a module can bound work a +/// reader waits for. Called once, before serving, by an adapter in exactly +/// the build that installs its timer. +pub fn declare_platform_timer() { + TIMER.store(true, Ordering::Relaxed); +} + +/// Whether the adapter declared a real timer on every request. +#[must_use] +pub fn platform_offers_timer() -> bool { + TIMER.load(Ordering::Relaxed) +} + +/// One request to send after the response, to a backend the module already +/// registered while the reader's request ran. +#[derive(Debug, Clone)] +pub struct AfterResponseRequest { + /// The request, its body held whole. + pub request: http::Request, + /// The backend it goes to. + pub backend_name: String, +} + +/// Declares that the platform sends handed-over requests after each +/// response. Called by an adapter whose instance keeps running after its +/// response is sent, once, before it serves. +pub fn declare_platform_after_response_handoff() { + SUPPORTED.store(true, Ordering::Relaxed); +} + +/// Whether the adapter declared the after-response handoff. +#[must_use] +pub fn platform_offers_after_response_handoff() -> bool { + SUPPORTED.load(Ordering::Relaxed) +} + +/// Hands `request` over to be sent after the response. Whether it was +/// accepted: never where the platform declared no handoff, the request's +/// body is over [`MAX_AFTER_RESPONSE_BODY_BYTES`], or +/// [`MAX_AFTER_RESPONSE_REQUESTS`] already wait. +pub fn hand_over_after_response(request: AfterResponseRequest) -> bool { + if !platform_offers_after_response_handoff() + || request.request.body().len() > MAX_AFTER_RESPONSE_BODY_BYTES + { + return false; + } + let mut waiting = WAITING.lock().unwrap_or_else(PoisonError::into_inner); + if waiting.len() >= MAX_AFTER_RESPONSE_REQUESTS { + return false; + } + waiting.push(request); + true +} + +/// Takes every request waiting to be sent, for the adapter to send after +/// the response or to drop before a new request starts. +#[must_use] +pub fn take_after_response_requests() -> Vec { + std::mem::take(&mut *WAITING.lock().unwrap_or_else(PoisonError::into_inner)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(body: usize) -> AfterResponseRequest { + AfterResponseRequest { + request: http::Request::builder() + .method("POST") + .uri("https://worker.example.com/v1/preparation/signals") + .body(Bytes::from(vec![b'x'; body])) + .expect("should build a request"), + backend_name: "worker".to_owned(), + } + } + + #[test] + fn handoff_is_refused_until_declared_then_bounded_and_drained() { + let _ = take_after_response_requests(); + if !platform_offers_after_response_handoff() { + assert!( + !hand_over_after_response(request(1)), + "should refuse a handoff the platform never declared" + ); + } + declare_platform_after_response_handoff(); + assert!( + !hand_over_after_response(request(MAX_AFTER_RESPONSE_BODY_BYTES + 1)), + "should refuse a body over the bound" + ); + for _ in 0..MAX_AFTER_RESPONSE_REQUESTS { + assert!( + hand_over_after_response(request(16)), + "should accept within the bound" + ); + } + assert!( + !hand_over_after_response(request(16)), + "should refuse past the request bound" + ); + assert_eq!( + take_after_response_requests().len(), + MAX_AFTER_RESPONSE_REQUESTS, + "should drain every waiting request" + ); + assert!( + take_after_response_requests().is_empty(), + "should hold nothing once drained" + ); + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 2dde71dc6e..3b5bbdfc18 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -36,6 +36,7 @@ use std::time::Duration; +mod after_response; mod auction_target; mod cache_headers; mod error; @@ -63,6 +64,11 @@ mod traits; mod types; mod value_cache; +pub use after_response::{ + AfterResponseRequest, MAX_AFTER_RESPONSE_BODY_BYTES, MAX_AFTER_RESPONSE_REQUESTS, + declare_platform_after_response_handoff, declare_platform_timer, hand_over_after_response, + platform_offers_after_response_handoff, platform_offers_timer, take_after_response_requests, +}; pub use auction_target::{AuctionTargetCapabilities, AuctionTargetDescriptor}; pub use cache_headers::{declare_platform_cache_headers, platform_cache_headers}; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index d699936fc3..c4ce965bc4 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1172,7 +1172,8 @@ pub async fn handle_asset_proxy_request( incoming_path, req.headers(), true, - ); + ) + .map(|claim| claim.observing(&crate::integrations::IntegrationRequestState::of(&req))); // The platform's image optimizer reads the origin itself, so core never // holds its input. It is asked to keep its input's C2PA manifest and // record what it did, and its answer is signed only where it did, see @@ -1323,7 +1324,7 @@ pub async fn handle_asset_proxy_request( reader_url, origin_identity, fetched_url: target_url.as_str(), - reader_query_free: incoming_query.is_empty(), + reader_query: incoming_query, }), _ => None, }; @@ -1422,7 +1423,10 @@ pub async fn handle_asset_proxy_request( incoming_path, req.headers(), true, - ); + ) + .map(|claim| { + claim.observing(&crate::integrations::IntegrationRequestState::of(&req)) + }); let mut platform_req = build_asset_platform_request( req.method(), &target_url, diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 37a2d95cd7..6044b48c7a 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -2943,8 +2943,9 @@ fn build_template_assembly_params( auction_request: None, dispatched_auction: None, price_granularity, - // A request that carries any keeps to the origin path, so none reaches a - // document served from a template. + // A request that carries any value keeps to the origin path, so only + // observations reach a document served from a template. Set by the + // caller. request_state: IntegrationRequestState::default(), // Set by the caller, from the request's services. public_prefix: crate::prefix::PublicPrefix::default(), @@ -5724,9 +5725,10 @@ pub async fn handle_publisher_request( log::debug!("Proxying request to configured publisher backend"); let request_path = req.uri().path().to_string(); - // Whether the reader's address is whole without its query, the only - // case it may name a large media source. - let reader_query_free = req.uri().query().is_none_or(str::is_empty); + // Preserve the reader's original query before the origin URI replaces it. + let reader_query = request_path_and_query + .split_once('?') + .map_or("", |(_, query)| query); let is_get = req.method() == http::Method::GET; let is_get_or_head = is_get || req.method() == http::Method::HEAD; @@ -5895,7 +5897,8 @@ pub async fn handle_publisher_request( req.headers(), media_shared, ) - .filter(|_| !request_host.is_empty()); + .filter(|_| !request_host.is_empty()) + .map(|claim| claim.observing(&request_state)); // A reader's range or tag naming signed bytes means nothing to the // origin, so it is asked for the whole representation, and the reader's // headers are answered against the signed bytes. @@ -6093,7 +6096,7 @@ pub async fn handle_publisher_request( reader_url, origin_identity, fetched_url, - reader_query_free, + reader_query, }), _ => None, }; @@ -6425,6 +6428,7 @@ pub async fn handle_publisher_request( params.request_path.clone_from(&request_path); params.public_prefix = services.public_prefix().clone(); params.removed_prefix = removed_prefix.clone().unwrap_or_default(); + params.request_state = request_state.clone(); params.seam_ad_slots = seam_ad_slots.clone(); params.dispatched_auction = dispatched_auction.take(); params.auction_observation = auction_observation.take(); @@ -7834,6 +7838,17 @@ pub(crate) enum TemplateCacheBypassReason { /// A response-bound CSP nonce cannot safely be replayed with a shared document. #[display("origin CSP contains a response-bound nonce")] CspNonce, + /// The origin forbade transforming the body. A replayed template carries + /// none of the origin's cache directives, so storing it would let a later + /// read of the same address present transformed bytes as if the origin + /// had allowed it; every read is answered from a fresh origin fetch instead. + #[display("origin forbade transforming the body")] + NoTransform, + /// The origin's own `Link` names a C2PA manifest. That is provenance of the + /// origin's, which a replayed template would lose, so every read keeps it + /// by being answered from a fresh origin fetch. + #[display("origin names a C2PA manifest")] + ProvenanceLink, /// A policy header selected for replay could not be represented losslessly. #[display("origin policy header is malformed")] MalformedPolicyHeader, @@ -8272,6 +8287,15 @@ fn template_cache_ttl( return Err(TemplateCacheBypassReason::CookiePolicy); } origin_response_may_be_kept(status, response_headers)?; + // A stored template replays none of these facts, and a middleware reading + // a replayed page is told no origin headers. Keeping such a page out of + // the store is what makes "no facts" mean "neither" on every replay. + if crate::media_signing::cache_control_has(response_headers, "no-transform") { + return Err(TemplateCacheBypassReason::NoTransform); + } + if crate::media_signing::carries_manifest_link(response_headers) { + return Err(TemplateCacheBypassReason::ProvenanceLink); + } // Checked here, among the leak vectors, because storing under a key that does not // cover the origin's Vary is cross-serving rather than mere ineligibility: a request // differing only in the uncovered header would read this template. @@ -18860,6 +18884,54 @@ mod tests { } } + #[tokio::test] + async fn a_page_whose_origin_forbids_transforming_or_names_a_manifest_is_never_replayed() { + // A replayed template reaches middleware with no origin headers, which + // a middleware reads as headers it does not know. Were these pages + // stored, a later read of the same address would lose the origin's + // `no-transform` or its own C2PA manifest link, and a preparation + // snapshot of it could be admitted as an unprotected source. + for (name, extra) in [ + ( + "no-transform", + ("cache-control", "public, max-age=300, no-transform"), + ), + ( + "manifest link", + ( + "link", + "; rel=\"c2pa-manifest\"", + ), + ), + ] { + let stub = Arc::new(StubHttpClient::new()); + let cache = Arc::new(MemoryTemplateCache::default()); + let settings = Arc::new(settings_with_mode("esi")); + let services = services(Arc::clone(&stub), Arc::clone(&cache)); + for _ in 0..2 { + let mut headers = vec![("content-type", "text/html; charset=utf-8")]; + if extra.0 != "cache-control" { + headers.push(("cache-control", "public, max-age=300")); + } + headers.push(extra); + stub.push_response_with_headers( + 200, + b"origin".to_vec(), + headers, + ); + } + + let _ = run(&settings, &services, navigation_request()).await; + let _ = run(&settings, &services, navigation_request()).await; + + assert_eq!( + stub.recorded_request_uris().len(), + 2, + "{name}: every read should come from the origin, with its own headers" + ); + } + } + #[tokio::test] async fn a_cache_hit_preserves_every_repeated_policy_header_in_order() { let stub = Arc::new(StubHttpClient::new()); diff --git a/crates/trusted-server-preparation-worker/Cargo.toml b/crates/trusted-server-preparation-worker/Cargo.toml new file mode 100644 index 0000000000..243e52bfd9 --- /dev/null +++ b/crates/trusted-server-preparation-worker/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "trusted-server-preparation-worker" +description = "Publisher-operated native preparation worker for Encypher asynchronous prepared signing: durable redb admission, claims, retries, reconciliation and retirement, outside reader delivery." +authors = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +publish = { workspace = true } +version = { workspace = true } + +[lints] +workspace = true + +[dependencies] +axum = { workspace = true } +base64 = { workspace = true } +bytes = { workspace = true } +derive_more = { workspace = true } +http = { workspace = true } +log = { workspace = true } +rand = { workspace = true } +redb = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "signal", "sync", "time"] } +toml = { workspace = true } +trusted-server-provenance-encypher = { workspace = true } +url = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/trusted-server-preparation-worker/src/client.rs b/crates/trusted-server-preparation-worker/src/client.rs new file mode 100644 index 0000000000..f600db5358 --- /dev/null +++ b/crates/trusted-server-preparation-worker/src/client.rs @@ -0,0 +1,636 @@ +//! The worker's configuration and its two HTTP clients: the Encypher API, +//! with the worker credential, and the publisher's delivery instance, with +//! the publication scope's secret. Neither ever carries a reader's state. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use serde::Deserialize; + +use trusted_server_provenance_encypher::preparation::VariantSchema; +use trusted_server_provenance_encypher::preparation::pipeline::{ + ApiAnswer, ApiRequest, ApiTransport, Failure, FailureClass, ProbeArtifactCache, +}; +use trusted_server_provenance_encypher::preparation::protocol::{ + MARKERS_PREFIX, MAX_TARGET_PATH_BYTES, MarkerEnvelope, PREPARATION_PROTOCOL, RESULTS_PREFIX, + ResultEnvelope, SNAPSHOT_META_HEADER, SNAPSHOT_PATH, SnapshotMeta, SnapshotMode, + SnapshotRequest, TOKEN_HEADER, Target, credential_context, domain_digest, +}; + +use crate::store::{Bounds, Store, StoreError}; + +/// The worker's configuration file, TOML. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkerConfig { + /// The address the worker listens on, such as `0.0.0.0:8112`. + pub listen: String, + /// The directory holding the redb store and source objects. + pub store_dir: PathBuf, + /// The deployment, as the delivery instance's configuration names it. + pub deployment_id: String, + /// The Encypher API origin, the same as the delivery instance's. + pub api_base_url: String, + /// The claimed domain, the same as the delivery instance's. + pub domain: String, + /// The host prepared addresses name, the same as the delivery + /// instance's `public_host`. + pub public_host: String, + /// The delivery instance's origin the worker reads snapshots from and + /// publishes results to. + pub edge_url: String, + /// The article selector override, the same as the delivery instance's. + #[serde(default)] + pub article_selector: String, + /// The least article text signed, the same as the delivery instance's. + #[serde(default = "default_min_text_length")] + pub min_text_length: usize, + /// The largest bounded media item, the same as the delivery instance's. + #[serde(default = "default_max_media_bytes")] + pub max_media_bytes: usize, + /// The largest large source, `0` to refuse every large source. + #[serde(default)] + pub max_large_media_bytes: u64, + /// The snapshot ceiling, reserved before acquisition even when size is unknown. + #[serde(default = "default_max_snapshot_bytes")] + pub max_snapshot_bytes: usize, + /// Total attempt deadline, including network and probe work. + #[serde(default = "default_preparation_seconds")] + pub max_preparation_seconds: u64, + /// The variant schema, the same as the delivery instance's. + #[serde(default)] + pub variants: VariantSchema, + /// Environment variables naming the secrets: the worker credential and + /// the three scopes. Secrets are never written in this file. + pub secrets: SecretNames, + /// Concurrent preparation tasks. + #[serde(default = "default_workers")] + pub workers: usize, + /// The hard byte quota of retained sources and outputs. + #[serde(default = "default_quota")] + pub artifact_quota_bytes: u64, + /// Targets reconciled periodically and warmed at start. + #[serde(default)] + pub inventory: Vec, + /// Reconciliation cadence: targets per minute, between 1 and 128. + #[serde(default = "default_scan_per_minute")] + pub scan_per_minute: usize, + /// The longest normal reconciliation interval, in seconds, between 60 and + /// 900. With `scan_per_minute` it bounds every inventory: configured, + /// registered and persisted. See [`WorkerConfig::store_bounds`]. + #[serde(default = "default_scan_interval")] + pub scan_interval_seconds: u64, +} + +/// The environment variables holding the worker's secrets. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SecretNames { + /// The `cwk_` worker credential. + pub worker_token: String, + /// The reader-signal scope. + pub signal_secret: String, + /// The snapshot and publication scope. + pub publication_secret: String, + /// The operator scope: warming, retirement, status. + pub operator_secret: String, + /// An optional staging canary token, sent as `X-Encypher-Canary-Token` + /// on signing calls only, where the API's admission is canary-gated. + #[serde(default)] + pub canary_token: Option, +} + +fn default_min_text_length() -> usize { + 50 +} + +fn default_max_media_bytes() -> usize { + 5_000_000 +} + +fn default_max_snapshot_bytes() -> usize { + 64 * 1024 * 1024 +} + +fn default_preparation_seconds() -> u64 { + 120 +} + +fn default_workers() -> usize { + 4 +} + +fn default_quota() -> u64 { + 10 * 1024 * 1024 * 1024 +} + +fn default_scan_per_minute() -> usize { + 128 +} + +fn default_scan_interval() -> u64 { + 900 +} + +/// The secrets, read from the environment once at start. +#[derive(Clone)] +pub struct Secrets { + /// The worker credential. + pub worker_token: String, + /// The reader-signal scope. + pub signal: String, + /// The publication scope. + pub publication: String, + /// The operator scope. + pub operator: String, + /// The staging canary token. + pub canary: Option, +} + +impl std::fmt::Debug for Secrets { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Secrets([REDACTED])") + } +} + +/// Why the configuration is not usable. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +#[display("preparation worker configuration: {_0}")] +pub struct ConfigError(pub String); + +impl core::error::Error for ConfigError {} + +impl WorkerConfig { + /// Reads and checks the configuration at `path`. + /// + /// # Errors + /// + /// A [`ConfigError`] naming what is wrong. + pub fn load(path: &Path) -> Result<(Self, Secrets), ConfigError> { + let text = std::fs::read_to_string(path) + .map_err(|error| ConfigError(format!("cannot read {}: {error}", path.display())))?; + let config: Self = toml::from_str(&text).map_err(|error| ConfigError(error.to_string()))?; + config.check()?; + let secrets = config.secrets()?; + Ok((config, secrets)) + } + + pub(crate) fn check(&self) -> Result<(), ConfigError> { + let https_or_loopback = |value: &str| { + url::Url::parse(value).is_ok_and(|url| { + (url.scheme() == "https" + || (url.scheme() == "http" + && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")))) + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + && matches!(url.path(), "" | "/") + }) + }; + if !https_or_loopback(&self.api_base_url) || !https_or_loopback(&self.edge_url) { + return Err(ConfigError( + "api_base_url and edge_url must be https, or http on a loopback host".to_owned(), + )); + } + if self + .public_host + .strip_prefix("www.") + .unwrap_or(&self.public_host) + != self.domain + { + return Err(ConfigError( + "public_host must be the domain or its www. name".to_owned(), + )); + } + let public = url::Url::parse(&format!("https://{}/", self.public_host)) + .map_err(|_| ConfigError("public_host is invalid".to_owned()))?; + if public.host_str() != Some(self.public_host.as_str()) + || public.port().is_some() + || !public.username().is_empty() + || public.password().is_some() + || public.path() != "/" + || public.query().is_some() + || public.fragment().is_some() + || self.deployment_id.is_empty() + || self.deployment_id.len() > 256 + { + return Err(ConfigError( + "public_host or deployment_id is invalid".to_owned(), + )); + } + if !self.variants.is_bounded() { + return Err(ConfigError("variants exceed their bounds".to_owned())); + } + if self.workers == 0 || self.workers > 4 { + return Err(ConfigError("workers must be between 1 and 4".to_owned())); + } + if self.inventory.len() > Bounds::default().max_inventory + || !self.inventory.iter().all(|target| { + target.is_well_formed() + && self.variants.admit(&target.variant).as_deref() + == Some(target.variant.as_str()) + }) + { + return Err(ConfigError( + "inventory targets are malformed or too many".to_owned(), + )); + } + if self.max_snapshot_bytes == 0 + || self.max_snapshot_bytes > 64 * 1024 * 1024 + || self.max_media_bytes == 0 + || self.max_media_bytes > self.max_snapshot_bytes + || self.artifact_quota_bytes == 0 + || self.max_preparation_seconds == 0 + || self.max_preparation_seconds > 3600 + || self.scan_per_minute == 0 + || self.scan_per_minute > 128 + || self.scan_interval_seconds < 60 + || self.scan_interval_seconds > 900 + || self + .inventory + .iter() + .map(|target| (target.path.as_str(), target.variant.as_str())) + .collect::>() + .len() + > self.store_bounds().max_inventory + { + return Err(ConfigError( + "size, deadline, quota or reconciliation bounds are invalid".to_owned(), + )); + } + Ok(()) + } + + /// The store bounds this configuration admits. The scheduler visits + /// `scan_per_minute` targets once a minute, so an inventory larger than + /// `scan_per_minute` times the whole minutes of `scan_interval_seconds` + /// would miss its interval; the default store's inventory cap still + /// applies. Configured, registered and persisted inventory share it. + pub(crate) fn store_bounds(&self) -> Bounds { + let defaults = Bounds::default(); + let minutes = usize::try_from(self.scan_interval_seconds / 60).unwrap_or(usize::MAX); + Bounds { + max_inventory: defaults + .max_inventory + .min(self.scan_per_minute.saturating_mul(minutes)), + quota_bytes: self.artifact_quota_bytes, + ..defaults + } + } + + pub(crate) fn damping_context(&self, worker_token: &str) -> String { + let credential = credential_context(&self.api_base_url, &self.domain, worker_token); + let selector = serde_json::json!({ + "deployment": self.deployment_id, "host": self.public_host, "edge": self.edge_url, + "article_selector": self.article_selector, "min_text_length": self.min_text_length, + "max_media_bytes": self.max_media_bytes, "max_large_media_bytes": self.max_large_media_bytes, + "variants": self.variants, + }).to_string(); + domain_digest( + "encypher-preparation/1:worker-config", + &[credential.as_bytes(), selector.as_bytes()], + ) + } + + fn secrets(&self) -> Result { + let read = |name: &str| { + std::env::var(name) + .ok() + .filter(|value| { + !value.trim().is_empty() + && value.len() <= 4096 + && !value.bytes().any(|byte| byte.is_ascii_control()) + }) + .ok_or_else(|| ConfigError(format!("environment variable {name} is not set"))) + }; + let secrets = Secrets { + worker_token: read(&self.secrets.worker_token)?, + signal: read(&self.secrets.signal_secret)?, + publication: read(&self.secrets.publication_secret)?, + operator: read(&self.secrets.operator_secret)?, + canary: match &self.secrets.canary_token { + Some(name) => Some(read(name)?), + None => None, + }, + }; + if !secrets.worker_token.starts_with("cwk_") { + return Err(ConfigError( + "worker_token must be a CDN worker credential".to_owned(), + )); + } + let scopes = [ + &secrets.worker_token, + &secrets.signal, + &secrets.publication, + &secrets.operator, + ]; + if scopes + .iter() + .enumerate() + .any(|(index, scope)| scopes[index + 1..].contains(scope)) + { + return Err(ConfigError( + "the worker credential and three scope secrets must differ".to_owned(), + )); + } + Ok(secrets) + } +} + +fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(120)) + .build() + .expect("should build an HTTP client") +} + +/// The Encypher API with the worker credential. +#[derive(Clone)] +pub struct Api { + client: reqwest::Client, + base: String, + token: String, + canary: Option, +} + +impl Api { + /// The API at `base`. + #[must_use] + pub fn new(base: &str, secrets: &Secrets) -> Self { + Self { + client: http_client(), + base: base.trim_end_matches('/').to_owned(), + token: secrets.worker_token.clone(), + canary: secrets.canary.clone(), + } + } +} + +impl ApiTransport for Api { + async fn exchange(&self, request: ApiRequest) -> Result { + let mut builder = self.client.request( + request.method, + format!("{}{}", self.base, request.path_and_query), + ); + if request.credentialed { + builder = builder.header("x-encypher-worker-token", &self.token); + if let Some(canary) = &self.canary + && matches!( + request.path_and_query.as_str(), + "/api/v1/public/cdn/sign" + | "/api/v1/public/cdn/media/hash/prepare" + | "/api/v1/public/cdn/media/hash/finalize" + ) + { + builder = builder.header("x-encypher-canary-token", canary); + } + } + if let Some(key) = &request.idempotency_key { + builder = builder.header("idempotency-key", key); + } + if let Some(body) = request.body { + builder = builder + .header("content-type", "application/json") + .body(body); + } + let response = builder + .send() + .await + .map_err(|_| Failure::new(FailureClass::Transient, "http_error"))?; + let status = response.status().as_u16(); + let body = bounded(response, request.max_response_bytes.saturating_add(1)).await?; + Ok(ApiAnswer { status, body }) + } +} + +async fn bounded(mut response: reqwest::Response, limit: usize) -> Result { + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| Failure::new(FailureClass::Transient, "http_error"))? + { + if body.len() + chunk.len() > limit { + return Err(Failure::new(FailureClass::Transient, "response_too_large")); + } + body.extend_from_slice(&chunk); + } + Ok(Bytes::from(body)) +} + +/// Verified probe artifacts on disk, by SHA-256, sharing the worker's hard quota. +pub struct DiskProbeCache(Arc); + +impl DiskProbeCache { + /// Uses the same durable owner and quota as source/output storage. + #[must_use] + pub fn new(store: Arc) -> Self { + Self(store) + } +} + +impl ProbeArtifactCache for DiskProbeCache { + async fn get(&self, sha256: &str) -> Option { + self.0.blob("probe", sha256).ok().map(Bytes::from) + } + + async fn put(&self, sha256: &str, artifact: Bytes) -> Result<(), Failure> { + self.0 + .keep_blob("probe", sha256, &artifact) + .map_err(|error| match error { + StoreError::Quota => Failure::new(FailureClass::Transient, "quota_exhausted"), + _ => Failure::new(FailureClass::Transient, "probe_storage"), + }) + } +} + +/// One snapshot answer: its metadata and bytes. +#[derive(Debug, Clone)] +pub struct Snapshot { + /// What the delivery instance reported. + pub meta: SnapshotMeta, + /// The bytes read. + pub bytes: Bytes, +} + +/// The publisher's delivery instance, with the publication scope. +#[derive(Clone)] +pub struct Edge { + client: reqwest::Client, + base: String, + secret: String, + deployment_id: String, +} + +impl Edge { + /// The delivery instance at `base`. + #[must_use] + pub fn new(base: &str, secrets: &Secrets, deployment_id: &str) -> Self { + Self { + client: http_client(), + base: base.trim_end_matches('/').to_owned(), + secret: secrets.publication.clone(), + deployment_id: deployment_id.to_owned(), + } + } + + /// Reads `target` through the reader pipeline. + /// + /// # Errors + /// + /// A [`Failure`] for a refused, private or unobserved snapshot. + pub async fn snapshot(&self, target: &Target, read: SnapshotMode) -> Result { + self.snapshot_bounded(target, read, 64 * 1024 * 1024).await + } + + /// Reads a snapshot within the already reserved acquisition ceiling. + /// + /// # Errors + /// Refuses an oversized, unobserved or failed snapshot. + pub async fn snapshot_bounded( + &self, + target: &Target, + read: SnapshotMode, + ceiling: usize, + ) -> Result { + let nonce = format!("{:032x}", rand::random::()); + let request = SnapshotRequest { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: self.deployment_id.clone(), + target: target.clone(), + nonce, + read, + }; + let response = self + .client + .post(format!("{}{SNAPSHOT_PATH}", self.base)) + .header(TOKEN_HEADER, &self.secret) + .json(&request) + .send() + .await + .map_err(|_| Failure::new(FailureClass::Transient, "edge_unavailable"))?; + let status = response.status().as_u16(); + let meta = response + .headers() + .get(SNAPSHOT_META_HEADER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| STANDARD.decode(value).ok()) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + let limit = if status != 200 { + 64 * 1024 + } else { + match &request.read { + SnapshotMode::Whole => ceiling, + SnapshotMode::Head => 0, + SnapshotMode::Range { first, last, .. } => { + usize::try_from(last.saturating_sub(*first).saturating_add(1)) + .unwrap_or(usize::MAX) + .min(ceiling) + } + } + }; + let body = bounded(response, limit).await.map_err(|failure| { + if failure.code == "response_too_large" { + Failure::new(FailureClass::Unsupported, "snapshot_too_large") + } else { + failure + } + })?; + if status != 200 { + return Err(edge_failure(status, &body)); + } + let meta = + meta.ok_or_else(|| Failure::new(FailureClass::Integrity, "snapshot_meta_missing"))?; + Ok(Snapshot { meta, bytes: body }) + } + + /// Publishes one result envelope. The delivery instance verifies it + /// through the API before it stores anything. + /// + /// # Errors + /// + /// A [`Failure`] for a refusal. + pub async fn publish(&self, envelope: &ResultEnvelope) -> Result<(), Failure> { + self.put(&format!("{RESULTS_PREFIX}{}", envelope.identity), envelope) + .await + } + + /// Publishes one retirement marker. + /// + /// # Errors + /// + /// A [`Failure`] for a refusal. + pub async fn retire(&self, marker: &MarkerEnvelope) -> Result<(), Failure> { + self.put(&format!("{MARKERS_PREFIX}{}", marker.identity), marker) + .await + } + + async fn put(&self, path: &str, body: &impl serde::Serialize) -> Result<(), Failure> { + let response = self + .client + .put(format!("{}{path}", self.base)) + .header(TOKEN_HEADER, &self.secret) + .json(body) + .send() + .await + .map_err(|_| Failure::new(FailureClass::Transient, "edge_unavailable"))?; + let status = response.status().as_u16(); + let answer = bounded(response, 64 * 1024).await?; + if status == 200 { + Ok(()) + } else { + Err(edge_failure(status, &answer)) + } + } + + /// Whether the delivery instance accepts the publication secret, by a + /// snapshot request it must refuse for its target, never for its + /// credential. + /// + /// # Errors + /// + /// A [`Failure`] where it refuses the credential or cannot be reached. + pub async fn check_authorization(&self) -> Result<(), Failure> { + let probe = Target { + // Always outside admission, even for a publisher whose routes contain "/". + path: format!("/{}", "x".repeat(MAX_TARGET_PATH_BYTES)), + variant: String::new(), + }; + match self.snapshot(&probe, SnapshotMode::Head).await { + Err(failure) if failure.code == "target_not_configured" => Ok(()), + Err(failure) => Err(failure), + Ok(_) => Ok(()), + } + } +} + +/// The failure a delivery instance's refusal names. +fn edge_failure(status: u16, body: &[u8]) -> Failure { + let code = serde_json::from_slice::(body) + .ok() + .and_then(|value| value.get("error")?.get("code")?.as_str().map(str::to_owned)) + .unwrap_or_else(|| format!("edge_status_{status}")); + let class = match status { + 401 => FailureClass::Privacy, + 403 if code == "source_private" => FailureClass::Privacy, + 403 => FailureClass::Unsupported, + 409 if code == "retired" => FailureClass::Integrity, + 409 => FailureClass::PolicyChanged, + 410 => FailureClass::Missing, + 412 => FailureClass::Integrity, + 413 => FailureClass::Unsupported, + 422 if code.contains("provenance") => FailureClass::ExistingProvenance, + 422 => FailureClass::Integrity, + _ => FailureClass::Transient, + }; + Failure { code, class } +} diff --git a/crates/trusted-server-preparation-worker/src/http_tests.rs b/crates/trusted-server-preparation-worker/src/http_tests.rs new file mode 100644 index 0000000000..3bf51d607f --- /dev/null +++ b/crates/trusted-server-preparation-worker/src/http_tests.rs @@ -0,0 +1,702 @@ +//! Management routes and the real supervised housekeeping loop over HTTP/redb. +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; + +use axum::extract::Request; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::*; +use trusted_server_provenance_encypher::preparation::protocol::{ + MARKERS_PREFIX, MarkerEnvelope, VariantParam, VariantSchema, +}; + +fn worker_config(dir: &tempfile::TempDir, edge_url: &str) -> WorkerConfig { + toml::from_str(&format!( + r#" +listen = "0.0.0.0:8112" +store_dir = {:?} +deployment_id = "owner-http-test" +api_base_url = {:?} +edge_url = {:?} +domain = "example.com" +public_host = "www.example.com" +[secrets] +worker_token = "TEST_WORKER" +signal_secret = "TEST_SIGNAL" +publication_secret = "TEST_PUBLICATION" +operator_secret = "TEST_OPERATOR" +"#, + dir.path().to_str().expect("should have path"), + edge_url, + edge_url + )) + .expect("should parse real worker configuration") +} + +fn shared(dir: &tempfile::TempDir, bounds: Bounds, edge_url: &str) -> Arc { + shared_with_config(worker_config(dir, edge_url), bounds) +} + +fn shared_with_config(config: WorkerConfig, bounds: Bounds) -> Arc { + config + .check() + .expect("should admit publisher configuration"); + let store = Arc::new( + Store::open(&config.store_dir.join("preparation.redb"), bounds) + .expect("should own durable store"), + ); + store + .recover_storage() + .expect("should recover store ownership before HTTP"); + Arc::new(Shared { + store, + config: Arc::new(config), + secrets: Secrets { + worker_token: "cwk_fixture".to_owned(), + signal: "signal".to_owned(), + publication: "publication".to_owned(), + operator: "operator".to_owned(), + canary: None, + }, + ready: AtomicBool::new(true), + }) +} + +async fn serve(router: Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind owner HTTP"); + let url = format!( + "http://{}", + listener.local_addr().expect("should read address") + ); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("should serve owner HTTP"); + }); + (url, task) +} + +#[tokio::test] +async fn warm_registers_independent_inventory_and_reports_capacity_per_target() { + let dir = tempfile::tempdir().expect("should create store"); + let shared = shared( + &dir, + Bounds { + max_jobs: 1, + reserved_jobs: 0, + ..Bounds::default() + }, + "http://127.0.0.1:9", + ); + let (url, server) = serve(router(Arc::clone(&shared))).await; + let client = reqwest::Client::new(); + let request = + json!({"targets":[{"path":"/a","variant":""},{"path":"/b","variant":""}],"register":true}); + let answer = client + .post(format!("{url}{WARM_PATH}")) + .header(TOKEN_HEADER, "operator") + .json(&request) + .send() + .await + .expect("should reach owner"); + assert_eq!(answer.status(), StatusCode::OK); + let answer: Value = answer + .json() + .await + .expect("should read exact target outcomes"); + assert_eq!(answer["targets"][0]["admission"]["outcome"], "queued"); + assert_eq!(answer["targets"][1]["admission"]["outcome"], "queue_full"); + assert_eq!( + shared.store.counts().expect("should count").inventory, + 2, + "inventory survives failed queue admission" + ); + let status: Value = client + .get(format!("{url}{STATUS_PATH}")) + .header(TOKEN_HEADER, "operator") + .send() + .await + .expect("should read owner status") + .json() + .await + .expect("should decode status"); + assert_eq!(status["capacity"]["jobs"], 1); + assert_eq!( + status["capacity"]["inventory_targets"], 1024, + "no article-count shortcut narrows independent inventory" + ); + assert_eq!(status["counts"]["pending"], 1); + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn scopes_schema_and_body_limits_refuse_without_admitting_or_registering_work() { + let dir = tempfile::tempdir().expect("should create store"); + let shared = shared(&dir, Bounds::default(), "http://127.0.0.1:9"); + let (url, server) = serve(router(Arc::clone(&shared))).await; + let client = reqwest::Client::new(); + for secret in ["signal", "publication", "wrong"] { + let answer = client + .post(format!("{url}{WARM_PATH}")) + .header(TOKEN_HEADER, secret) + .json(&json!({"targets":[{"path":"/a"}],"register":true})) + .send() + .await + .expect("should reach owner"); + assert_eq!(answer.status(), StatusCode::UNAUTHORIZED); + } + let malformed = client + .post(format!("{url}{WARM_PATH}")) + .header(TOKEN_HEADER, "operator") + .json(&json!({"targets":[{"path":"/a"}],"extra":"not accepted"})) + .send() + .await + .expect("should reach owner"); + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); + let refused: Value = client.post(format!("{url}{WARM_PATH}")).header(TOKEN_HEADER, "operator").json(&json!({"targets":[{"path":"//other.example.com/escape"},{"path":"/a","variant":"reader_cookie=private"}],"register":true})).send().await.expect("should reach owner").json().await.expect("should report every refused target"); + assert!( + refused["targets"] + .as_array() + .expect("should list targets") + .iter() + .all(|target| target["outcome"] == "not_admitted") + ); + let large = client + .post(format!("{url}{SIGNALS_PATH}")) + .header(TOKEN_HEADER, "signal") + .body(vec![b'x'; MAX_SIGNAL_BYTES + 1]) + .send() + .await + .expect("should reach bounded route"); + assert_eq!(large.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(shared.store.counts().expect("should count").pending, 0); + assert_eq!(shared.store.counts().expect("should count").inventory, 0); + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn source_bytes_require_public_admission_and_last_reference_retirement_withdraws_them() { + let dir = tempfile::tempdir().expect("should create storage"); + let shared = shared(&dir, Bounds::default(), "http://127.0.0.1:9"); + let bytes = b"admitted original source"; + let sha: String = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let first = "a".repeat(64); + let second = "b".repeat(64); + shared + .store + .keep_source(&sha, "image/png", bytes, &first) + .expect("should retain first public admission"); + shared + .store + .keep_source(&sha, "image/png", bytes, &second) + .expect("should retain independent second reference"); + let (url, server) = serve(router(Arc::clone(&shared))).await; + let client = reqwest::Client::new(); + let source_url = format!("{url}{WORKER_SOURCE_PREFIX}{sha}"); + assert_eq!( + client + .get(&source_url) + .header(TOKEN_HEADER, "signal") + .send() + .await + .expect("should refuse signal scope") + .status(), + StatusCode::NOT_FOUND + ); + let answer = client + .get(&source_url) + .header(TOKEN_HEADER, "publication") + .send() + .await + .expect("should read admitted source"); + assert_eq!(answer.status(), StatusCode::OK); + assert_eq!(answer.headers()[header::X_CONTENT_TYPE_OPTIONS], "nosniff"); + assert_eq!(answer.headers()[header::CONTENT_DISPOSITION], "attachment"); + assert_eq!( + answer.headers()[header::CONTENT_SECURITY_POLICY], + "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'" + ); + assert_eq!( + answer + .bytes() + .await + .expect("should read exact bytes") + .as_ref(), + bytes + ); + for (identity, expected) in [(&first, StatusCode::OK), (&second, StatusCode::NOT_FOUND)] { + let answer = client + .post(format!("{url}{RETIRE_PATH}")) + .header(TOKEN_HEADER, "operator") + .json(&RetireRequest { + identity: identity.clone(), + }) + .send() + .await + .expect("should retire through owner"); + assert_eq!(answer.status(), StatusCode::OK); + assert_eq!( + client + .get(&source_url) + .header(TOKEN_HEADER, "publication") + .send() + .await + .expect("should check retained source authorization") + .status(), + expected + ); + } + assert!( + !shared + .store + .source(&sha) + .expect("should retain admission record") + .expect("should retain tombstone") + .public + ); + assert!( + !dir.path().join("sources").join(&sha).exists(), + "last-reference retirement unlinks before releasing source bytes" + ); + server.abort(); + let _ = server.await; +} + +struct MarkerBoundary { + fail: AtomicBool, + attempts: AtomicUsize, + markers: Mutex>, +} + +async fn marker_boundary(State(state): State>, request: Request) -> Response { + if request.uri().path().ends_with("/capabilities") { + return json( + StatusCode::FORBIDDEN, + json!({"error":{"code":"CREATION_DISABLED"}}), + ); + } + assert!(request.uri().path().starts_with(MARKERS_PREFIX)); + assert_eq!( + request + .headers() + .get(TOKEN_HEADER) + .expect("should use publication scope"), + "publication" + ); + let bytes = axum::body::to_bytes(request.into_body(), MAX_SIGNAL_BYTES) + .await + .expect("should read bounded marker"); + let marker: MarkerEnvelope = + serde_json::from_slice(&bytes).expect("should use frozen marker protocol"); + state + .markers + .lock() + .expect("should lock markers") + .push(marker); + state.attempts.fetch_add(1, Ordering::SeqCst); + if state.fail.load(Ordering::SeqCst) { + json( + StatusCode::SERVICE_UNAVAILABLE, + json!({"error":{"code":"OFFLINE"}}), + ) + } else { + json(StatusCode::OK, json!({"stored":true})) + } +} + +async fn until(mut condition: impl FnMut() -> bool) { + tokio::time::timeout(Duration::from_secs(5), async { + while !condition() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("should reach bounded observable condition"); +} + +#[tokio::test] +async fn automatic_retirement_outbox_retries_after_restart_and_acknowledges_only_successful_put() { + let boundary = Arc::new(MarkerBoundary { + fail: AtomicBool::new(true), + attempts: AtomicUsize::new(0), + markers: Mutex::new(Vec::new()), + }); + let (edge_url, edge_server) = serve( + Router::new() + .fallback(marker_boundary) + .with_state(Arc::clone(&boundary)), + ) + .await; + let dir = tempfile::tempdir().expect("should create durable owner storage"); + let identity = "a".repeat(64); + { + let owner = shared(&dir, Bounds::default(), &edge_url); + owner + .store + .retire(&identity, "owner-http-test") + .expect("should commit tombstone and marker before sending"); + let task = tokio::spawn(housekeeping( + Arc::clone(&owner), + Edge::new(&edge_url, &owner.secrets, "owner-http-test"), + Api::new(&edge_url, &owner.secrets), + )); + until(|| boundary.attempts.load(Ordering::SeqCst) == 1).await; + assert_eq!( + owner + .store + .counts() + .expect("should report exact local status") + .retirement_pending, + 1 + ); + task.abort(); + let _ = task.await; + } + boundary.fail.store(false, Ordering::SeqCst); + let owner = shared(&dir, Bounds::default(), &edge_url); + assert_eq!( + owner + .store + .outbox() + .expect("should persist failed send across restart") + .len(), + 1 + ); + let task = tokio::spawn(housekeeping( + Arc::clone(&owner), + Edge::new(&edge_url, &owner.secrets, "owner-http-test"), + Api::new(&edge_url, &owner.secrets), + )); + until(|| { + owner + .store + .counts() + .expect("should observe durable acknowledgement") + .retirement_acknowledged + == 1 + }) + .await; + assert!( + owner + .store + .outbox() + .expect("should settle durable outbox") + .is_empty() + ); + { + let markers = boundary + .markers + .lock() + .expect("should inspect actual network payloads"); + assert_eq!(markers.len(), 2); + assert_eq!( + markers[0], markers[1], + "restart retries the exact immutable retirement epoch" + ); + assert_eq!(markers[1].identity, identity); + } + task.abort(); + let _ = task.await; + edge_server.abort(); + let _ = edge_server.await; +} + +#[test] +fn configuration_cadence_refuses_subminute_intervals_and_floors_partial_minutes() { + let dir = tempfile::tempdir().expect("should create store"); + let mut config = worker_config(&dir, "http://127.0.0.1:9"); + config.scan_per_minute = 1; + for interval in [0, 59, 901] { + config.scan_interval_seconds = interval; + assert!( + config.check().is_err(), + "interval {interval} must be refused even with empty inventory" + ); + } + for (interval, capacity) in [(60, 1), (61, 1), (900, 15)] { + config.scan_interval_seconds = interval; + config.inventory = (0..capacity) + .map(|index| Target { + path: format!("/article/{index:04}"), + variant: String::new(), + }) + .collect(); + config + .check() + .expect("should admit exactly the cadence capacity"); + assert_eq!(config.store_bounds().max_inventory, capacity); + config.inventory.push(Target { + path: "/overflow".to_owned(), + variant: String::new(), + }); + assert!( + config.check().is_err(), + "interval {interval} must refuse a distinct target above capacity {capacity}" + ); + } +} + +#[test] +fn configured_inventory_counts_distinct_path_variant_pairs_but_preserves_raw_entry_limit() { + let dir = tempfile::tempdir().expect("should create store"); + let mut config = worker_config(&dir, "http://127.0.0.1:9"); + config.scan_per_minute = 1; + config.scan_interval_seconds = 61; + config.variants = VariantSchema(vec![VariantParam { + name: "edition".to_owned(), + values: vec!["uk".to_owned(), "us".to_owned()], + }]); + let target = Target { + path: "/article".to_owned(), + variant: "edition=uk".to_owned(), + }; + config.inventory = vec![target.clone(); 1024]; + config + .check() + .expect("duplicate configured entries consume one scan slot"); + config.inventory.push(target.clone()); + assert!( + config.check().is_err(), + "the raw 1024-entry configuration limit still applies to duplicates" + ); + config.inventory = vec![target.clone(), target]; + config.inventory.push(Target { + path: "/article".to_owned(), + variant: "edition=us".to_owned(), + }); + assert!( + config.check().is_err(), + "two variants at the same path consume two scan slots" + ); + config.scan_per_minute = 2; + config + .check() + .expect("two scan slots should admit both distinct variants"); + let inventory = config.inventory.clone(); + let shared = shared_with_config(config.clone(), config.store_bounds()); + assert_eq!( + shared + .store + .register(&inventory) + .expect("runtime registration should share configured deduplication"), + 2 + ); + assert_eq!(shared.store.counts().expect("should count").inventory, 2); + config.inventory[0].variant = "edition=private".to_owned(); + assert!( + config.check().is_err(), + "deduplication must not bypass configured target validation" + ); +} + +#[test] +fn default_cadence_admits_1024_configured_and_registered_targets() { + let dir = tempfile::tempdir().expect("should create store"); + let mut config = worker_config(&dir, "http://127.0.0.1:9"); + assert_eq!(config.scan_per_minute, 128); + assert_eq!(config.scan_interval_seconds, 900); + assert_eq!(config.store_bounds().max_inventory, 1024); + assert_eq!( + config.store_bounds().quota_bytes, + config.artifact_quota_bytes + ); + config.inventory = (0..1024) + .map(|index| Target { + path: format!("/article/{index:04}"), + variant: String::new(), + }) + .collect(); + let inventory = config.inventory.clone(); + let shared = shared_with_config(config.clone(), config.store_bounds()); + assert_eq!( + shared + .store + .register(&inventory) + .expect("default cadence should retain full inventory capacity"), + 1024 + ); + assert_eq!(shared.store.counts().expect("should count").inventory, 1024); +} + +#[tokio::test] +async fn warm_registration_enforces_configured_capacity_with_duplicates_and_atomic_overflow() { + for (interval, capacity) in [(61, 1), (900, 15)] { + let dir = tempfile::tempdir().expect("should create store"); + let mut config = worker_config(&dir, "http://127.0.0.1:9"); + config.scan_per_minute = 1; + config.scan_interval_seconds = interval; + let shared = shared_with_config(config.clone(), config.store_bounds()); + let (url, server) = serve(router(Arc::clone(&shared))).await; + let client = reqwest::Client::new(); + for index in 0..capacity - 1 { + let answer = client + .post(format!("{url}{WARM_PATH}")) + .header(TOKEN_HEADER, "operator") + .json(&json!({ + "targets": [{"path": format!("/article/{index:04}")}], + "register": true + })) + .send() + .await + .expect("should reach owner"); + assert_eq!(answer.status(), StatusCode::OK); + } + let last = json!({"path": format!("/article/{:04}", capacity - 1)}); + let overflow = json!({"path": "/overflow"}); + let answer = client + .post(format!("{url}{WARM_PATH}")) + .header(TOKEN_HEADER, "operator") + .json(&json!({"targets": [last.clone(), overflow.clone()], "register": true})) + .send() + .await + .expect("should reach owner"); + assert_eq!(answer.status(), StatusCode::CONFLICT); + let answer: Value = answer.json().await.expect("should decode refusal"); + assert_eq!(answer["error"]["code"], "inventory_full"); + assert_eq!( + shared.store.counts().expect("should count").inventory, + capacity - 1, + "a rejected batch cannot partially register its first new target" + ); + for targets in [vec![last.clone()], vec![last.clone(), last.clone()]] { + let answer = client + .post(format!("{url}{WARM_PATH}")) + .header(TOKEN_HEADER, "operator") + .json(&json!({"targets": targets, "register": true})) + .send() + .await + .expect("should reach owner"); + assert_eq!(answer.status(), StatusCode::OK); + } + let answer = client + .post(format!("{url}{WARM_PATH}")) + .header(TOKEN_HEADER, "operator") + .json(&json!({"targets": [last, overflow], "register": true})) + .send() + .await + .expect("should reach owner"); + assert_eq!(answer.status(), StatusCode::CONFLICT); + let answer: Value = answer.json().await.expect("should decode refusal"); + assert_eq!(answer["error"]["code"], "inventory_full"); + let counts = shared.store.counts().expect("should count"); + assert_eq!(counts.inventory, capacity); + assert_eq!(counts.pending, capacity); + let status = client + .get(format!("{url}{STATUS_PATH}")) + .header(TOKEN_HEADER, "operator") + .send() + .await + .expect("should read owner status"); + assert_eq!(status.status(), StatusCode::OK); + let status: Value = status.json().await.expect("should decode status"); + assert_eq!(status["capacity"]["inventory_targets"], capacity); + assert_eq!(status["counts"]["inventory"], capacity); + + let targets: Vec<_> = (0..1024) + .map(|index| json!({"path": format!("/warm-only/{index:04}")})) + .collect(); + let answer = client + .post(format!("{url}{WARM_PATH}")) + .header(TOKEN_HEADER, "operator") + .json(&json!({"targets": targets, "register": false})) + .send() + .await + .expect("should reach owner"); + assert_eq!( + answer.status(), + StatusCode::OK, + "the warm request limit remains 1024 independently of registration capacity" + ); + let answer: Value = answer.json().await.expect("should decode warm outcomes"); + assert_eq!( + answer["targets"] + .as_array() + .expect("should list outcomes") + .len(), + 1024 + ); + assert_eq!( + shared.store.counts().expect("should count").inventory, + capacity + ); + server.abort(); + let _ = server.await; + } +} + +#[tokio::test] +async fn concurrent_http_registrations_cannot_exceed_configured_inventory_capacity() { + let dir = tempfile::tempdir().expect("should create store"); + let mut config = worker_config(&dir, "http://127.0.0.1:9"); + config.scan_per_minute = 1; + config.scan_interval_seconds = 900; + let shared = shared_with_config(config.clone(), config.store_bounds()); + let (url, server) = serve(router(Arc::clone(&shared))).await; + let client = reqwest::Client::new(); + let barrier = Arc::new(tokio::sync::Barrier::new(25)); + let mut requests = Vec::new(); + for index in 0..24 { + let client = client.clone(); + let url = url.clone(); + let barrier = Arc::clone(&barrier); + requests.push(tokio::spawn(async move { + barrier.wait().await; + let answer = client + .post(format!("{url}{WARM_PATH}")) + .header(TOKEN_HEADER, "operator") + .json(&json!({ + "targets": [{"path": format!("/article/{index:04}")}], + "register": true + })) + .send() + .await + .expect("should reach owner"); + let status = answer.status(); + let body: Value = answer.json().await.expect("should decode registration"); + (status, body) + })); + } + barrier.wait().await; + let mut accepted = 0; + let mut refused = 0; + for request in requests { + let (status, body) = request.await.expect("should join registration"); + match status { + StatusCode::OK => { + assert_eq!(body["targets"][0]["admission"]["outcome"], "queued"); + accepted += 1; + } + StatusCode::CONFLICT => { + assert_eq!(body["error"]["code"], "inventory_full"); + refused += 1; + } + _ => panic!("unexpected registration response: {status} {body}"), + } + } + assert_eq!(accepted, 15); + assert_eq!(refused, 9); + let counts = shared.store.counts().expect("should count"); + assert_eq!(counts.inventory, 15); + assert_eq!(counts.pending, 15); + let status: Value = client + .get(format!("{url}{STATUS_PATH}")) + .header(TOKEN_HEADER, "operator") + .send() + .await + .expect("should read owner status") + .json() + .await + .expect("should decode status"); + assert_eq!(status["capacity"]["inventory_targets"], 15); + assert_eq!(status["counts"]["inventory"], 15); + server.abort(); + let _ = server.await; +} diff --git a/crates/trusted-server-preparation-worker/src/lib.rs b/crates/trusted-server-preparation-worker/src/lib.rs new file mode 100644 index 0000000000..315ce52f1e --- /dev/null +++ b/crates/trusted-server-preparation-worker/src/lib.rs @@ -0,0 +1,1051 @@ +//! The publisher-operated native preparation worker for Encypher's +//! asynchronous prepared signing. +//! +//! One long-lived process owns one durable store. It admits reader hints, +//! publication events and operator warms into bounded durable jobs, +//! claims them with renewable fenced leases on a fixed number of +//! supervised tasks, prepares and signs content outside reader delivery, +//! and publishes results the delivery instance verifies through the API. +//! Reconciliation re-reads the registered inventory on a persisted cursor, +//! and retirement markers leave through a durable outbox until the +//! delivery instance acknowledges them. +//! +//! The process is ready only after it proves store ownership, the delivery +//! instance's acceptance of its publication credential and the API's +//! policy answer; until then `/v1/preparation/ready` answers `503`. + +pub mod client; +pub mod prepare; +pub mod store; + +#[cfg(test)] +#[path = "http_tests.rs"] +mod http_tests; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use axum::Router; +use axum::body::Bytes; +use axum::extract::{Path as UrlPath, State}; +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use serde::{Deserialize, Serialize}; + +use trusted_server_provenance_encypher::preparation::pipeline; +use trusted_server_provenance_encypher::preparation::protocol::{ + MAX_SIGNAL_BYTES, PREPARATION_PROTOCOL, READY_PATH, RETIRE_PATH, SIGNALS_PATH, STATUS_PATH, + SignalRequest, TOKEN_HEADER, Target, WARM_PATH, WORKER_SOURCE_PREFIX, is_digest, token_matches, +}; + +use client::{Api, Edge, Secrets, WorkerConfig}; +use prepare::{Context, now}; +use store::{Admission, Bounds, JobOrigin, Store, StoreError}; + +/// Shared state of the HTTP routes and tasks. +struct Shared { + store: Arc, + config: Arc, + secrets: Secrets, + ready: AtomicBool, +} + +/// An operator warm: targets to prepare now and, optionally, to register +/// in the reconciliation inventory. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WarmRequest { + /// The targets. + pub targets: Vec, + /// Register them for periodic reconciliation. + #[serde(default)] + pub register: bool, + /// Clear damped outcomes of these targets: an explicit operator retry. + #[serde(default)] + pub retry: bool, +} + +/// An operator retirement. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RetireRequest { + /// The identity retired. + pub identity: String, +} + +pub(crate) fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output +} + +fn authorized(headers: &HeaderMap, secret: &str) -> bool { + headers + .get(TOKEN_HEADER) + .and_then(|value| value.to_str().ok()) + .is_some_and(|presented| token_matches(secret, presented)) +} + +fn json(status: StatusCode, value: serde_json::Value) -> Response { + ( + status, + [(header::CACHE_CONTROL, "no-store")], + axum::Json(value), + ) + .into_response() +} + +fn store_error(error: &StoreError) -> Response { + log::error!("preparation: {error}"); + let (status, code) = match error { + StoreError::InventoryFull => (StatusCode::CONFLICT, "inventory_full"), + StoreError::Quota => (StatusCode::SERVICE_UNAVAILABLE, "quota_exhausted"), + _ => (StatusCode::SERVICE_UNAVAILABLE, "store_unavailable"), + }; + json(status, serde_json::json!({"error": {"code": code}})) +} + +fn admitted(target: &Target, config: &WorkerConfig) -> bool { + target.is_well_formed() + && config.variants.admit(&target.variant).as_deref() == Some(target.variant.as_str()) +} + +async fn signals(State(shared): State>, headers: HeaderMap, body: Bytes) -> Response { + if !authorized(&headers, &shared.secrets.signal) { + return json( + StatusCode::UNAUTHORIZED, + serde_json::json!({"error": {"code": "unauthorized"}}), + ); + } + if body.len() > MAX_SIGNAL_BYTES { + return json( + StatusCode::PAYLOAD_TOO_LARGE, + serde_json::json!({"error": {"code": "too_large"}}), + ); + } + let Ok(signal) = serde_json::from_slice::(&body) else { + return json( + StatusCode::BAD_REQUEST, + serde_json::json!({"error": {"code": "malformed"}}), + ); + }; + if signal.protocol != PREPARATION_PROTOCOL + || signal.deployment_id != shared.config.deployment_id + || !admitted(&signal.target, &shared.config) + || !signal.items.iter().all(|item| is_digest(&item.identity)) + { + return json( + StatusCode::FORBIDDEN, + serde_json::json!({"error": {"code": "not_admitted"}}), + ); + } + match shared + .store + .admit(&signal.target, JobOrigin::Signal, &signal.items, now()) + { + Ok(admission @ (Admission::Queued { .. } | Admission::Coalesced { .. })) => { + json(StatusCode::ACCEPTED, serde_json::json!(admission)) + } + Ok(Admission::QueueFull) => json( + StatusCode::SERVICE_UNAVAILABLE, + serde_json::json!(Admission::QueueFull), + ), + Ok(admission) => json(StatusCode::OK, serde_json::json!(admission)), + Err(error) => store_error(&error), + } +} + +async fn warm(State(shared): State>, headers: HeaderMap, body: Bytes) -> Response { + if !authorized(&headers, &shared.secrets.operator) { + return json( + StatusCode::UNAUTHORIZED, + serde_json::json!({"error": {"code": "unauthorized"}}), + ); + } + let Ok(request) = serde_json::from_slice::(&body) else { + return json( + StatusCode::BAD_REQUEST, + serde_json::json!({"error": {"code": "malformed"}}), + ); + }; + if request.targets.len() > Bounds::default().max_inventory { + return json( + StatusCode::PAYLOAD_TOO_LARGE, + serde_json::json!({"error": {"code": "too_many_targets"}}), + ); + } + if request.register { + let admitted_targets: Vec<_> = request + .targets + .iter() + .filter(|target| admitted(target, &shared.config)) + .cloned() + .collect(); + if let Err(error) = shared.store.register(&admitted_targets) { + return store_error(&error); + } + } + let mut outcomes = Vec::with_capacity(request.targets.len()); + for target in &request.targets { + if !admitted(target, &shared.config) { + outcomes.push(serde_json::json!({"target": target, "outcome": "not_admitted"})); + continue; + } + if request.retry + && let Err(error) = shared.store.clear_outcomes(target, now()) + { + return store_error(&error); + } + match shared.store.admit(target, JobOrigin::Warm, &[], now()) { + Ok(admission) => { + outcomes.push(serde_json::json!({"target": target, "admission": admission})) + } + Err(error) => return store_error(&error), + } + } + json( + StatusCode::OK, + serde_json::json!({"protocol": PREPARATION_PROTOCOL, "targets": outcomes}), + ) +} + +async fn retire(State(shared): State>, headers: HeaderMap, body: Bytes) -> Response { + if !authorized(&headers, &shared.secrets.operator) { + return json( + StatusCode::UNAUTHORIZED, + serde_json::json!({"error": {"code": "unauthorized"}}), + ); + } + let Ok(request) = serde_json::from_slice::(&body) else { + return json( + StatusCode::BAD_REQUEST, + serde_json::json!({"error": {"code": "malformed"}}), + ); + }; + if !is_digest(&request.identity) { + return json( + StatusCode::BAD_REQUEST, + serde_json::json!({"error": {"code": "identity_invalid"}}), + ); + } + match shared + .store + .retire(&request.identity, &shared.config.deployment_id) + { + // Retired locally; marker publication is pending until the + // delivery instance acknowledges it, see status. + Ok(epoch) => { + let acknowledged = match shared.store.retirement(&request.identity) { + Ok(Some(retirement)) => retirement.acknowledged, + Ok(None) => false, + Err(error) => return store_error(&error), + }; + json( + StatusCode::OK, + serde_json::json!({ + "identity": request.identity, "epoch": epoch, + "marker": if acknowledged { "acknowledged" } else { "pending" }, + }), + ) + } + Err(error) => store_error(&error), + } +} + +async fn status(State(shared): State>, headers: HeaderMap) -> Response { + if !authorized(&headers, &shared.secrets.operator) { + return json( + StatusCode::UNAUTHORIZED, + serde_json::json!({"error": {"code": "unauthorized"}}), + ); + } + let refusals = match shared.store.active_outcome_counts(now()) { + Ok(refusals) => refusals, + Err(error) => return store_error(&error), + }; + match shared.store.counts() { + Ok(counts) => json( + StatusCode::OK, + serde_json::json!({ + "protocol": PREPARATION_PROTOCOL, + "ready": shared.ready.load(Ordering::Relaxed), + "counts": counts, + "active_refusals_by_code": refusals, + "capacity": { + "jobs": shared.store.bounds().max_jobs, + "reader_jobs": shared.store.bounds().max_jobs - shared.store.bounds().reserved_jobs, + "queue_bytes": shared.store.bounds().max_queue_bytes, + "reader_queue_bytes": shared.store.bounds().max_queue_bytes - shared.store.bounds().reserved_bytes, + "inventory_targets": shared.store.bounds().max_inventory, + "artifact_bytes": shared.store.bounds().quota_bytes, + }, + }), + ), + Err(error) => store_error(&error), + } +} + +async fn ready(State(shared): State>) -> Response { + if shared.ready.load(Ordering::Relaxed) { + json(StatusCode::OK, serde_json::json!({"ready": true})) + } else { + json( + StatusCode::SERVICE_UNAVAILABLE, + serde_json::json!({"ready": false}), + ) + } +} + +async fn source( + State(shared): State>, + headers: HeaderMap, + UrlPath(sha256): UrlPath, +) -> Response { + if !authorized(&headers, &shared.secrets.publication) || !is_digest(&sha256) { + return StatusCode::NOT_FOUND.into_response(); + } + let Ok(Some(record)) = shared.store.source(&sha256) else { + return StatusCode::NOT_FOUND.into_response(); + }; + // A digest held privately is never public authorization. + if !record.public || record.references.is_empty() { + return StatusCode::NOT_FOUND.into_response(); + } + match shared.store.blob("source", &sha256) { + Ok(bytes) => ( + [ + (header::CONTENT_TYPE, record.mime), + (header::CACHE_CONTROL, "private, no-store".to_owned()), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_owned()), + (header::CONTENT_DISPOSITION, "attachment".to_owned()), + ( + header::CONTENT_SECURITY_POLICY, + "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'".to_owned(), + ), + ], + bytes, + ) + .into_response(), + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} + +/// The worker's routes over `store`. +fn router(shared: Arc) -> Router { + Router::new() + .route( + SIGNALS_PATH, + post(signals).layer(axum::extract::DefaultBodyLimit::max(MAX_SIGNAL_BYTES)), + ) + .route(WARM_PATH, post(warm)) + .route(RETIRE_PATH, post(retire)) + .route(STATUS_PATH, get(status)) + .route(READY_PATH, get(ready)) + .route(&format!("{WORKER_SOURCE_PREFIX}{{sha256}}"), get(source)) + .layer(axum::extract::DefaultBodyLimit::max( + MAX_SIGNAL_BYTES * 1024, + )) + .with_state(shared) +} + +/// Runs one preparation task on its own thread until `stop` is set: claim, +/// run with lease renewal, settle. No work outlives its claim. +fn task(context: Arc, stop: Arc) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build a task runtime"); + runtime.block_on(async move { + let lease = Duration::from_secs(context.store.bounds().lease_seconds / 3); + while !stop.load(Ordering::Relaxed) { + let token = rand::random::(); + let claim = match context.store.claim(now(), token) { + Ok(Some(claim)) => claim, + Ok(None) => { + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + Err(error) => { + log::error!("preparation: claim failed: {error}"); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; + } + }; + let key = claim.job.key.clone(); + let work = prepare::run(&context, &claim); + tokio::pin!(work); + let mut renew = tokio::time::interval(lease); + renew.tick().await; + let result = loop { + tokio::select! { + result = &mut work => break result, + _ = renew.tick() => { + match context.store.renew(&key, token, now()) { + Ok(true) => {} + // Another owner holds the claim now: this + // attempt is abandoned, never settled. + _ => break Err(pipeline::Failure::new( + pipeline::FailureClass::Transient, + "lease_lost", + )), + } + } + } + }; + let settled = match result { + Ok(()) => context.store.complete(&key, token, now()), + Err(failure) => { + log::info!("preparation: attempt failed code={}", failure.code); + context.store.retry(&key, token, now(), &failure.code) + } + }; + if let Err(error) = settled { + log::error!("preparation: settle failed: {error}"); + } + } + }); +} + +/// Publishes queued retirement markers until each is acknowledged, and +/// admits due inventory targets for reconciliation, once a minute. +async fn housekeeping(shared: Arc, edge: Edge, api: Api) { + let context = Context::new( + Arc::clone(&shared.store), + api, + edge.clone(), + Arc::clone(&shared.config), + &shared.secrets.worker_token, + ); + let page = format!("https://{}/", shared.config.public_host); + let mut tick = tokio::time::interval(Duration::from_secs(60)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tick.tick().await; + if let Err(error) = shared.store.prune_expired_outcomes(now()) { + log::error!("preparation: expired outcome pruning failed: {error}"); + } + if let Err(error) = shared.store.recover_expired(now()) { + log::error!("preparation: expired claim recovery failed: {error}"); + } + match shared.store.due_inventory( + now(), + shared.config.scan_interval_seconds, + shared.config.scan_per_minute, + ) { + Ok(due) => { + for target in due { + if let Err(error) = + shared + .store + .admit(&target, JobOrigin::Reconcile, &[], now()) + { + log::error!("preparation: reconciliation admission failed: {error}"); + } + } + } + Err(error) => log::error!("preparation: inventory scan failed: {error}"), + } + // A signed release change can make a previously unsupported item eligible. + // This is metadata selection only: no probe execution or API creation. + match tokio::time::timeout( + Duration::from_secs(2), + prepare::refresh_probe_context(&context, &page), + ) + .await + { + Ok(Ok(())) => {} + Ok(Err(failure)) => log::debug!( + "preparation: probe observation deferred code={}", + failure.code + ), + Err(_) => log::debug!("preparation: probe observation deferred code=deadline"), + } + if let Ok(markers) = shared.store.outbox_batch(16) { + for marker in markers { + match tokio::time::timeout(Duration::from_secs(2), edge.retire(&marker)).await { + Ok(Ok(())) => { + if let Err(error) = shared + .store + .acknowledge_marker(&marker.identity, marker.epoch) + { + log::error!("preparation: marker acknowledgement failed: {error}"); + } + } + Ok(Err(failure)) => log::info!( + "preparation: marker publication pending code={}", + failure.code + ), + Err(_) => log::info!("preparation: marker publication pending code=deadline"), + } + } + } + } +} + +/// Why the worker stopped. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +#[display("preparation worker: {_0}")] +pub struct WorkerError(pub String); + +impl core::error::Error for WorkerError {} + +/// The asynchronous tasks one worker process supervises beside its +/// preparation threads, which report an unrequested stop on `failures`. +struct Supervised { + readiness: tokio::task::JoinHandle<()>, + keeper: tokio::task::JoinHandle<()>, + failures: tokio::sync::mpsc::Receiver<()>, +} + +/// Why `name`, which never ends on its own, ended before shutdown. +fn premature(name: &str, joined: &Result<(), tokio::task::JoinError>) -> String { + match joined { + Ok(()) => format!("{name} stopped"), + Err(error) => format!("{name} failed: {error}"), + } +} + +/// Serves until `signal` resolves or anything supervised stops, then stops +/// everything it owns: readiness is withdrawn, `stop` is set for the +/// preparation threads, the server drains through `shutdown`, and the +/// readiness and housekeeping tasks are cancelled and joined. Each handle +/// is polled to completion exactly once. The caller joins the threads. +/// +/// # Errors +/// +/// A [`WorkerError`] where the signal handler, the listener or any +/// supervised task stopped without being asked to. +async fn supervise( + serving: F, + shutdown: tokio::sync::oneshot::Sender<()>, + signal: S, + mut supervised: Supervised, + ready: &AtomicBool, + stop: &AtomicBool, +) -> Result<(), WorkerError> +where + F: Future>, + S: Future>, +{ + tokio::pin!(serving); + let mut served = false; + let mut readiness_joined = false; + let mut keeper_joined = false; + let mut failure = tokio::select! { + result = &mut serving => { + served = true; + Some(match result { + Ok(()) => "the listener stopped".to_owned(), + Err(error) => format!("the listener failed: {error}"), + }) + } + signalled = signal => signalled + .err() + .map(|error| format!("the shutdown signal failed: {error}")), + _ = supervised.failures.recv() => Some("a preparation task stopped".to_owned()), + joined = &mut supervised.readiness => { + readiness_joined = true; + Some(premature("readiness", &joined)) + } + joined = &mut supervised.keeper => { + keeper_joined = true; + Some(premature("housekeeping", &joined)) + } + }; + ready.store(false, Ordering::Relaxed); + stop.store(true, Ordering::Relaxed); + // Resolves the shutdown future axum awaits on a task of its own; the + // send fails only where the server has already finished. + let _ = shutdown.send(()); + let drained = if served { Ok(()) } else { serving.await }; + if let Err(error) = drained { + failure.get_or_insert_with(|| format!("the listener failed: {error}")); + } + for (name, handle, joined) in [ + ("readiness", supervised.readiness, readiness_joined), + ("housekeeping", supervised.keeper, keeper_joined), + ] { + if joined { + continue; + } + handle.abort(); + if let Err(error) = handle.await + && error.is_panic() + { + failure.get_or_insert_with(|| format!("{name} failed: {error}")); + } + } + match failure { + Some(message) => Err(WorkerError(message)), + None => Ok(()), + } +} + +/// Runs the worker described by the configuration at `config_path` until +/// interrupted. +/// +/// # Errors +/// +/// A [`WorkerError`] where the configuration, store or listener cannot be +/// set up, including a store another process owns, or where a supervised +/// task, thread or the listener stops without being asked to. +pub fn run(config_path: &Path) -> Result<(), WorkerError> { + let (config, secrets) = + WorkerConfig::load(config_path).map_err(|error| WorkerError(error.to_string()))?; + std::fs::create_dir_all(config.store_dir.join("probes")) + .map_err(|error| WorkerError(error.to_string()))?; + let store = Arc::new( + Store::open( + &config.store_dir.join("preparation.redb"), + config.store_bounds(), + ) + .map_err(|error| WorkerError(error.to_string()))?, + ); + store + .recover_storage() + .map_err(|error| WorkerError(error.to_string()))?; + store + .refresh_context("config", &config.damping_context(&secrets.worker_token)) + .map_err(|error| WorkerError(error.to_string()))?; + log::info!("preparation: store owned and filesystem accounting reconciled"); + store + .register(&config.inventory) + .map_err(|error| WorkerError(error.to_string()))?; + let config = Arc::new(config); + let api = Api::new(&config.api_base_url, &secrets); + let edge = Edge::new(&config.edge_url, &secrets, &config.deployment_id); + let shared = Arc::new(Shared { + store: Arc::clone(&store), + config: Arc::clone(&config), + secrets: secrets.clone(), + ready: AtomicBool::new(false), + }); + let address: SocketAddr = config + .listen + .parse() + .map_err(|_| WorkerError(format!("listen address {} is invalid", config.listen)))?; + let stop = Arc::new(AtomicBool::new(false)); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| WorkerError(error.to_string()))?; + // Every preparation thread started inside the runtime is stopped and + // joined below, on every path out of it, startup errors included. + let mut threads = Vec::with_capacity(config.workers); + let supervised = runtime.block_on(async { + let listener = tokio::net::TcpListener::bind(address) + .await + .map_err(|error| WorkerError(error.to_string()))?; + log::info!("preparation: listening on {address}"); + // Each thread reports at most once, so a send never finds it full. + let (failed, failures) = tokio::sync::mpsc::channel(config.workers); + for index in 0..config.workers { + let context = Arc::new(Context::new( + Arc::clone(&store), + api.clone(), + edge.clone(), + Arc::clone(&config), + &secrets.worker_token, + )); + let stop = Arc::clone(&stop); + let shared = Arc::clone(&shared); + let failed = failed.clone(); + let thread = std::thread::Builder::new() + .name(format!("preparation-{index}")) + .spawn(move || { + while !shared.ready.load(Ordering::Relaxed) && !stop.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(500)); + } + let ran = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + task(context, Arc::clone(&stop)); + })); + // A task ends cleanly only when it was told to stop. + let clean = ran.is_ok() && stop.load(Ordering::Relaxed); + if !clean { + shared.ready.store(false, Ordering::Relaxed); + stop.store(true, Ordering::Relaxed); + let _ = failed.try_send(()); + } + clean + }) + .map_err(|error| { + WorkerError(format!("a preparation task cannot start: {error}")) + })?; + threads.push(thread); + } + // Only the threads hold senders now, so a closed channel means + // every thread has ended. + drop(failed); + // Ready only once the delivery instance accepts the publication + // credential and the API answers this credential's policy. + let readiness = { + let shared = Arc::clone(&shared); + let edge = edge.clone(); + let api = api.clone(); + let page = format!("https://{}/", config.public_host); + let context = Context::new( + Arc::clone(&store), + api.clone(), + edge.clone(), + Arc::clone(&config), + &secrets.worker_token, + ); + tokio::spawn(async move { + loop { + let recovered = shared.store.recover_all(now()); + if let Ok(count) = &recovered + && *count > 0 + { + log::info!("preparation: {count} unfinished jobs recovered"); + } + let edge_ok = edge.check_authorization().await; + let api_ok = pipeline::policy_digest(&api, &page).await; + let probe_ok = prepare::probe_ready(&context, &page).await; + match (&edge_ok, &api_ok, &probe_ok, &recovered) { + (Ok(()), Ok(_), Ok(()), Ok(_)) => { + shared.ready.store(true, Ordering::Relaxed); + log::info!("preparation: ready"); + std::future::pending::<()>().await; + return; + } + _ => log::warn!( + "preparation: not ready edge={:?} api={:?} probe={:?} recovery={:?}", + edge_ok.err().map(|failure| failure.code), + api_ok.err().map(|failure| failure.code), + probe_ok.err().map(|failure| failure.code), + recovered.err() + ), + } + tokio::time::sleep(Duration::from_secs(10)).await; + } + }) + }; + let keeper = tokio::spawn(housekeeping(Arc::clone(&shared), edge.clone(), api.clone())); + let (shutdown, shutdown_requested) = tokio::sync::oneshot::channel(); + let serving = axum::serve(listener, router(Arc::clone(&shared))) + .with_graceful_shutdown(async move { + // A dropped sender shuts the server down as well. + let _ = shutdown_requested.await; + }) + .into_future(); + supervise( + serving, + shutdown, + tokio::signal::ctrl_c(), + Supervised { + readiness, + keeper, + failures, + }, + &shared.ready, + &stop, + ) + .await + }); + shared.ready.store(false, Ordering::Relaxed); + stop.store(true, Ordering::Relaxed); + let mut threads_failed = false; + for thread in threads { + match thread.join() { + Ok(true) => {} + Ok(false) => threads_failed = true, + Err(_) => { + log::error!("preparation: a task panicked"); + threads_failed = true; + } + } + } + log::info!("preparation: stopped"); + match supervised { + Ok(()) if threads_failed => Err(WorkerError( + "a supervised preparation task stopped".to_owned(), + )), + result => result, + } +} + +#[cfg(test)] +mod supervision_tests { + use super::*; + + use std::sync::atomic::Ordering::SeqCst; + + use tokio::sync::{mpsc, oneshot}; + + /// Records that the future holding it was dropped. + struct Dropped(Arc); + + impl Drop for Dropped { + fn drop(&mut self) { + self.0.store(true, SeqCst); + } + } + + /// A task that never ends on its own and records its cancellation. + fn forever(cancelled: &Arc) -> tokio::task::JoinHandle<()> { + let guard = Dropped(Arc::clone(cancelled)); + tokio::spawn(async move { + let _guard = guard; + std::future::pending::<()>().await; + }) + } + + /// A server that serves until asked to shut down, then drains. + fn serving( + requested: oneshot::Receiver<()>, + drained: &Arc, + ) -> impl Future> { + let drained = Arc::clone(drained); + async move { + let _ = requested.await; + drained.store(true, SeqCst); + Ok(()) + } + } + + struct Observed { + result: Result<(), WorkerError>, + ready: bool, + stop: bool, + } + + async fn observe( + serving: F, + shutdown: oneshot::Sender<()>, + signal: S, + supervised: Supervised, + ) -> Observed + where + F: Future>, + S: Future>, + { + let ready = AtomicBool::new(true); + let stop = AtomicBool::new(false); + let result = supervise(serving, shutdown, signal, supervised, &ready, &stop).await; + Observed { + result, + ready: ready.load(SeqCst), + stop: stop.load(SeqCst), + } + } + + #[tokio::test] + async fn signal_drains_the_server_and_cancels_every_task_cleanly() { + let (drained, readiness, keeper) = ( + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ); + let (_failed, failures) = mpsc::channel(1); + let (shutdown, requested) = oneshot::channel(); + let supervised = Supervised { + readiness: forever(&readiness), + keeper: forever(&keeper), + failures, + }; + + let observed = observe( + serving(requested, &drained), + shutdown, + async { Ok(()) }, + supervised, + ) + .await; + + assert_eq!(observed.result, Ok(()), "should stop cleanly on a signal"); + assert!( + drained.load(SeqCst), + "should drain the server before returning" + ); + assert!(readiness.load(SeqCst), "should cancel and join readiness"); + assert!(keeper.load(SeqCst), "should cancel and join housekeeping"); + assert!(observed.stop, "should tell preparation threads to stop"); + assert!(!observed.ready, "should withdraw readiness"); + } + + #[tokio::test] + async fn premature_housekeeping_exit_fails_and_stops_everything_else() { + let (drained, readiness) = ( + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ); + let (_failed, failures) = mpsc::channel(1); + let (shutdown, requested) = oneshot::channel(); + // Already finished: joining it a second time would panic. + let supervised = Supervised { + readiness: forever(&readiness), + keeper: tokio::spawn(async {}), + failures, + }; + + let observed = observe( + serving(requested, &drained), + shutdown, + std::future::pending(), + supervised, + ) + .await; + + assert_eq!( + observed.result, + Err(WorkerError("housekeeping stopped".to_owned())), + "should report the housekeeping exit" + ); + assert!(drained.load(SeqCst), "should drain the server"); + assert!(readiness.load(SeqCst), "should cancel and join readiness"); + assert!( + observed.stop && !observed.ready, + "should stop threads and withdraw readiness" + ); + } + + #[tokio::test] + async fn panicking_readiness_fails_and_cancels_housekeeping() { + let (drained, keeper) = ( + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ); + let (_failed, failures) = mpsc::channel(1); + let (shutdown, requested) = oneshot::channel(); + let supervised = Supervised { + readiness: tokio::spawn(async { panic!("should surface as a readiness failure") }), + keeper: forever(&keeper), + failures, + }; + + let observed = observe( + serving(requested, &drained), + shutdown, + std::future::pending(), + supervised, + ) + .await; + + let error = observed + .result + .expect_err("should fail when readiness panics"); + assert!( + error.0.starts_with("readiness failed"), + "should name readiness, got {error}" + ); + assert!(drained.load(SeqCst), "should drain the server"); + assert!(keeper.load(SeqCst), "should cancel and join housekeeping"); + } + + #[tokio::test] + async fn a_thread_failure_report_stops_the_worker() { + let (drained, readiness, keeper) = ( + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ); + let (failed, failures) = mpsc::channel(1); + failed.try_send(()).expect("should report a thread failure"); + let (shutdown, requested) = oneshot::channel(); + let supervised = Supervised { + readiness: forever(&readiness), + keeper: forever(&keeper), + failures, + }; + + let observed = observe( + serving(requested, &drained), + shutdown, + std::future::pending(), + supervised, + ) + .await; + + assert_eq!( + observed.result, + Err(WorkerError("a preparation task stopped".to_owned())), + "should report the thread failure" + ); + assert!(drained.load(SeqCst), "should drain the server"); + assert!( + readiness.load(SeqCst) && keeper.load(SeqCst), + "should cancel and join both tasks" + ); + } + + #[tokio::test] + async fn a_failing_listener_cancels_every_task_without_polling_it_again() { + let (readiness, keeper) = ( + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ); + let (_failed, failures) = mpsc::channel(1); + let (shutdown, _requested) = oneshot::channel(); + let supervised = Supervised { + readiness: forever(&readiness), + keeper: forever(&keeper), + failures, + }; + let mut polls = 0_u32; + let failing = std::future::poll_fn(move |_| { + polls += 1; + assert_eq!(polls, 1, "should never poll a finished listener again"); + std::task::Poll::Ready(Err(std::io::Error::other("accept failed"))) + }); + + let observed = observe(failing, shutdown, std::future::pending(), supervised).await; + + assert_eq!( + observed.result, + Err(WorkerError("the listener failed: accept failed".to_owned())), + "should report the serve error" + ); + assert!( + readiness.load(SeqCst) && keeper.load(SeqCst), + "should cancel and join both tasks" + ); + assert!( + observed.stop && !observed.ready, + "should stop threads and withdraw readiness" + ); + } + + #[tokio::test] + async fn a_failed_signal_handler_stops_the_worker_as_a_failure() { + let (drained, readiness, keeper) = ( + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ); + let (_failed, failures) = mpsc::channel(1); + let (shutdown, requested) = oneshot::channel(); + let supervised = Supervised { + readiness: forever(&readiness), + keeper: forever(&keeper), + failures, + }; + + let observed = observe( + serving(requested, &drained), + shutdown, + async { Err(std::io::Error::other("no handler")) }, + supervised, + ) + .await; + + assert_eq!( + observed.result, + Err(WorkerError( + "the shutdown signal failed: no handler".to_owned() + )), + "should report the signal handler failure" + ); + assert!(drained.load(SeqCst), "should drain the server"); + assert!( + readiness.load(SeqCst) && keeper.load(SeqCst), + "should cancel and join both tasks" + ); + } +} diff --git a/crates/trusted-server-preparation-worker/src/media_tests.rs b/crates/trusted-server-preparation-worker/src/media_tests.rs new file mode 100644 index 0000000000..f96eb6d181 --- /dev/null +++ b/crates/trusted-server-preparation-worker/src/media_tests.rs @@ -0,0 +1,1182 @@ +//! Real signed-probe execution and durable worker preparation. Only HTTP is a fixture. +use std::collections::HashMap; +use std::sync::Mutex; + +use axum::Router; +use axum::body::to_bytes; +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use base64::Engine as _; +use serde_json::{Value, json}; + +use super::*; +use crate::client::Secrets; +use crate::store::{Bounds, JobState}; +use trusted_server_provenance_encypher::preparation::protocol::{ + SNAPSHOT_META_HEADER, SnapshotMeta, SnapshotOrigin, SnapshotRequest, +}; + +struct Case { + planned: PlannedMedia, + source: Vec, + output: Vec, + prepare: Value, + finalize: Value, + recover: Value, +} + +impl Case { + fn read(name: &str) -> Self { + let root = fixture_root().join(name); + let envelope: ResultEnvelope = serde_json::from_slice( + &std::fs::read(root.join("envelope.json")) + .expect("should read exported real API envelope"), + ) + .expect("should parse exported envelope"); + let (planned, source, output) = match envelope.item { + EnvelopeItem::Embedded { + planned, + source_b64, + output_b64, + } => ( + planned, + STANDARD.decode(source_b64).expect("should decode source"), + STANDARD + .decode(output_b64) + .expect("should decode native output"), + ), + EnvelopeItem::Media { + planned, + source_b64, + } => { + let source = STANDARD + .decode(source_b64) + .expect("should decode external source"); + (planned, source.clone(), source) + } + _ => panic!("fixture should be a real prepared media envelope"), + }; + let read = |name: &str| { + serde_json::from_slice( + &std::fs::read(root.join(name)).expect("should read exported API response"), + ) + .expect("should parse API response") + }; + Self { + planned, + source, + output, + prepare: read("prepare.json"), + finalize: read("finalize.json"), + recover: read("recover.json"), + } + } +} + +fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../provenance/encypher/src/fixtures/prepared") +} + +struct Boundary { + cases: Vec, + active: Vec, + extra_html: String, + operations: HashMap, + completed: Vec, + preparations: usize, + finalizations: usize, + finalize_attempts: usize, + retired_receipt: bool, + capabilities: usize, + probe_fetches: usize, + publications: Vec, + closed: bool, + lose_receipt_once: bool, + fail_publication: bool, + standalone: bool, + large: bool, + short_range: bool, + range_reads: usize, + snapshot_reads: usize, + remap_on_snapshot: Option, + origin_identity_sha256: String, + private: bool, + etag: String, + descriptor: Value, + probe: Vec, +} + +struct Harness { + boundary: Arc>, + url: String, + server: tokio::task::JoinHandle<()>, +} + +impl Harness { + async fn start(names: &[&str]) -> Self { + let root = fixture_root(); + let cases: Vec<_> = names.iter().map(|name| Case::read(name)).collect(); + let descriptor = serde_json::from_slice( + &std::fs::read(root.join("descriptor.json")) + .expect("should read production-signed release descriptor"), + ) + .expect("should parse descriptor"); + let probe = std::fs::read(root.join("probe.wasm")) + .expect("should read shared production-authenticated probe"); + let completed = vec![false; cases.len()]; + let boundary = Arc::new(Mutex::new(Boundary { + cases, + active: vec![0], + extra_html: String::new(), + operations: HashMap::new(), + completed, + preparations: 0, + finalizations: 0, + finalize_attempts: 0, + retired_receipt: false, + capabilities: 0, + probe_fetches: 0, + publications: Vec::new(), + closed: false, + lose_receipt_once: false, + fail_publication: false, + standalone: false, + large: false, + short_range: false, + range_reads: 0, + snapshot_reads: 0, + remap_on_snapshot: None, + origin_identity_sha256: crate::hex_lower(&Sha256::digest( + b"https://origin.example.com/old/\0origin.example.com", + )), + private: false, + etag: "\"fixture-v1\"".to_owned(), + descriptor, + probe, + })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind HTTP boundary"); + let url = format!( + "http://{}", + listener.local_addr().expect("should read listener address") + ); + let app = Router::new() + .fallback(exchange) + .with_state(Arc::clone(&boundary)); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("should serve boundary"); + }); + Self { + boundary, + url, + server, + } + } + + fn context(&self, directory: &tempfile::TempDir) -> Context { + let state = self.boundary.lock().expect("should lock boundary"); + let page = url::Url::parse(&state.cases[0].planned.request.page_url) + .expect("should parse fixture page"); + let host = page.host_str().expect("should have fixture host"); + let config: WorkerConfig = toml::from_str(&format!( + r#" +listen = "0.0.0.0:8112" +store_dir = {:?} +deployment_id = "real-probe-worker-test" +api_base_url = {:?} +edge_url = {:?} +domain = {:?} +public_host = {:?} +max_media_bytes = 1048576 +max_snapshot_bytes = 1048576 +max_large_media_bytes = 16777216 +max_preparation_seconds = 120 +[secrets] +worker_token = "TEST_WORKER" +signal_secret = "TEST_SIGNAL" +publication_secret = "TEST_PUBLICATION" +operator_secret = "TEST_OPERATOR" +"#, + directory.path().to_str().expect("should have path"), + self.url, + self.url, + host.strip_prefix("www.").unwrap_or(host), + host + )) + .expect("should parse worker config"); + config + .check() + .expect("should admit actual fixture configuration"); + drop(state); + let store = Arc::new( + Store::open( + &directory.path().join("preparation.redb"), + Bounds::default(), + ) + .expect("should own real durable store"), + ); + store + .recover_storage() + .expect("should recover all durable source/probe/output material"); + store + .recover_all(now()) + .expect("should fence previous process claims"); + let secrets = Secrets { + worker_token: "cwk_real_probe_fixture".to_owned(), + signal: "signal".to_owned(), + publication: "publication".to_owned(), + operator: "operator".to_owned(), + canary: Some("creation-canary".to_owned()), + }; + Context::new( + store, + Api::new(&self.url, &secrets), + Edge::new(&self.url, &secrets, &config.deployment_id), + Arc::new(config), + &secrets.worker_token, + ) + } + + fn target(&self) -> Target { + let state = self.boundary.lock().expect("should lock boundary"); + let page = + url::Url::parse(&state.cases[0].planned.request.page_url).expect("should parse page"); + Target { + path: page.path().to_owned(), + variant: String::new(), + } + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.server.abort(); + } +} + +fn answer(status: StatusCode, value: &Value) -> Response { + (status, axum::Json(value.clone())).into_response() +} +fn denied() -> Response { + answer( + StatusCode::FORBIDDEN, + &json!({"error":{"code":"CREATION_DISABLED"}}), + ) +} + +async fn exchange(State(boundary): State>>, request: Request) -> Response { + let path = request.uri().path().to_owned(); + let headers = request.headers().clone(); + let body = to_bytes(request.into_body(), 8 * 1024 * 1024) + .await + .expect("should read bounded control request"); + let value: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + let mut state = boundary.lock().expect("should lock HTTP boundary"); + if path.starts_with("/api/v1/sign/media/probe/") { + assert!(headers.get("x-encypher-canary-token").is_none()); + assert!( + headers.get("x-encypher-worker-token").is_none(), + "public probe acquisition must not leak the worker credential" + ); + assert_eq!( + path, + format!( + "/api/v1/sign/media/probe/{}.wasm", + state.descriptor["sha256"] + .as_str() + .expect("should have descriptor digest") + ) + ); + state.probe_fetches += 1; + return state.probe.clone().into_response(); + } + if path.starts_with("/api/v1/") { + assert_eq!( + headers + .get("x-encypher-worker-token") + .expect("should authenticate API calls"), + "cwk_real_probe_fixture" + ); + if path.ends_with("/prepare") || path.ends_with("/finalize") { + assert_eq!( + headers + .get("x-encypher-canary-token") + .expect("creation should carry configured canary"), + "creation-canary" + ); + } else { + assert!( + headers.get("x-encypher-canary-token").is_none(), + "read-only authority and recovery must not require a creation canary" + ); + } + let policy = state.cases[0] + .planned + .request + .expected_policy_digest + .clone(); + if path.ends_with("/policy") { + return answer(StatusCode::OK, &json!({"policy_digest":policy})); + } + if path.ends_with("/capabilities") { + state.capabilities += 1; + if state.closed { + return denied(); + } + let formats: Vec<_> = state + .cases + .iter() + .map(|case| case.planned.request.source.mime.clone()) + .collect(); + return answer( + StatusCode::OK, + &json!({"protocol":pipeline::PREPARED_PROTOCOL,"enabled":true,"policy_digest":policy,"formats":formats,"embedded_formats":formats,"contract_versions":[1,2],"maximums":{"total_size_bytes":16777216,"manifest_decoded_bytes":2097152},"probe":[state.descriptor]}), + ); + } + let key = headers + .get("idempotency-key") + .expect("should pin operation key") + .to_str() + .expect("should parse key") + .to_owned(); + if path.ends_with("/prepare") { + if state.closed { + return denied(); + } + let index = state + .cases + .iter() + .position(|case| { + serde_json::to_value(&case.planned.request) + .expect("should serialize frozen request") + == value + }) + .expect("worker must inspect exactly the exported source and frozen contract"); + if let Some(prior) = state.operations.get(&key) { + assert_eq!(*prior, index, "operation identity cannot be rebound"); + } + state.operations.insert(key, index); + state.preparations += 1; + return answer(StatusCode::OK, &state.cases[index].prepare); + } + if path.ends_with("/recover") { + let Some(index) = state.operations.get(&key).copied() else { + return answer( + StatusCode::NOT_FOUND, + &json!({"error":{"code":"NOT_FOUND"}}), + ); + }; + assert_eq!( + value, + serde_json::to_value(&state.cases[index].planned.request) + .expect("should serialize request") + ); + if !state.completed[index] { + return answer( + StatusCode::NOT_FOUND, + &json!({"error":{"code":"NOT_FOUND"}}), + ); + } + if state.retired_receipt { + return answer( + StatusCode::GONE, + &json!({"error":{"code":"RESULT_RETIRED"}}), + ); + } + if state.lose_receipt_once { + state.lose_receipt_once = false; + return answer( + StatusCode::SERVICE_UNAVAILABLE, + &json!({"error":{"code":"LOST_RECEIPT_REPLY"}}), + ); + } + return answer(StatusCode::OK, &state.cases[index].recover); + } + if path.ends_with("/finalize") { + state.finalize_attempts += 1; + if state.closed { + return denied(); + } + let index = *state + .operations + .get(&key) + .expect("finalize must use the durable prepare operation"); + let plan = &state.cases[index].planned; + assert_eq!(value["plan_id"], plan.plan_id); + assert_eq!( + value["binding"]["digest"], plan.digest, + "the real signed probe hash program must reproduce the native fixture digest" + ); + assert!( + !state.completed[index], + "retry must recover, not finalize a completed operation" + ); + state.completed[index] = true; + state.finalizations += 1; + return answer(StatusCode::OK, &state.cases[index].finalize); + } + panic!("worker must not use text signing or media upload in this scenario: {path}"); + } + assert_eq!( + headers + .get("x-encypher-preparation-token") + .expect("should authenticate publisher control"), + "publication" + ); + assert!( + headers.get("cookie").is_none() && headers.get("authorization").is_none(), + "snapshot/publication transport must never carry reader state" + ); + if path == "/_encypher/preparation/snapshot" { + let request: SnapshotRequest = + serde_json::from_value(value).expect("should use frozen snapshot protocol"); + let first = state.active[0]; + let plan = &state.cases[first].planned; + let mut bytes = if state.standalone { + state.cases[first].source.clone() + } else { + let images: String = state + .active + .iter() + .map(|index| { + let case = &state.cases[*index]; + assert_eq!( + case.planned.request.page_url, plan.request.page_url, + "fixture siblings share their exact page binding" + ); + format!( + "", + case.planned.request.source.mime, + STANDARD.encode(&case.source) + ) + }) + .collect(); + format!( + "
{images}{}
", + state.extra_html + ) + .into_bytes() + }; + let total = bytes.len() as u64; + let page_url = plan.request.page_url.clone(); + let origin_host = plan.request.source.origin_host.clone(); + let content_type = if state.standalone { + plan.request.source.mime.clone() + } else { + "text/html".to_owned() + }; + let reference = if state.standalone && plan.request.source.reference_kind == "origin" { + Some(plan.request.source.reference.clone()) + } else { + None + }; + let mut status = 200; + match request.read { + SnapshotMode::Whole if state.large => { + return answer( + StatusCode::PAYLOAD_TOO_LARGE, + &json!({"error":{"code":"snapshot_too_large"}}), + ); + } + SnapshotMode::Whole => {} + SnapshotMode::Head => bytes.clear(), + SnapshotMode::Range { + first, + last, + if_match, + } => { + assert_eq!(if_match, state.etag); + bytes = bytes[first as usize..=last as usize].to_vec(); + if state.short_range { + bytes.pop(); + } + state.range_reads += 1; + status = 206; + } + } + state.snapshot_reads += 1; + if state.remap_on_snapshot == Some(state.snapshot_reads) { + state.origin_identity_sha256 = crate::hex_lower(&Sha256::digest( + b"https://origin.example.com/new/\0origin.example.com", + )); + } + let metadata = SnapshotMeta { + protocol: PREPARATION_PROTOCOL.to_owned(), + page_url, + content_type, + etag: Some(state.etag.clone()), + length: Some(total), + origin: SnapshotOrigin { + status, + private: state.private, + no_transform: false, + manifest_link: false, + content_encoding: String::new(), + origin_host, + origin_identity_sha256: state.origin_identity_sha256.clone(), + source_reference: reference, + }, + }; + return ( + [ + ( + SNAPSHOT_META_HEADER, + STANDARD + .encode(serde_json::to_vec(&metadata).expect("should encode source facts")), + ), + ("cache-control", "private, no-store".to_owned()), + ], + bytes, + ) + .into_response(); + } + if path.starts_with("/_encypher/preparation/results/") { + if state.fail_publication { + return answer( + StatusCode::SERVICE_UNAVAILABLE, + &json!({"error":{"code":"EDGE_UNAVAILABLE"}}), + ); + } + state.publications.push( + serde_json::from_value(value).expect("should publish the actual worker envelope"), + ); + return answer(StatusCode::OK, &json!({"stored":true})); + } + panic!("unexpected external boundary route: {path}"); +} + +fn claimed(context: &Context, target: &Target, token: u64) -> Claim { + context + .store + .admit(target, JobOrigin::Warm, &[], now()) + .expect("should admit authenticated warm"); + context + .store + .claim(now(), token) + .expect("should claim") + .expect("should have durable work") +} + +async fn warm(context: &Context, target: &Target, token: u64) { + let claim = claimed(context, target, token); + run(context, &claim) + .await + .expect("should finish real probe work"); + assert!( + context + .store + .complete(&claim.job.key, claim.token, now()) + .expect("should acknowledge exact generation") + ); +} + +#[tokio::test] +async fn real_probe_changed_only_media_preparation_reuses_siblings_and_durable_outputs() { + let harness = Harness::start(&["png", "jpeg", "webp", "svg"]).await; + let directory = tempfile::tempdir().expect("should create durable worker storage"); + let context = harness.context(&directory); + harness.boundary.lock().expect("should lock").active = vec![0, 1]; + warm(&context, &harness.target(), 1).await; + { + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.finalizations, 2); + for envelope in &state.publications { + if let EnvelopeItem::Embedded { + planned, + output_b64, + .. + } = &envelope.item + { + let expected = state + .cases + .iter() + .find(|case| { + case.planned.request.source.sha256 == planned.request.source.sha256 + }) + .expect("should identify real source"); + assert_eq!( + STANDARD.decode(output_b64).expect("should decode output"), + expected.output, + "assembly must match real native output" + ); + } else { + panic!("embedded carrier is required, never an external fallback"); + } + } + } + // Change one image while the JPEG remains byte-identical on the same page. + harness.boundary.lock().expect("should lock").active = vec![2, 1]; + warm(&context, &harness.target(), 2).await; + { + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, 3, "only the changed source prepares"); + assert_eq!(state.finalizations, 3, "stable sibling is not re-signed"); + assert_eq!( + state.probe_fetches, 1, + "all retained plans share the exact accounted real probe" + ); + let sha = state.descriptor["sha256"] + .as_str() + .expect("should have digest"); + assert_eq!( + context + .store + .probe_references(sha) + .expect("should retain release references") + .len(), + 3 + ); + assert!( + context.store.reserved().expect("should count hard bytes") >= state.probe.len() as u64 + ); + } + harness.boundary.lock().expect("should lock").active = vec![3, 1]; + warm(&context, &harness.target(), 3).await; + assert_eq!( + harness.boundary.lock().expect("should lock").finalizations, + 4 + ); + drop(context); + harness.boundary.lock().expect("should lock").closed = true; + let context = harness.context(&directory); + warm(&context, &harness.target(), 4).await; + let state = harness.boundary.lock().expect("should lock"); + assert_eq!( + state.finalizations, 4, + "restart/eviction restores stored output with creation disabled" + ); + assert_eq!(state.preparations, 4); + assert_eq!(state.publications.len(), 8); +} + +#[tokio::test] +async fn html_extraction_refusals_remain_visible_while_valid_siblings_prepare_and_reuse() { + let harness = Harness::start(&["png"]).await; + let directory = tempfile::tempdir().expect("should create durable storage"); + let context = harness.context(&directory); + harness.boundary.lock().expect("should lock").extra_html = + "".to_owned(); + let target = harness.target(); + warm(&context, &target, 1).await; + harness + .boundary + .lock() + .expect("should lock") + .extra_html + .push_str(""); + warm(&context, &target, 2).await; + { + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, 1); + assert_eq!(state.finalizations, 1, "the valid sibling stays reusable"); + let EnvelopeItem::Embedded { output_b64, .. } = &state.publications[0].item else { + panic!("the valid sibling should retain its embedded carrier"); + }; + assert_eq!( + STANDARD.decode(output_b64).expect("should decode output"), + state.cases[0].output + ); + } + drop(context); + let context = harness.context(&directory); + assert_eq!( + context + .store + .active_outcome_counts(now()) + .expect("should read durable operator outcomes") + .get("malformed_data_uri"), + Some(&1), + "unchanged refused media must not accumulate diagnostics for unrelated HTML changes" + ); +} + +#[tokio::test] +async fn reader_snapshot_instability_damps_only_the_changed_item_across_restart() { + use crate::store::Admission; + use trusted_server_provenance_encypher::preparation::protocol::ObservedItem; + + let harness = Harness::start(&["png", "jpeg", "webp"]).await; + let directory = tempfile::tempdir().expect("should create durable storage"); + let context = harness.context(&directory); + let target = harness.target(); + let observed: Vec<_> = { + let state = harness.boundary.lock().expect("should lock"); + state + .cases + .iter() + .map(|case| ObservedItem { + kind: ItemKind::Embedded, + identity: MediaIdentity { + credential_context: &context.credential_context, + policy_digest: &case.planned.request.expected_policy_digest, + page_url: &case.planned.request.page_url, + variant: &target.variant, + carrier: Carrier::Embedded, + mime: &case.planned.request.source.mime, + source_sha256: &case.planned.request.source.sha256, + } + .digest(), + }) + .collect() + }; + // The reader saw PNG + JPEG; the independently acquired snapshot now has WebP + JPEG. + harness.boundary.lock().expect("should lock").active = vec![2, 1]; + assert!(matches!( + context + .store + .admit(&target, JobOrigin::Signal, &observed[..2], now()) + .expect("should admit"), + Admission::Queued { .. } + )); + let claim = context + .store + .claim(now(), 1) + .expect("should claim") + .expect("should have work"); + run(&context, &claim) + .await + .expect("should settle the observed generation"); + assert!( + context + .store + .complete(&claim.job.key, claim.token, now()) + .expect("should acknowledge") + ); + let refusal = context + .store + .outcome(&target, &observed[0].identity) + .expect("should read outcome") + .expect("should record instability"); + assert_eq!(refusal.code, "source_unstable"); + assert_eq!(refusal.class, OutcomeClass::SourceUnstable); + assert!( + context + .store + .artifact(&observed[0].identity) + .expect("should read") + .is_none() + ); + assert!( + context + .store + .artifact(&observed[2].identity) + .expect("should read") + .is_none(), + "the unobserved replacement must not sign" + ); + { + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, 1); + assert_eq!(state.finalizations, 1); + assert_eq!(state.publications[0].identity, observed[1].identity); + } + drop(context); + let context = harness.context(&directory); + assert_eq!( + context + .store + .admit(&target, JobOrigin::Signal, &observed[..1], now()) + .expect("should admit"), + Admission::Damped, + "the same unstable observation remains damped after restart" + ); + assert!(matches!( + context + .store + .admit(&target, JobOrigin::Signal, &observed[1..], now()) + .expect("should admit new observation"), + Admission::Queued { .. } + )); + let claim = context + .store + .claim(now(), 2) + .expect("should claim") + .expect("should have changed work"); + run(&context, &claim) + .await + .expect("should prepare the newly observed replacement"); + assert!( + context + .store + .complete(&claim.job.key, claim.token, now()) + .expect("should acknowledge") + ); + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, 2); + assert_eq!( + state.finalizations, 2, + "the stable JPEG must not be re-signed" + ); + assert!( + state + .publications + .iter() + .all(|item| item.identity != observed[0].identity) + ); +} + +#[tokio::test] +async fn crash_after_finalize_recovers_pinned_geometry_and_probe_without_capabilities_or_new_sign() +{ + let harness = Harness::start(&["png"]).await; + let directory = tempfile::tempdir().expect("should create durable storage"); + { + let context = harness.context(&directory); + harness + .boundary + .lock() + .expect("should lock") + .lose_receipt_once = true; + let claim = claimed(&context, &harness.target(), 1); + assert_eq!( + run(&context, &claim) + .await + .expect_err("should lose receipt after committed finalize") + .class, + FailureClass::Transient + ); + assert_eq!(context.store.counts().expect("should count").artifacts, 0); + } + let previous_capabilities = { + let mut state = harness.boundary.lock().expect("should lock"); + state.closed = true; + assert_eq!(state.finalizations, 1); + state.capabilities + }; + let context = harness.context(&directory); + let page = harness.boundary.lock().expect("should lock").cases[0] + .planned + .request + .page_url + .clone(); + probe_ready(&context, &page) + .await + .expect("readiness must accept retained pinned releases while creation is closed"); + let claim = context + .store + .claim(now(), 2) + .expect("should recover durable claim") + .expect("should keep unfinished generation"); + assert!(matches!( + claim.job.state, + JobState::Processing { token: 2, .. } + )); + run(&context, &claim) + .await + .expect("should reconstruct exact completed receipt while creation is disabled"); + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, 1); + assert_eq!(state.finalizations, 1); + assert_eq!(state.finalize_attempts, 1); + assert_eq!( + state.capabilities, previous_capabilities, + "recovery bypasses creation capability admission" + ); + assert_eq!( + state.probe_fetches, 1, + "restart retains the exact authenticated release" + ); + assert_eq!(state.publications.len(), 1); +} + +#[tokio::test] +async fn real_external_media_uses_small_and_pinned_large_lanes_without_upload() { + for large in [false, true] { + let harness = Harness::start(&["external-png"]).await; + let directory = tempfile::tempdir().expect("should create durable storage"); + { + let mut state = harness.boundary.lock().expect("should lock"); + state.standalone = true; + state.large = large; + } + let context = harness.context(&directory); + warm(&context, &harness.target(), 1).await; + let published_identity = { + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.finalizations, 1); + assert_eq!(state.publications.len(), 1); + assert_eq!(state.range_reads > 0, large); + assert!(matches!(&state.publications[0].item, EnvelopeItem::Large { .. }) == large); + assert!(matches!(&state.publications[0].item, EnvelopeItem::Media { .. }) != large); + if let EnvelopeItem::Large { + planned, + etag, + origin_identity_sha256, + } = &state.publications[0].item + { + assert_eq!(origin_identity_sha256, &state.origin_identity_sha256); + assert_eq!( + state.publications[0].identity, + LargeLocator { + credential_context: &context.credential_context, + policy_digest: &planned.request.expected_policy_digest, + reader_url: &planned.request.page_url, + variant: "", + origin_host: &planned.request.source.origin_host, + origin_identity_sha256, + mime: &planned.request.source.mime, + etag, + length: planned.request.source.byte_length, + } + .digest() + ); + } + assert!( + !directory + .path() + .join("large") + .read_dir() + .is_ok_and(|mut entries| entries.next().is_some()), + "temporary pinned sources are removed before their reservations are released" + ); + state.publications[0].identity.clone() + }; + drop(context); + harness.boundary.lock().expect("should lock").closed = true; + let context = harness.context(&directory); + warm(&context, &harness.target(), 2).await; + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, 1); + assert_eq!(state.finalizations, 1); + assert_eq!(state.publications.len(), 2); + assert_eq!(state.publications[1].identity, published_identity); + } +} + +#[tokio::test] +async fn configured_origin_remap_refuses_large_acquisition_and_both_current_checks() { + for (remap_on_snapshot, preparations, finalizations) in [(2, 0, 0), (3, 1, 0), (4, 1, 1)] { + let harness = Harness::start(&["external-png"]).await; + let directory = tempfile::tempdir().expect("should create durable storage"); + { + let mut state = harness.boundary.lock().expect("should lock"); + state.standalone = true; + state.large = true; + state.remap_on_snapshot = Some(remap_on_snapshot); + } + let context = harness.context(&directory); + let target = harness.target(); + let claim = claimed(&context, &target, 1); + run(&context, &claim) + .await + .expect("should persist integrity refusal"); + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, preparations); + assert_eq!(state.finalizations, finalizations); + assert_eq!(state.range_reads, 1); + assert!(state.publications.is_empty()); + assert_eq!(context.store.counts().expect("should count").artifacts, 0); + let request = &state.cases[0].planned.request; + let expected_origin_identity = crate::hex_lower(&Sha256::digest( + b"https://origin.example.com/old/\0origin.example.com", + )); + let identity = LargeLocator { + credential_context: &context.credential_context, + policy_digest: &request.expected_policy_digest, + reader_url: &request.page_url, + variant: "", + origin_host: &request.source.origin_host, + origin_identity_sha256: &expected_origin_identity, + mime: &request.source.mime, + etag: &state.etag, + length: request.source.byte_length, + } + .digest(); + assert_eq!( + context + .store + .outcome(&target, &identity) + .expect("should read item outcome") + .expect("should retain configured-origin refusal") + .code, + "source_version_changed" + ); + } +} + +#[tokio::test] +async fn invalid_configured_origin_digest_refuses_large_work_before_acquisition() { + for invalid in ["".to_owned(), "not-a-digest".to_owned(), "A".repeat(64)] { + let harness = Harness::start(&["external-png"]).await; + let directory = tempfile::tempdir().expect("should create durable storage"); + { + let mut state = harness.boundary.lock().expect("should lock"); + state.standalone = true; + state.large = true; + state.origin_identity_sha256 = invalid.to_owned(); + } + let context = harness.context(&directory); + let target = harness.target(); + warm(&context, &target, 1).await; + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.range_reads, 0); + assert_eq!(state.preparations, 0); + assert_eq!(state.finalizations, 0); + assert!(state.publications.is_empty()); + assert_eq!( + context + .store + .outcome(&target, "*") + .expect("should read snapshot outcome") + .expect("should retain invalid-origin refusal") + .code, + "source_origin_identity_invalid" + ); + } +} + +#[tokio::test] +async fn truncated_pinned_source_is_refused_before_prepare_and_cleans_its_claim_owned_file() { + let harness = Harness::start(&["external-png"]).await; + let directory = tempfile::tempdir().expect("should create storage"); + { + let mut state = harness.boundary.lock().expect("should lock"); + state.standalone = true; + state.large = true; + state.short_range = true; + } + let context = harness.context(&directory); + warm(&context, &harness.target(), 1).await; + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, 0); + assert_eq!(state.finalizations, 0); + assert!(state.publications.is_empty()); + assert!( + context + .store + .reserved() + .expect("should retain only bounded refusal metadata") + < 4096 + ); + assert!( + !directory + .path() + .join("large") + .read_dir() + .expect("should have source directory") + .any(|entry| entry.is_ok()) + ); +} + +#[tokio::test] +async fn retired_completed_receipt_refuses_recovery_without_any_finalize_attempt() { + let harness = Harness::start(&["png"]).await; + let directory = tempfile::tempdir().expect("should create durable storage"); + { + let context = harness.context(&directory); + harness + .boundary + .lock() + .expect("should lock") + .lose_receipt_once = true; + let claim = claimed(&context, &harness.target(), 1); + run(&context, &claim) + .await + .expect_err("should simulate crash after API commit"); + } + { + let mut state = harness.boundary.lock().expect("should lock"); + state.closed = true; + state.retired_receipt = true; + } + let context = harness.context(&directory); + let claim = context + .store + .claim(now(), 2) + .expect("should reclaim") + .expect("should retain exact operation"); + run(&context, &claim) + .await + .expect("should record explicit terminal retirement refusal"); + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.finalizations, 1); + assert_eq!( + state.finalize_attempts, 1, + "HTTP410 must never fall through to a finalize call" + ); + assert_eq!(state.preparations, 1); + assert!(state.publications.is_empty()); + assert_eq!(context.store.counts().expect("should count").artifacts, 0); + let request = &state.cases[0].planned.request; + let identity = MediaIdentity { + credential_context: &context.credential_context, + policy_digest: &request.expected_policy_digest, + page_url: &request.page_url, + variant: "", + carrier: Carrier::Embedded, + mime: &request.source.mime, + source_sha256: &request.source.sha256, + } + .digest(); + assert_eq!( + context + .store + .outcome(&claim.job.target, &identity) + .expect("should retain outcome") + .expect("should expose retired refusal") + .code, + "result_retired" + ); +} + +#[tokio::test] +async fn corrupt_durable_output_is_refused_without_replanning_or_signing() { + let harness = Harness::start(&["png"]).await; + let directory = tempfile::tempdir().expect("should create storage"); + let context = harness.context(&directory); + warm(&context, &harness.target(), 1).await; + let identity = harness.boundary.lock().expect("should lock").publications[0] + .identity + .clone(); + let path = directory + .path() + .join("artifacts") + .join(format!("{identity}.json")); + let mut bytes = std::fs::read(&path).expect("should inspect durable output"); + bytes[0] ^= 1; + std::fs::write(path, bytes).expect("should simulate disk corruption"); + harness.boundary.lock().expect("should lock").closed = true; + let claim = claimed(&context, &harness.target(), 2); + assert_eq!( + run(&context, &claim) + .await + .expect_err("should refuse corrupt durable material") + .code, + "store_unavailable" + ); + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, 1); + assert_eq!(state.finalize_attempts, 1); + assert_eq!( + state.publications.len(), + 1, + "corrupt bytes never reach publication" + ); +} + +#[tokio::test] +async fn private_media_and_existing_real_embedded_provenance_are_never_replaced_or_uploaded() { + for existing_provenance in [false, true] { + let harness = Harness::start(&["png"]).await; + let directory = tempfile::tempdir().expect("should create storage"); + { + let mut state = harness.boundary.lock().expect("should lock"); + if existing_provenance { + state.cases[0].source = state.cases[0].output.clone(); + } else { + state.private = true; + } + } + let context = harness.context(&directory); + warm(&context, &harness.target(), 1).await; + let state = harness.boundary.lock().expect("should lock"); + assert_eq!(state.preparations, 0); + assert_eq!(state.finalize_attempts, 0); + assert!(state.publications.is_empty()); + assert_eq!(context.store.counts().expect("should count").artifacts, 0); + } +} diff --git a/crates/trusted-server-preparation-worker/src/prepare.rs b/crates/trusted-server-preparation-worker/src/prepare.rs new file mode 100644 index 0000000000..2c14dba028 --- /dev/null +++ b/crates/trusted-server-preparation-worker/src/prepare.rs @@ -0,0 +1,1138 @@ +//! One claimed job, end to end: a snapshot through the reader pipeline, +//! the same item extraction the reader runs, the per-item stability rule +//! of the job's origin, local prepared hashing, signing outside delivery, +//! and publication the delivery instance verifies through the API. +//! +//! A published item whose delivery record a reader still misses is +//! republished from its durable artifact, never signed again. A verified +//! plan is recorded before its finalize, so a retry after a crash +//! finalizes or recovers the same operation rather than planning a new +//! one. + +use std::io::Write as _; +use std::os::unix::fs::FileExt as _; +use std::path::PathBuf; +use std::sync::Arc; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use sha2::{Digest as _, Sha256}; + +use trusted_server_provenance_encypher::preparation::items::{ + DocumentKind, ItemContext, document_items, +}; +use trusted_server_provenance_encypher::preparation::pipeline::{ + self, Failure, FailureClass, MediaJob, PlannedMedia, SourceReader, +}; +use trusted_server_provenance_encypher::preparation::protocol::{ + Carrier, EnvelopeItem, ItemKind, LargeLocator, MediaIdentity, PREPARATION_PROTOCOL, + PUBLIC_SOURCE_PREFIX, ResultEnvelope, SnapshotMode, Target, credential_context, is_digest, + operation_key, +}; + +use crate::client::{Api, DiskProbeCache, Edge, Snapshot, WorkerConfig}; +use crate::store::{Artifact, Claim, JobOrigin, OutcomeClass, Store, outcome_class}; + +#[cfg(test)] +#[path = "media_tests.rs"] +mod media_tests; + +/// Everything a preparation task uses. +pub struct Context { + /// The durable store. + pub store: Arc, + /// The API. + pub api: Api, + /// The delivery instance. + pub edge: Edge, + /// The configuration. + pub config: Arc, + /// The credential context identities bind. + pub credential_context: String, +} + +impl Context { + /// The context for `config` with `api` and `edge`. + #[must_use] + pub fn new( + store: Arc, + api: Api, + edge: Edge, + config: Arc, + worker_token: &str, + ) -> Self { + let credential_context = + credential_context(&config.api_base_url, &config.domain, worker_token); + Self { + store, + api, + edge, + config, + credential_context, + } + } + + fn probes(&self) -> DiskProbeCache { + DiskProbeCache::new(Arc::clone(&self.store)) + } + + async fn load_probe( + &self, + capabilities: &pipeline::Capabilities, + ) -> Result { + let probe = pipeline::load_probe(&self.api, &self.probes(), capabilities).await?; + let release = probe.release(); + self.store + .refresh_context("probe", &format!("{}:{}", release.sha256, release.release)) + .map_err(|error| store_failure(&error))?; + Ok(probe) + } + + async fn load_release( + &self, + release: &pipeline::ProbeRelease, + ) -> Result { + pipeline::load_release(&self.api, &self.probes(), release).await + } +} + +pub(crate) async fn probe_ready(context: &Context, page: &str) -> Result<(), Failure> { + let _budget = TemporaryBudget::new(context, "temp:readiness".to_owned(), 8 * 1024 * 1024)?; + let releases = context + .store + .probe_releases() + .map_err(|error| store_failure(&error))?; + for release in &releases { + context.load_release(release).await?; + } + if !releases.is_empty() + || context + .store + .has_text_operations() + .map_err(|error| store_failure(&error))? + { + // Completed or interrupted work remains recoverable when creation is closed. + return Ok(()); + } + let capabilities = pipeline::capabilities(&context.api, page).await?; + context.load_probe(&capabilities).await?; + Ok(()) +} + +pub(crate) async fn refresh_probe_context(context: &Context, page: &str) -> Result<(), Failure> { + let _budget = TemporaryBudget::new(context, "temp:probe-observation".to_owned(), 64 * 1024)?; + let capabilities = pipeline::capabilities(&context.api, page).await?; + let (sha256, release) = pipeline::advertised_release_identity(&capabilities)?; + context + .store + .refresh_context("probe", &format!("{sha256}:{release}")) + .map_err(|error| store_failure(&error)) +} + +/// Unix seconds now. +#[must_use] +pub fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) +} + +/// One item of the current snapshot. +enum Work { + Text(pipeline::TextSignRequest), + Embedded { + mime: String, + source: Vec, + sha256: String, + }, + Media { + mime: String, + sha256: String, + }, + Large { + mime: String, + etag: String, + length: u64, + origin_identity_sha256: String, + }, +} + +/// The configured source and validators every acquisition read must retain. +#[derive(Clone, Copy)] +struct LargeVersion<'a> { + mime: &'a str, + etag: &'a str, + length: u64, + origin_identity_sha256: &'a str, +} + +struct Item { + identity: String, + kind: ItemKind, + work: Work, +} + +struct SnapshotItems { + items: Vec, + refusals: Vec<&'static str>, +} + +/// Runs one claimed job. An `Err` is transient and retried; every other +/// outcome is recorded per item. +/// +/// # Errors +/// +/// A transient [`Failure`]. +pub async fn run(context: &Context, claim: &Claim) -> Result<(), Failure> { + tokio::time::timeout( + std::time::Duration::from_secs(context.config.max_preparation_seconds), + run_claim(context, claim), + ) + .await + .map_err(|_| Failure::new(FailureClass::Transient, "preparation_deadline"))? +} + +async fn run_claim(context: &Context, claim: &Claim) -> Result<(), Failure> { + check_claim(context, claim)?; + // Unknown bounded source sizes reserve their configured ceiling before acquisition. + let _snapshot_budget = TemporaryBudget::new( + context, + format!("temp:snapshot:{}", claim.token), + (context.config.max_snapshot_bytes as u64).saturating_mul(4), + )?; + let job = &claim.job; + let target = &job.target; + let snapshot = match context + .edge + .snapshot_bounded( + target, + SnapshotMode::Whole, + context.config.max_snapshot_bytes, + ) + .await + { + Ok(snapshot) => snapshot, + Err(failure) + if failure.code == "snapshot_too_large" && context.config.max_large_media_bytes > 0 => + { + context.edge.snapshot(target, SnapshotMode::Head).await? + } + Err(failure) => return settle_failure(context, claim, &failure), + }; + if let Err(failure) = admit_snapshot(context, target, &snapshot) { + return settle_failure(context, claim, &failure); + } + let page_url = snapshot.meta.page_url.clone(); + let policy = pipeline::policy_digest(&context.api, &page_url).await?; + let SnapshotItems { items, refusals } = match current_items(context, target, &snapshot, &policy) + { + Ok(items) => items, + Err(failure) => return settle_failure(context, claim, &failure), + }; + let identities: Vec = items.iter().map(|item| item.identity.clone()).collect(); + let previous = context + .store + .observe_claim_snapshot(claim, &identities, now()) + .map_err(|error| store_failure(&error))? + .unwrap_or_default(); + let mut transient = + record_extraction_refusals(context, claim, &snapshot, &policy, refusals).err(); + let selected: Vec<&Item> = match job.origin { + JobOrigin::Signal => { + for observed in &job.observed { + if !identities.contains(&observed.identity) { + // The snapshot is not what the reader saw: damped per + // item, never signed as if it were. + if let Err(failure) = store_outcome( + context, + claim, + &observed.identity, + "source_unstable", + OutcomeClass::SourceUnstable, + ) { + transient.get_or_insert(failure); + } + } + } + items + .iter() + .filter(|item| { + job.observed + .iter() + .any(|observed| observed.identity == item.identity) + }) + .collect() + } + JobOrigin::Reconcile => items + .iter() + .filter(|item| previous.contains(&item.identity)) + .collect(), + JobOrigin::Warm => items.iter().collect(), + }; + for item in selected { + if context + .store + .outcome(target, &item.identity) + .map_err(|error| store_failure(&error))? + .is_some_and(|outcome| now() < outcome.until) + { + continue; + } + match prepare_item(context, target, &snapshot, &policy, item, claim).await { + Ok(()) => {} + Err(failure) + if matches!( + failure.class, + FailureClass::Transient | FailureClass::PolicyChanged | FailureClass::Missing + ) => + { + transient.get_or_insert(failure); + } + Err(failure) => { + log::info!( + "preparation: item refused kind={:?} code={}", + item.kind, + failure.code + ); + if let Some(class) = outcome_class(failure.class) + && let Err(failure) = + store_outcome(context, claim, &item.identity, &failure.code, class) + { + transient.get_or_insert(failure); + } + } + } + } + transient.map_or(Ok(()), Err) +} + +fn store_failure(error: &crate::store::StoreError) -> Failure { + match error { + crate::store::StoreError::Quota => Failure::new(FailureClass::Transient, "quota_exhausted"), + _ => Failure::new(FailureClass::Transient, "store_unavailable"), + } +} + +fn store_outcome( + context: &Context, + claim: &Claim, + identity: &str, + code: &str, + class: OutcomeClass, +) -> Result<(), Failure> { + context + .store + .record_claim_outcome(claim, identity, code, class, now()) + .map_err(|error| store_failure(&error)) +} + +/// Retain extraction refusals without damping the whole target or its valid +/// siblings. One outcome per reason and admitted page scope is a diagnostic, +/// not a count of assets or unrelated document revisions. +fn record_extraction_refusals( + context: &Context, + claim: &Claim, + snapshot: &Snapshot, + policy: &str, + mut refusals: Vec<&'static str>, +) -> Result<(), Failure> { + // These nested bytes are already covered by the containing SVG item. + refusals.retain(|reason| *reason != "inside_inline_svg"); + if refusals.is_empty() { + return Ok(()); + } + refusals.sort_unstable(); + refusals.dedup(); + let mut scope = Sha256::new(); + scope.update(b"encypher.document-extraction-refusal.v2\0"); + for part in [ + context.credential_context.as_bytes(), + policy.as_bytes(), + snapshot.meta.page_url.as_bytes(), + claim.job.target.variant.as_bytes(), + ] { + scope.update((part.len() as u64).to_be_bytes()); + scope.update(part); + } + for reason in refusals { + let mut digest = scope.clone(); + digest.update(reason.as_bytes()); + let identity = crate::hex_lower(&digest.finalize()); + store_outcome(context, claim, &identity, reason, OutcomeClass::Terminal)?; + } + Ok(()) +} + +fn settle_failure(context: &Context, claim: &Claim, failure: &Failure) -> Result<(), Failure> { + let Some(class) = outcome_class(failure.class) else { + return Err(failure.clone()); + }; + if claim.job.observed.is_empty() { + store_outcome(context, claim, "*", &failure.code, class)?; + } + for item in &claim.job.observed { + store_outcome(context, claim, &item.identity, &failure.code, class)?; + } + Ok(()) +} + +/// The items of the snapshot, by the reader's own extraction. +fn current_items( + context: &Context, + target: &Target, + snapshot: &Snapshot, + policy: &str, +) -> Result { + let meta = &snapshot.meta; + let media_type = meta + .content_type + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + let item_context = ItemContext { + credential_context: &context.credential_context, + policy_digest: policy, + page_url: &meta.page_url, + variant: &target.variant, + article_selector: &context.config.article_selector, + min_text_length: context.config.min_text_length, + max_media_bytes: context.config.max_media_bytes, + }; + let document_kind = match media_type.as_str() { + "text/html" => Some(DocumentKind::Page), + "text/css" => Some(DocumentKind::Stylesheet), + _ => None, + }; + if let Some(kind) = document_kind { + let document = std::str::from_utf8(&snapshot.bytes) + .map_err(|_| Failure::new(FailureClass::Unsupported, "not_utf8"))?; + let found = document_items(&item_context, document, kind, None); + let mut items = Vec::new(); + if let Some(text) = found.text { + items.push(Item { + identity: text.identity, + kind: ItemKind::Text, + work: Work::Text(text.request), + }); + } + for embedded in found.embedded { + items.push(Item { + identity: embedded.identity, + kind: ItemKind::Embedded, + work: Work::Embedded { + mime: embedded.mime, + source: embedded.source, + sha256: embedded.sha256, + }, + }); + } + let mut refusals = found.refused; + refusals.extend(found.text_refusal); + return Ok(SnapshotItems { items, refusals }); + } + if snapshot.bytes.is_empty() { + if !is_digest(&meta.origin.origin_identity_sha256) { + return Err(Failure::new( + FailureClass::Integrity, + "source_origin_identity_invalid", + )); + } + let (Some(etag), Some(length)) = (meta.etag.clone(), meta.length) else { + return Err(Failure::new(FailureClass::Unsupported, "no_strong_etag")); + }; + if length > context.config.max_large_media_bytes { + return Err(Failure::new(FailureClass::Unsupported, "media_too_large")); + } + let identity = LargeLocator { + credential_context: &context.credential_context, + policy_digest: policy, + reader_url: &meta.page_url, + variant: &target.variant, + origin_host: &meta.origin.origin_host, + origin_identity_sha256: &meta.origin.origin_identity_sha256, + mime: &media_type, + etag: &etag, + length, + } + .digest(); + return Ok(SnapshotItems { + items: vec![Item { + identity, + kind: ItemKind::Large, + work: Work::Large { + mime: media_type, + etag, + length, + origin_identity_sha256: meta.origin.origin_identity_sha256.clone(), + }, + }], + refusals: Vec::new(), + }); + } + let sha256 = crate::hex_lower(&Sha256::digest(&snapshot.bytes)); + let identity = MediaIdentity { + credential_context: &context.credential_context, + policy_digest: policy, + page_url: &meta.page_url, + variant: &target.variant, + carrier: Carrier::External, + mime: &media_type, + source_sha256: &sha256, + } + .digest(); + Ok(SnapshotItems { + items: vec![Item { + identity, + kind: ItemKind::Media, + work: Work::Media { + mime: media_type, + sha256, + }, + }], + refusals: Vec::new(), + }) +} + +fn admit_snapshot(context: &Context, target: &Target, snapshot: &Snapshot) -> Result<(), Failure> { + let meta = &snapshot.meta; + if meta.protocol != PREPARATION_PROTOCOL + || meta.page_url != format!("https://{}{}", context.config.public_host, target.path) + { + return Err(Failure::new( + FailureClass::Integrity, + "snapshot_identity_mismatch", + )); + } + if meta.origin.private { + return Err(Failure::new(FailureClass::Privacy, "source_private")); + } + if meta.origin.no_transform || meta.origin.manifest_link { + return Err(Failure::new( + FailureClass::ExistingProvenance, + "source_protected", + )); + } + if !matches!(meta.origin.status, 200 | 206) + || (!meta.origin.content_encoding.is_empty() + && !meta + .origin + .content_encoding + .eq_ignore_ascii_case("identity")) + { + return Err(Failure::new( + FailureClass::Unsupported, + "source_not_admitted", + )); + } + Ok(()) +} + +struct TemporaryBudget { + store: Arc, + owner: String, +} + +impl TemporaryBudget { + fn new(context: &Context, owner: String, bytes: u64) -> Result { + reserve(context, &owner, bytes)?; + Ok(Self { + store: Arc::clone(&context.store), + owner, + }) + } +} + +impl Drop for TemporaryBudget { + fn drop(&mut self) { + if self.store.release(&self.owner).is_err() { + log::warn!("preparation: temporary reservation retained for restart cleanup"); + } + } +} + +struct ArtifactBudget<'a> { + context: &'a Context, + claim: &'a Claim, + identity: &'a str, +} + +impl Drop for ArtifactBudget<'_> { + fn drop(&mut self) { + // A newer claim may already be acquiring this identity. A stale + // attempt never releases shared source/output ownership underneath it. + if check_claim(self.context, self.claim).is_err() { + return; + } + match self.context.store.has_reconstruction(self.identity) { + Ok(false) => { + if self + .context + .store + .release(&format!("artifact:{}", self.identity)) + .is_err() + || self + .context + .store + .discard_unplanned_sources(self.identity) + .is_err() + { + log::warn!( + "preparation: unplanned material remains charged for restart cleanup" + ); + } + } + Ok(true) => {} + Err(_) => log::warn!("preparation: uncertain output reservation remains charged"), + } + } +} + +fn check_claim(context: &Context, claim: &Claim) -> Result<(), Failure> { + let current = context + .store + .job(&claim.job.key) + .map_err(|error| store_failure(&error))?; + if current.is_some_and(|job| matches!(job.state, crate::store::JobState::Processing { token, lease_until } if token == claim.token && lease_until > now())) { + Ok(()) + } else { + Err(Failure::new(FailureClass::Transient, "lease_lost")) + } +} + +/// Prepares and publishes one item, or republishes its artifact. +async fn prepare_item( + context: &Context, + target: &Target, + snapshot: &Snapshot, + policy: &str, + item: &Item, + claim: &Claim, +) -> Result<(), Failure> { + check_claim(context, claim)?; + if context + .store + .retirement(&item.identity) + .map_err(|error| store_failure(&error))? + .is_some() + { + return Ok(()); + } + if let Some(artifact) = context + .store + .artifact(&item.identity) + .map_err(|error| store_failure(&error))? + { + // Republished from the durable artifact: the delivery instance + // recovers the same receipt, and nothing is signed again. + context.edge.publish(&artifact.envelope).await?; + context + .store + .acknowledge_artifact(&item.identity) + .map_err(|error| store_failure(&error))?; + return Ok(()); + } + let epoch = context + .store + .retirement_epoch() + .map_err(|error| store_failure(&error))?; + let meta = &snapshot.meta; + let page_url = meta.page_url.as_str(); + let source_size = match &item.work { + Work::Text(request) => request.text.len() as u64, + Work::Embedded { source, .. } => source.len() as u64, + Work::Media { .. } => snapshot.bytes.len() as u64, + Work::Large { .. } => 0, + }; + // Source copies, base64 encodings, probe transfer, manifest, carrier and + // output assembly are all bounded and charged before any API creation. + let _assembly_budget = TemporaryBudget::new( + context, + format!("temp:assembly:{}", claim.token), + source_size + .saturating_mul(8) + .saturating_add(16 * 1024 * 1024), + )?; + reserve( + context, + &format!("artifact:{}", item.identity), + source_size + .saturating_mul(4) + .saturating_add(8 * 1024 * 1024), + )?; + let _artifact_budget = ArtifactBudget { + context, + claim, + identity: &item.identity, + }; + let envelope_item = match &item.work { + Work::Text(request) => { + let key = operation_key(&item.identity, 0); + match pipeline::recover_text(&context.api, request, &key).await { + Ok(_) => { + context + .store + .begin_text(&item.identity, request) + .map_err(|error| store_failure(&error))?; + } + Err(failure) if failure.class == FailureClass::Missing => { + if !context + .store + .begin_text(&item.identity, request) + .map_err(|error| store_failure(&error))? + { + return Err(Failure::new( + FailureClass::Integrity, + "text_result_unrecoverable", + )); + } + check_claim(context, claim)?; + pipeline::sign_text(&context.api, request, &key).await?; + } + Err(failure) => return Err(failure), + } + EnvelopeItem::Text { + request: request.clone(), + idempotency_key: key, + } + } + Work::Embedded { + mime, + source, + sha256, + } => { + keep_public_source(context, sha256, mime, source, &item.identity)?; + let reference = format!( + "https://{}{PUBLIC_SOURCE_PREFIX}{sha256}", + context.config.public_host + ); + let job = MediaJob { + page_url, + policy_digest: policy, + carrier: Carrier::Embedded, + mime, + source_sha256: sha256, + reference: &reference, + origin_host: &meta.origin.origin_host, + reference_kind: "publisher_snapshot", + idempotency_key: &operation_key(&item.identity, 0), + }; + let planned = planned(context, &item.identity, &job, source.as_slice()).await?; + let output = recover_or_finalize( + context, + claim, + &item.identity, + &planned, + Some(source.as_slice()), + ) + .await? + .ok_or_else(|| Failure::new(FailureClass::Integrity, "no_output"))?; + EnvelopeItem::Embedded { + planned, + source_b64: STANDARD.encode(source), + output_b64: STANDARD.encode(output), + } + } + Work::Media { mime, sha256 } => { + let source = snapshot.bytes.as_ref(); + let origin = &meta.origin; + let origin_reference = origin.source_reference.as_deref().filter(|reference| { + url::Url::parse(reference).is_ok_and(|url| { + url.scheme() == "https" && url.host_str() == Some(origin.origin_host.as_str()) + }) + }); + let snapshot_reference = format!( + "https://{}{PUBLIC_SOURCE_PREFIX}{sha256}", + context.config.public_host + ); + let (reference, kind, origin_host) = match origin_reference { + Some(reference) => (reference.to_owned(), "origin", origin.origin_host.clone()), + None => { + keep_public_source(context, sha256, mime, source, &item.identity)?; + ( + snapshot_reference, + "publisher_snapshot", + origin.origin_host.clone(), + ) + } + }; + let job = MediaJob { + page_url, + policy_digest: policy, + carrier: Carrier::External, + mime, + source_sha256: sha256, + reference: &reference, + origin_host: &origin_host, + reference_kind: kind, + idempotency_key: &operation_key(&item.identity, 0), + }; + let planned = planned(context, &item.identity, &job, source).await?; + recover_or_finalize(context, claim, &item.identity, &planned, None).await?; + EnvelopeItem::Media { + planned, + source_b64: STANDARD.encode(source), + } + } + Work::Large { + mime, + etag, + length, + origin_identity_sha256, + } => { + let version = LargeVersion { + mime, + etag, + length: *length, + origin_identity_sha256, + }; + let file = + download_large(context, target, &item.identity, claim.token, version).await?; + let reference = meta.origin.source_reference.clone().ok_or_else(|| { + Failure::new(FailureClass::Unsupported, "source_reference_required") + })?; + let job = MediaJob { + page_url, + policy_digest: policy, + carrier: Carrier::External, + mime, + source_sha256: &file.sha256, + reference: &reference, + origin_host: &meta.origin.origin_host, + reference_kind: "origin", + idempotency_key: &operation_key(&item.identity, 0), + }; + let result = async { + let planned = planned(context, &item.identity, &job, &file).await?; + confirm_large(context, target, version).await?; + recover_or_finalize(context, claim, &item.identity, &planned, None).await?; + confirm_large(context, target, version).await?; + Ok::<_, Failure>(planned) + } + .await; + EnvelopeItem::Large { + planned: result?, + etag: etag.clone(), + origin_identity_sha256: origin_identity_sha256.clone(), + } + } + }; + let envelope = ResultEnvelope { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: context.config.deployment_id.clone(), + identity: item.identity.clone(), + retirement_epoch: epoch, + variant: target.variant.clone(), + item: envelope_item, + }; + let artifact = Artifact { + envelope, + target: target.clone(), + acknowledged: false, + }; + // The result is durable before it is acknowledged, and never outlives a + // retirement that happened while it was prepared. + if !context + .store + .record_artifact(&artifact) + .map_err(|error| store_failure(&error))? + { + context + .store + .release(&format!("artifact:{}", item.identity)) + .map_err(|error| store_failure(&error))?; + return Ok(()); + } + check_claim(context, claim)?; + if context + .store + .retirement(&item.identity) + .map_err(|error| store_failure(&error))? + .is_some() + { + return Ok(()); + } + context.edge.publish(&artifact.envelope).await?; + context + .store + .acknowledge_artifact(&item.identity) + .map_err(|error| store_failure(&error)) +} + +async fn recover_or_finalize( + context: &Context, + claim: &Claim, + identity: &str, + planned: &PlannedMedia, + source: Option<&[u8]>, +) -> Result>, Failure> { + check_claim(context, claim)?; + if context + .store + .retirement(identity) + .map_err(|error| store_failure(&error))? + .is_some() + { + return Err(Failure::new(FailureClass::Integrity, "retired")); + } + let probe = context.load_release(&planned.release).await?; + match pipeline::recover_media(&context.api, &planned.request, &planned.idempotency_key).await { + Ok(recovered) => pipeline::reconstruct_media(&probe, planned, &recovered, source).await, + Err(failure) if failure.class == FailureClass::Missing => { + check_claim(context, claim)?; + if context + .store + .retirement(identity) + .map_err(|error| store_failure(&error))? + .is_some() + { + return Err(Failure::new(FailureClass::Integrity, "retired")); + } + pipeline::finalize_media(&context.api, &probe, planned, source).await + } + Err(failure) => Err(failure), + } +} + +/// The verified plan of an identity: the one recorded before an earlier +/// finalize, or a new one, recorded before it is finalized. +async fn planned( + context: &Context, + identity: &str, + job: &MediaJob<'_>, + source: &S, +) -> Result { + if let Some(planned) = context + .store + .plan(identity) + .map_err(|error| store_failure(&error))? + { + if planned.request.source.sha256 != job.source_sha256 + || planned.request.source.byte_length != source.size() + || planned.request.source.mime != job.mime + || planned.request.expected_policy_digest != job.policy_digest + { + return Err(Failure::new( + FailureClass::Integrity, + "stored_plan_source_mismatch", + )); + } + return Ok(planned); + } + let capabilities = pipeline::capabilities(&context.api, job.page_url).await?; + let probe = context.load_probe(&capabilities).await?; + let planned = pipeline::plan_media(&context.api, &probe, &capabilities, job, source).await?; + context + .store + .record_plan(identity, &planned) + .map_err(|error| store_failure(&error))?; + Ok(planned) +} + +/// Keeps an admitted public source snapshot the publisher route serves by +/// digest, within the byte quota. +fn keep_public_source( + context: &Context, + sha256: &str, + mime: &str, + bytes: &[u8], + identity: &str, +) -> Result<(), Failure> { + context + .store + .keep_source(sha256, mime, bytes, identity) + .map_err(|error| store_failure(&error)) +} + +/// Reserves `bytes` for `owner` against the one hard quota every task +/// shares, in a store transaction, before anything is written. A full +/// quota refuses the work and retries later; it never evicts a retained +/// result. +fn reserve(context: &Context, owner: &str, bytes: u64) -> Result<(), Failure> { + if context + .store + .reserve(owner, bytes) + .map_err(|error| store_failure(&error))? + { + Ok(()) + } else { + Err(Failure::new(FailureClass::Transient, "quota_exhausted")) + } +} + +/// A large source streamed to a private file through pinned range reads. +struct LargeFile { + file: std::fs::File, + size: u64, + sha256: String, + _cleanup: TemporarySource, +} + +struct TemporarySource { + store: Arc, + owner: String, + path: PathBuf, +} + +impl Drop for TemporarySource { + fn drop(&mut self) { + let removed = match std::fs::remove_file(&self.path) { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(_) => false, + }; + if removed { + let synced = self.path.parent().is_some_and(|parent| { + std::fs::File::open(parent) + .and_then(|file| file.sync_all()) + .is_ok() + }); + if synced && self.store.release(&self.owner).is_ok() { + return; + } + } + log::warn!("preparation: temporary source remains charged for restart cleanup"); + } +} + +impl SourceReader for LargeFile { + fn size(&self) -> u64 { + self.size + } + + async fn read_exact_at(&self, offset: u64, destination: &mut [u8]) -> Result<(), Failure> { + self.file + .read_exact_at(destination, offset) + .map_err(|_| Failure::new(FailureClass::Transient, "source_read")) + } +} + +async fn confirm_large( + context: &Context, + target: &Target, + version: LargeVersion<'_>, +) -> Result<(), Failure> { + let LargeVersion { + mime, + etag, + length, + origin_identity_sha256, + } = version; + let current = context.edge.snapshot(target, SnapshotMode::Head).await?; + admit_snapshot(context, target, ¤t)?; + if current.meta.etag.as_deref() != Some(etag) + || current.meta.length != Some(length) + || current.meta.origin.origin_identity_sha256 != origin_identity_sha256 + || current + .meta + .content_type + .split(';') + .next() + .unwrap_or_default() + .trim() + != mime + { + return Err(Failure::new( + FailureClass::Integrity, + "source_version_changed", + )); + } + Ok(()) +} + +/// Streams every byte of the version `etag` names, in pinned ranges read +/// through the reader pipeline, into a private file, hashing as it goes. +async fn download_large( + context: &Context, + target: &Target, + identity: &str, + token: u64, + version: LargeVersion<'_>, +) -> Result { + let LargeVersion { + mime, + etag, + length, + origin_identity_sha256, + } = version; + const CHUNK: u64 = 8 * 1024 * 1024; + if length == 0 + || length > context.config.max_large_media_bytes + || !is_digest(origin_identity_sha256) + || !etag.starts_with('"') + || !etag.ends_with('"') + || etag.len() < 2 + || etag.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(Failure::new( + FailureClass::Unsupported, + "no_strong_source_version", + )); + } + let name = format!("{identity}-{token}"); + let owner = format!("temp:large:{name}"); + reserve(context, &owner, length)?; + let dir = context.config.store_dir.join("large"); + std::fs::create_dir_all(&dir).map_err(|_| Failure::new(FailureClass::Transient, "storage"))?; + let path = dir.join(name); + let cleanup = TemporarySource { + store: Arc::clone(&context.store), + owner, + path: path.clone(), + }; + let mut file = std::fs::OpenOptions::new() + .write(true) + .read(true) + .create_new(true) + .open(&path) + .map_err(|_| Failure::new(FailureClass::Transient, "storage"))?; + let mut hasher = Sha256::new(); + let mut first = 0_u64; + while first < length { + let last = (first + CHUNK).min(length) - 1; + let part = context + .edge + .snapshot( + target, + SnapshotMode::Range { + first, + last, + if_match: etag.to_owned(), + }, + ) + .await; + let part = part?; + admit_snapshot(context, target, &part)?; + if part.meta.etag.as_deref() != Some(etag) + || part.meta.length != Some(length) + || part.meta.origin.origin_identity_sha256 != origin_identity_sha256 + || part.bytes.len() as u64 != last - first + 1 + || part + .meta + .content_type + .split(';') + .next() + .unwrap_or_default() + .trim() + != mime + { + return Err(Failure::new( + FailureClass::Integrity, + "source_version_changed", + )); + } + hasher.update(&part.bytes); + file.write_all(&part.bytes) + .map_err(|_| Failure::new(FailureClass::Transient, "storage"))?; + first = last + 1; + } + file.sync_all() + .map_err(|_| Failure::new(FailureClass::Transient, "storage"))?; + Ok(LargeFile { + _cleanup: cleanup, + file, + size: length, + sha256: crate::hex_lower(&hasher.finalize()), + }) +} diff --git a/crates/trusted-server-preparation-worker/src/store.rs b/crates/trusted-server-preparation-worker/src/store.rs new file mode 100644 index 0000000000..6af42c92e9 --- /dev/null +++ b/crates/trusted-server-preparation-worker/src/store.rs @@ -0,0 +1,2463 @@ +//! The worker's durable state: one exclusively owned redb database whose +//! transactions serialize admission, fixed-capacity slots, claim fencing, +//! attempts, damping, inventory, retirement and the marker outbox. +//! +//! Every write is one redb write transaction, which commits atomically or +//! not at all, and redb admits one writer at a time, so no lock or +//! compare-and-swap is fabricated over anything else. A second process +//! cannot open the same file: redb holds an exclusive lock on it, and the +//! worker refuses to start without it. Management and the CLI go through +//! the owner's HTTP routes, never through a second open. + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use redb::{ + Database, ReadableDatabase as _, ReadableTable as _, ReadableTableMetadata as _, + TableDefinition, +}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use trusted_server_provenance_encypher::preparation::pipeline::{FailureClass, PlannedMedia}; +use trusted_server_provenance_encypher::preparation::protocol::{ + MarkerEnvelope, ObservedItem, PREPARATION_PROTOCOL, ResultEnvelope, Target, is_digest, +}; + +#[path = "store_files.rs"] +mod files; +#[path = "store_maintenance.rs"] +mod maintenance; + +const JOBS: TableDefinition<&str, &[u8]> = TableDefinition::new("jobs"); +const OUTCOMES: TableDefinition<&str, &[u8]> = TableDefinition::new("outcomes"); +const ARTIFACTS: TableDefinition<&str, &[u8]> = TableDefinition::new("artifacts"); +const PLANS: TableDefinition<&str, &[u8]> = TableDefinition::new("plans"); +const INVENTORY: TableDefinition<&str, &[u8]> = TableDefinition::new("inventory"); +const RETIRED: TableDefinition<&str, &[u8]> = TableDefinition::new("retired"); +/// Hard byte reservations by owner: `source:`, `probe:`, +/// `artifact:` for retained material, `temp:` for a +/// large source being read. Reserved before anything is written. +const QUOTA: TableDefinition<&str, u64> = TableDefinition::new("quota"); +const OUTBOX: TableDefinition<&str, &[u8]> = TableDefinition::new("outbox"); +const SOURCES: TableDefinition<&str, &[u8]> = TableDefinition::new("sources"); +const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta"); +const TEXT_OPERATIONS: TableDefinition<&str, &[u8]> = TableDefinition::new("text_operations"); + +fn reserve_in( + tx: &redb::WriteTransaction, + owner: &str, + bytes: u64, + limit: u64, +) -> Result { + let mut quota = tx.open_table(QUOTA).map_err(storage)?; + let mut used = 0_u64; + let mut held = 0_u64; + for entry in quota.iter().map_err(storage)? { + let (key, value) = entry.map_err(storage)?; + used = used + .checked_add(value.value()) + .ok_or_else(|| storage("quota overflow"))?; + if key.value() == owner { + held = value.value(); + } + } + let wanted = held.max(bytes); + if used + .saturating_sub(held) + .checked_add(wanted) + .is_none_or(|total| total > limit) + { + return Ok(false); + } + quota.insert(owner, wanted).map_err(storage)?; + Ok(true) +} +const PROBE_REFERENCES: TableDefinition<&str, &[u8]> = TableDefinition::new("probe_references"); + +fn put_outcome( + tx: &redb::WriteTransaction, + target: &Target, + identity: &str, + outcome: &Outcome, + limit: u64, +) -> Result<(), StoreError> { + let key = outcome_key(target, identity); + let encoded = encode(outcome)?; + if !reserve_in( + tx, + &format!("outcome:{key}"), + (key.len() + encoded.len()) as u64, + limit, + )? { + return Err(StoreError::Quota); + } + tx.open_table(OUTCOMES) + .map_err(storage)? + .insert(key.as_str(), encoded.as_slice()) + .map_err(storage)?; + Ok(()) +} + +fn damp_exhausted( + tx: &redb::WriteTransaction, + job: &Job, + code: &str, + now: u64, + limit: u64, +) -> Result<(), StoreError> { + let outcome = Outcome { + code: code.to_owned(), + class: OutcomeClass::Exhausted, + until: now.saturating_add(3600), + }; + for item in &job.observed { + put_outcome(tx, &job.target, &item.identity, &outcome, limit)?; + } + put_outcome(tx, &job.target, "*", &outcome, limit) +} + +/// Why a store operation failed. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +pub enum StoreError { + /// Another process owns the database. + #[display("the preparation store is owned by another process")] + AlreadyOwned, + /// The database failed. + #[display("the preparation store failed: {_0}")] + Storage(String), + /// A hard byte reservation could not be obtained. + #[display("the preparation artifact quota is exhausted")] + Quota, + /// Independent inventory capacity has been reached. + #[display("the preparation inventory is full")] + InventoryFull, +} + +impl core::error::Error for StoreError {} + +fn storage(error: E) -> StoreError { + StoreError::Storage(error.to_string()) +} + +/// The fixed bounds the store enforces in its transactions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Bounds { + /// Most jobs, of any origin. + pub max_jobs: usize, + /// Most queued job metadata bytes. + pub max_queue_bytes: usize, + /// Slots reader hints can never take. + pub reserved_jobs: usize, + /// Bytes reader hints can never take. + pub reserved_bytes: usize, + /// Most attempts of one job. + pub max_attempts: u32, + /// Longest one job is retried, in seconds. + pub retry_window_seconds: u64, + /// Longest backoff, in seconds. + pub max_backoff_seconds: u64, + /// Claim lease, in seconds. + pub lease_seconds: u64, + /// Most inventory targets. + pub max_inventory: usize, + /// Most items one job holds. + pub max_items_per_job: usize, + /// The hard byte quota of sources, temporary files, probes and retained + /// outputs together. + pub quota_bytes: u64, +} + +impl Default for Bounds { + fn default() -> Self { + Self { + max_jobs: 1024, + max_queue_bytes: 8 * 1024 * 1024, + reserved_jobs: 256, + reserved_bytes: 2 * 1024 * 1024, + max_attempts: 6, + retry_window_seconds: 24 * 3600, + max_backoff_seconds: 300, + lease_seconds: 120, + max_inventory: 1024, + max_items_per_job: 64, + quota_bytes: 10 * 1024 * 1024 * 1024, + } + } +} + +/// What started a job, which decides its capacity and stability rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobOrigin { + /// A reader's hint: only observed items, only while they match. + Signal, + /// An authenticated publication event or operator warm: the current + /// snapshot is intentionally selected. + Warm, + /// Periodic reconciliation: only items stable across two snapshots. + Reconcile, +} + +/// A job's state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum JobState { + /// Waiting until `ready_at`. + Pending { + /// Unix seconds it may be claimed from. + ready_at: u64, + }, + /// Claimed under `token` until `lease_until`. + Processing { + /// The claim fence. + token: u64, + /// Unix seconds the lease ends. + lease_until: u64, + }, +} + +/// One durable job. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Job { + /// Its key, the target's. + pub key: String, + /// The target. + pub target: Target, + /// What started it. + pub origin: JobOrigin, + /// Items a reader observed, for a signal job. + pub observed: Vec, + /// Bumped whenever a newer observation replaces the desired state. + pub generation: u64, + /// A newer observation arrived while it was processing. + pub follow_up: bool, + /// Attempts made. + pub attempts: u32, + /// Unix seconds it was first admitted. + pub admitted_at: u64, + /// Its state. + pub state: JobState, + /// Whether it holds a reserved slot. + pub reserved: bool, +} + +/// A claimed job, fenced by its token. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Claim { + /// The job as claimed. + pub job: Job, + /// The fence. + pub token: u64, +} + +/// The damped outcome of one item. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Outcome { + /// Its code. + pub code: String, + /// Its class. + pub class: OutcomeClass, + /// Unix seconds it stops suppressing hints, `u64::MAX` until the + /// identity changes or an operator retries. + pub until: u64, +} + +/// How an item's outcome damps further work on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OutcomeClass { + /// Its snapshot did not match the observation: five minutes. + SourceUnstable, + /// Retries exhausted: one hour. + Exhausted, + /// Integrity, privacy, unsupported carrier or existing provenance: + /// until the identity changes or an operator retries. + Terminal, +} + +/// What admission decided. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum Admission { + /// Committed as a new job. + Queued { + /// The job key. + key: String, + }, + /// Merged into an existing job for the same target. + Coalesced { + /// The job key. + key: String, + }, + /// Every item is damped, retired or already published. + Damped, + /// No capacity; a later submission is safe. + QueueFull, +} + +/// A published identity's durable artifact: the envelope the delivery +/// instance verified, kept to republish after cache eviction without a +/// new sign operation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Artifact { + /// The envelope. + pub envelope: ResultEnvelope, + /// The target it belongs to. + pub target: Target, + /// Whether the delivery instance acknowledged it. + pub acknowledged: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ArtifactFile { + sha256: String, + bytes: u64, + acknowledged: bool, +} + +/// A retired identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Retirement { + /// Its epoch. + pub epoch: u64, + /// Whether the delivery instance acknowledged its marker. This is not + /// proof every replica or downstream cache changed. + pub acknowledged: bool, +} + +/// One inventory target and the identities of its last snapshot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InventoryEntry { + /// The target. + pub target: Target, + /// Unix seconds it was last reconciled. + pub last_scan: u64, + /// The item identities its last snapshot held. + pub last_identities: Vec, +} + +/// A source snapshot held on the worker. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceRecord { + /// Its media type. + pub mime: String, + /// Whether the page and asset passed public admission, so the publisher + /// route may serve it. + pub public: bool, + /// Identities whose retained artifacts reference it. + pub references: Vec, +} + +/// The store's counts, for status. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Counts { + /// Pending jobs. + pub pending: usize, + /// Processing jobs. + pub processing: usize, + /// Retained artifacts, independently of publication acknowledgement. + pub artifacts: usize, + /// Retained artifacts awaiting an acknowledged result PUT. + pub publication_pending: usize, + /// Result PUTs acknowledged by the delivery instance. + pub publication_acknowledged: usize, + /// Retirements awaiting marker acknowledgement. + pub retirement_pending: usize, + /// Retirements acknowledged by the delivery instance. + pub retirement_acknowledged: usize, + /// Inventory targets. + pub inventory: usize, + /// Hard artifact bytes reserved, including temporary and probe material. + pub reserved_bytes: u64, +} + +/// The durable store. +pub struct Store { + db: Database, + bounds: Bounds, + root: PathBuf, + /// Serializes filesystem ownership changes with retirement and cleanup. + objects: Mutex<()>, +} + +impl std::fmt::Debug for Store { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Store") + .field("bounds", &self.bounds) + .finish_non_exhaustive() + } +} + +fn encode(value: &T) -> Result, StoreError> { + serde_json::to_vec(value).map_err(storage) +} + +fn decode(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(storage) +} + +/// A target's job key. +#[must_use] +pub fn target_key(target: &Target) -> String { + format!("{}?{}", target.path, target.variant) +} + +fn outcome_key(target: &Target, identity: &str) -> String { + format!("{}#{identity}", target_key(target)) +} + +impl Store { + /// Opens or creates the store at `path` as its only owner. + /// + /// # Errors + /// + /// [`StoreError::AlreadyOwned`] where another process holds it, and + /// [`StoreError::InventoryFull`] where the persisted inventory exceeds + /// `bounds.max_inventory`; nothing is removed, and raising the bound + /// reopens it unchanged. + pub fn open(path: &Path, bounds: Bounds) -> Result { + if bounds.max_jobs == 0 + || bounds.reserved_jobs > bounds.max_jobs + || bounds.reserved_bytes > bounds.max_queue_bytes + || bounds.max_items_per_job == 0 + || bounds.max_attempts == 0 + || bounds.lease_seconds < 3 + { + return Err(storage("invalid worker store bounds")); + } + let db = Database::create(path).map_err(|error| match error { + redb::DatabaseError::DatabaseAlreadyOpen => StoreError::AlreadyOwned, + other => storage(other), + })?; + let root = path.parent().unwrap_or_else(|| Path::new(".")).to_owned(); + let store = Self { + db, + bounds, + root, + objects: Mutex::new(()), + }; + let tx = store.db.begin_write().map_err(storage)?; + for table in [ + JOBS, + OUTCOMES, + ARTIFACTS, + PLANS, + INVENTORY, + RETIRED, + OUTBOX, + SOURCES, + META, + TEXT_OPERATIONS, + PROBE_REFERENCES, + ] { + tx.open_table(table).map_err(storage)?; + } + tx.open_table(QUOTA).map_err(storage)?; + let inventory = tx + .open_table(INVENTORY) + .map_err(storage)? + .len() + .map_err(storage)? as usize; + if inventory > bounds.max_inventory { + tx.abort().map_err(storage)?; + return Err(StoreError::InventoryFull); + } + tx.commit().map_err(storage)?; + Ok(store) + } + + /// The bounds. + #[must_use] + pub fn bounds(&self) -> Bounds { + self.bounds + } + + fn read_value( + &self, + table: TableDefinition<&str, &[u8]>, + key: &str, + ) -> Result, StoreError> { + let tx = self.db.begin_read().map_err(storage)?; + let table = tx.open_table(table).map_err(storage)?; + table + .get(key) + .map_err(storage)? + .map(|value| decode(value.value())) + .transpose() + } + + fn read_all( + &self, + table: TableDefinition<&str, &[u8]>, + ) -> Result, StoreError> { + let tx = self.db.begin_read().map_err(storage)?; + let table = tx.open_table(table).map_err(storage)?; + let mut out = Vec::new(); + for entry in table.iter().map_err(storage)? { + let (key, value) = entry.map_err(storage)?; + out.push((key.value().to_owned(), decode(value.value())?)); + } + Ok(out) + } + + /// Admits a job for `target` in one transaction: drops retired, damped + /// and already published items, coalesces with the target's existing + /// job, and enforces the total and reserved bounds. A reader hint never + /// takes a reserved slot or byte. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn admit( + &self, + target: &Target, + origin: JobOrigin, + observed: &[ObservedItem], + now: u64, + ) -> Result { + let key = target_key(target); + let tx = self.db.begin_write().map_err(storage)?; + let admission = { + let mut jobs = tx.open_table(JOBS).map_err(storage)?; + let outcomes = tx.open_table(OUTCOMES).map_err(storage)?; + let retired = tx.open_table(RETIRED).map_err(storage)?; + if let Some(value) = outcomes + .get(outcome_key(target, "*").as_str()) + .map_err(storage)? + { + let outcome: Outcome = decode(value.value())?; + if outcome.class == OutcomeClass::Exhausted + && outcome.until > now + && origin != JobOrigin::Signal + { + return Ok(Admission::Damped); + } + } + let mut kept = Vec::new(); + for item in observed { + if retired + .get(item.identity.as_str()) + .map_err(storage)? + .is_some() + { + continue; + } + if let Some(outcome) = outcomes + .get(outcome_key(target, &item.identity).as_str()) + .map_err(storage)? + { + let outcome: Outcome = decode(outcome.value())?; + if now < outcome.until { + continue; + } + } + // An item already published but still missed by a reader was + // evicted from delivery storage; the job republishes its + // durable artifact and never signs it again. + if !kept.contains(item) { + kept.push(item.clone()); + } + } + let damped = origin == JobOrigin::Signal && kept.is_empty(); + let existing_job = if damped { + None + } else { + let existing = jobs.get(key.as_str()).map_err(storage)?; + existing + .map(|value| decode::(value.value())) + .transpose()? + }; + if damped { + Admission::Damped + } else if let Some(mut job) = existing_job { + // Existing work already covers reconciliation. A pending + // publisher warm will acquire current content itself; a reader + // cannot grow that job using the publisher's reserved bytes. + if origin == JobOrigin::Reconcile + || (origin == JobOrigin::Signal + && job.reserved + && matches!(job.state, JobState::Pending { .. })) + { + return Ok(Admission::Coalesced { key }); + } + let desired: Vec<_> = kept + .into_iter() + .take(self.bounds.max_items_per_job) + .collect(); + let desired_origin = origin; + if job.observed != desired || job.origin != desired_origin { + job.observed = desired; + job.origin = desired_origin; + job.reserved = origin != JobOrigin::Signal; + job.generation = job.generation.saturating_add(1); + job.follow_up |= matches!(job.state, JobState::Processing { .. }); + if !matches!(job.state, JobState::Processing { .. }) { + job.attempts = 0; + job.admitted_at = now; + job.state = JobState::Pending { ready_at: now }; + } + } + let encoded = encode(&job)?; + let mut total = encoded.len() + 128; + let mut readers = if job.reserved { 0 } else { encoded.len() + 128 }; + let mut reader_count = usize::from(!job.reserved); + for entry in jobs.iter().map_err(storage)? { + let (other_key, value) = entry.map_err(storage)?; + if other_key.value() == key { + continue; + } + total += value.value().len() + 128; + if !decode::(value.value())?.reserved { + readers += value.value().len() + 128; + reader_count += 1; + } + } + if total > self.bounds.max_queue_bytes + || (!job.reserved + && readers + > self + .bounds + .max_queue_bytes + .saturating_sub(self.bounds.reserved_bytes)) + || (!job.reserved + && reader_count + > self + .bounds + .max_jobs + .saturating_sub(self.bounds.reserved_jobs)) + { + return Ok(Admission::QueueFull); + } + jobs.insert(key.as_str(), encoded.as_slice()) + .map_err(storage)?; + Admission::Coalesced { key: key.clone() } + } else { + let mut count = 0_usize; + let mut bytes = 0_usize; + let mut reader_count = 0_usize; + let mut reader_bytes = 0_usize; + for entry in jobs.iter().map_err(storage)? { + let (_, value) = entry.map_err(storage)?; + let job: Job = decode(value.value())?; + count += 1; + bytes += value.value().len() + 128; + if !job.reserved { + reader_count += 1; + reader_bytes += value.value().len() + 128; + } + } + let job = Job { + key: key.clone(), + target: target.clone(), + origin, + observed: kept + .into_iter() + .take(self.bounds.max_items_per_job) + .collect(), + generation: 0, + follow_up: false, + attempts: 0, + admitted_at: now, + state: JobState::Pending { ready_at: now }, + reserved: origin != JobOrigin::Signal, + }; + let encoded = encode(&job)?; + let size = encoded.len() + 128; + let total_ok = + count < self.bounds.max_jobs && bytes + size <= self.bounds.max_queue_bytes; + let reader_ok = reader_count + < self + .bounds + .max_jobs + .saturating_sub(self.bounds.reserved_jobs) + && reader_bytes + size + <= self + .bounds + .max_queue_bytes + .saturating_sub(self.bounds.reserved_bytes); + if !total_ok || (origin == JobOrigin::Signal && !reader_ok) { + Admission::QueueFull + } else { + jobs.insert(key.as_str(), encoded.as_slice()) + .map_err(storage)?; + Admission::Queued { key: key.clone() } + } + } + }; + tx.commit().map_err(storage)?; + Ok(admission) + } + + /// Claims the next ready job, fencing it with a fresh token. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn claim(&self, now: u64, token: u64) -> Result, StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + let claimed = { + let mut jobs = tx.open_table(JOBS).map_err(storage)?; + let mut ready = None; + let mut exhausted = Vec::new(); + for entry in jobs.iter().map_err(storage)? { + let (key, value) = entry.map_err(storage)?; + let job: Job = decode(value.value())?; + if let JobState::Pending { ready_at } = job.state + && ready_at <= now + { + if job.attempts >= self.bounds.max_attempts + || now.saturating_sub(job.admitted_at) >= self.bounds.retry_window_seconds + { + exhausted.push((key.value().to_owned(), job)); + continue; + } + let older = ready.as_ref().is_none_or(|(_, prior): &(String, Job)| { + let prior_ready = match prior.state { + JobState::Pending { ready_at } => ready_at, + JobState::Processing { .. } => u64::MAX, + }; + (ready_at, job.admitted_at) < (prior_ready, prior.admitted_at) + }); + if older { + ready = Some((key.value().to_owned(), job)); + } + } + } + for (key, job) in exhausted { + damp_exhausted( + &tx, + &job, + "retry_window_exhausted", + now, + self.bounds.quota_bytes, + )?; + jobs.remove(key.as_str()).map_err(storage)?; + } + match ready { + Some((key, mut job)) => { + job.attempts += 1; + job.state = JobState::Processing { + token, + lease_until: now + self.bounds.lease_seconds, + }; + jobs.insert(key.as_str(), encode(&job)?.as_slice()) + .map_err(storage)?; + Some(Claim { job, token }) + } + None => None, + } + }; + tx.commit().map_err(storage)?; + Ok(claimed) + } + + /// Renews a claim's lease, only for its owner. Whether it still owns it. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn renew(&self, key: &str, token: u64, now: u64) -> Result { + self.with_claimed(key, token, |job| { + if matches!(job.state, JobState::Processing { lease_until, .. } if lease_until <= now) { + return Ok(None); + } + job.state = JobState::Processing { + token, + lease_until: now + self.bounds_lease(), + }; + Ok(Some(())) + }) + .map(|renewed| renewed.is_some()) + } + + fn bounds_lease(&self) -> u64 { + self.bounds.lease_seconds + } + + /// Applies `change` to a job still claimed under `token`; a stale claim + /// changes nothing. + fn with_claimed( + &self, + key: &str, + token: u64, + change: impl FnOnce(&mut Job) -> Result, StoreError>, + ) -> Result, StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + let result = { + let mut jobs = tx.open_table(JOBS).map_err(storage)?; + let current = jobs + .get(key) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + match current { + Some(mut job) if matches!(job.state, JobState::Processing { token: held, .. } if held == token) => + { + let result = change(&mut job)?; + jobs.insert(key, encode(&job)?.as_slice()) + .map_err(storage)?; + result + } + _ => None, + } + }; + tx.commit().map_err(storage)?; + Ok(result) + } + + /// Settles a claim that finished its work: removes the job, unless a + /// newer observation arrived, which is scheduled at once. A stale claim + /// settles nothing. Whether the claim was current. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn complete(&self, key: &str, token: u64, now: u64) -> Result { + let tx = self.db.begin_write().map_err(storage)?; + let current = { + let mut jobs = tx.open_table(JOBS).map_err(storage)?; + let job = jobs + .get(key) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + match job { + Some(mut job) if matches!(job.state, JobState::Processing { token: held, lease_until } if held == token && lease_until > now) => + { + if job.follow_up { + job.follow_up = false; + job.attempts = 0; + job.admitted_at = now; + job.state = JobState::Pending { ready_at: now }; + jobs.insert(key, encode(&job)?.as_slice()) + .map_err(storage)?; + } else { + jobs.remove(key).map_err(storage)?; + } + true + } + _ => false, + } + }; + tx.commit().map_err(storage)?; + Ok(current) + } + + /// Settles a claim whose attempt failed transiently: retried with + /// bounded exponential backoff while attempts and the retry window + /// last, else removed with each observed item damped for an hour. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn retry(&self, key: &str, token: u64, now: u64, code: &str) -> Result { + let tx = self.db.begin_write().map_err(storage)?; + { + let mut jobs = tx.open_table(JOBS).map_err(storage)?; + let current = jobs + .get(key) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + let Some(mut job) = current else { + return Ok(false); + }; + if !matches!(job.state, JobState::Processing { token: held, lease_until } if held == token && lease_until > now) + { + return Ok(false); + } + if job.follow_up { + // Failure of an older generation never damps or deletes the desired one. + job.follow_up = false; + job.attempts = 0; + job.admitted_at = now; + job.state = JobState::Pending { ready_at: now }; + jobs.insert(key, encode(&job)?.as_slice()) + .map_err(storage)?; + } else if job.attempts >= self.bounds.max_attempts + || now.saturating_sub(job.admitted_at) >= self.bounds.retry_window_seconds + { + damp_exhausted(&tx, &job, code, now, self.bounds.quota_bytes)?; + jobs.remove(key).map_err(storage)?; + } else { + let delay = 5_u64 + .saturating_mul(1_u64 << job.attempts.min(16)) + .min(self.bounds.max_backoff_seconds); + job.state = JobState::Pending { + ready_at: now.saturating_add(delay), + }; + jobs.insert(key, encode(&job)?.as_slice()) + .map_err(storage)?; + } + } + tx.commit().map_err(storage)?; + Ok(true) + } + + /// One job. + /// + /// # Errors + /// + /// A [`StoreError`] where the read fails. + pub fn job(&self, key: &str) -> Result, StoreError> { + self.read_value(JOBS, key) + } + + /// Returns every processing job whose lease ended to pending, as on + /// restart. How many were recovered. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn recover_expired(&self, now: u64) -> Result { + self.recover_claims(now, false) + } + + /// Recovers every claim at exclusive-owner startup, using a finite retry time. + /// + /// # Errors + /// A store transaction failure. + pub fn recover_all(&self, now: u64) -> Result { + self.recover_claims(now, true) + } + + fn recover_claims(&self, now: u64, all: bool) -> Result { + let tx = self.db.begin_write().map_err(storage)?; + let mut recovered = 0; + { + let mut jobs = tx.open_table(JOBS).map_err(storage)?; + let mut expired = Vec::new(); + for entry in jobs.iter().map_err(storage)? { + let (key, value) = entry.map_err(storage)?; + let job: Job = decode(value.value())?; + if let JobState::Processing { lease_until, .. } = job.state + && (all || lease_until <= now) + { + expired.push((key.value().to_owned(), job)); + } + } + for (key, mut job) in expired { + if job.follow_up { + job.follow_up = false; + job.attempts = 0; + job.admitted_at = now; + } else if job.attempts >= self.bounds.max_attempts + || now.saturating_sub(job.admitted_at) >= self.bounds.retry_window_seconds + { + damp_exhausted( + &tx, + &job, + "retry_window_exhausted", + now, + self.bounds.quota_bytes, + )?; + jobs.remove(key.as_str()).map_err(storage)?; + recovered += 1; + continue; + } + job.state = JobState::Pending { ready_at: now }; + jobs.insert(key.as_str(), encode(&job)?.as_slice()) + .map_err(storage)?; + recovered += 1; + } + } + tx.commit().map_err(storage)?; + Ok(recovered) + } + + /// Records one item's damped outcome. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn record_outcome( + &self, + target: &Target, + identity: &str, + code: &str, + class: OutcomeClass, + now: u64, + ) -> Result<(), StoreError> { + let until = match class { + OutcomeClass::SourceUnstable => now + 300, + OutcomeClass::Exhausted => now + 3600, + OutcomeClass::Terminal => u64::MAX, + }; + let outcome = Outcome { + code: code.to_owned(), + class, + until, + }; + let tx = self.db.begin_write().map_err(storage)?; + put_outcome(&tx, target, identity, &outcome, self.bounds.quota_bytes)?; + tx.commit().map_err(storage) + } + + /// Records an outcome only for the current claim and desired generation. + /// Late failures cannot damp an operator retry or a newer observation. + /// + /// # Errors + /// A failed store transaction. + pub fn record_claim_outcome( + &self, + claim: &Claim, + identity: &str, + code: &str, + class: OutcomeClass, + now: u64, + ) -> Result<(), StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + let current = tx + .open_table(JOBS) + .map_err(storage)? + .get(claim.job.key.as_str()) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + if current.is_some_and(|job| job.generation == claim.job.generation + && matches!(job.state, JobState::Processing { token, lease_until } if token == claim.token && lease_until > now)) + { + let until = match class { + OutcomeClass::SourceUnstable => now.saturating_add(300), + OutcomeClass::Exhausted => now.saturating_add(3600), + OutcomeClass::Terminal => u64::MAX, + }; + let outcome = Outcome { code: code.to_owned(), class, until }; + put_outcome(&tx, &claim.job.target, identity, &outcome, self.bounds.quota_bytes)?; + } + tx.commit().map_err(storage) + } + + /// Invalidates terminal damping when relevant configured behavior or the + /// authenticated probe release changes. Retirement tombstones are never cleared. + /// + /// # Errors + /// A failed store transaction. + pub fn refresh_context(&self, scope: &str, identity: &str) -> Result<(), StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + { + let mut meta = tx.open_table(META).map_err(storage)?; + let key = format!("damping_context:{scope}"); + let prior = meta + .get(key.as_str()) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + if prior.as_deref().is_some_and(|prior| prior != identity) { + let mut outcomes = tx.open_table(OUTCOMES).map_err(storage)?; + let mut stale = Vec::new(); + for entry in outcomes.iter().map_err(storage)? { + let (key, value) = entry.map_err(storage)?; + if decode::(value.value())?.class == OutcomeClass::Terminal { + stale.push(key.value().to_owned()); + } + } + for key in stale { + outcomes.remove(key.as_str()).map_err(storage)?; + tx.open_table(QUOTA) + .map_err(storage)? + .remove(format!("outcome:{key}").as_str()) + .map_err(storage)?; + } + } + meta.insert(key.as_str(), encode(&identity)?.as_slice()) + .map_err(storage)?; + } + tx.commit().map_err(storage) + } + + /// Clears every outcome of `target`, for an operator's explicit retry. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn clear_outcomes(&self, target: &Target, now: u64) -> Result<(), StoreError> { + let prefix = format!("{}#", target_key(target)); + let tx = self.db.begin_write().map_err(storage)?; + { + let mut outcomes = tx.open_table(OUTCOMES).map_err(storage)?; + let mut keys = Vec::new(); + for entry in outcomes.iter().map_err(storage)? { + let (key, _) = entry.map_err(storage)?; + if key.value().starts_with(&prefix) { + keys.push(key.value().to_owned()); + } + } + for key in keys { + outcomes.remove(key.as_str()).map_err(storage)?; + tx.open_table(QUOTA) + .map_err(storage)? + .remove(format!("outcome:{key}").as_str()) + .map_err(storage)?; + } + } + { + let mut jobs = tx.open_table(JOBS).map_err(storage)?; + let key = target_key(target); + let current = jobs + .get(key.as_str()) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + if let Some(mut job) = current { + job.generation = job.generation.saturating_add(1); + if matches!(job.state, JobState::Processing { .. }) { + job.follow_up = true; + } else { + job.attempts = 0; + job.admitted_at = now; + job.state = JobState::Pending { ready_at: now }; + } + jobs.insert(key.as_str(), encode(&job)?.as_slice()) + .map_err(storage)?; + } + } + tx.commit().map_err(storage) + } + + /// The outcome recorded for an item. + /// + /// # Errors + /// + /// A [`StoreError`] where the read fails. + pub fn outcome(&self, target: &Target, identity: &str) -> Result, StoreError> { + self.read_value(OUTCOMES, &outcome_key(target, identity)) + } + + /// Records a verified plan before its finalize, so a crash after + /// finalize recovers the receipt rather than planning again. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn record_plan(&self, identity: &str, planned: &PlannedMedia) -> Result<(), StoreError> { + let _guard = self.objects.lock().map_err(storage)?; + self.reserve_identity(identity)?; + let encoded = encode(planned)?; + if !self.reserve(&format!("plan:{identity}"), encoded.len() as u64)? { + return Err(StoreError::Quota); + } + let tx = self.db.begin_write().map_err(storage)?; + if tx + .open_table(RETIRED) + .map_err(storage)? + .get(identity) + .map_err(storage)? + .is_some() + { + return Err(StoreError::Storage( + "identity retired during planning".to_owned(), + )); + } + tx.open_table(PLANS) + .map_err(storage)? + .insert(identity, encoded.as_slice()) + .map_err(storage)?; + let mut references = tx.open_table(PROBE_REFERENCES).map_err(storage)?; + let sha = planned.release.sha256.as_str(); + let mut identities: Vec = references + .get(sha) + .map_err(storage)? + .map(|value| decode(value.value())) + .transpose()? + .unwrap_or_default(); + if !identities.iter().any(|existing| existing == identity) { + identities.push(identity.to_owned()); + } + let encoded = encode(&identities)?; + if !reserve_in( + &tx, + &format!("probe-record:{sha}"), + encoded.len() as u64, + self.bounds.quota_bytes, + )? { + return Err(StoreError::Quota); + } + references + .insert(sha, encoded.as_slice()) + .map_err(storage)?; + drop(references); + tx.commit().map_err(storage) + } + + /// The plan recorded for an identity. + /// + /// # Errors + /// + /// A [`StoreError`] where the read fails. + pub fn plan(&self, identity: &str) -> Result, StoreError> { + self.read_value(PLANS, identity) + } + + /// The distinct pinned releases retained plans require, without retaining all geometry in memory. + /// + /// # Errors + /// A failed store read. + pub fn probe_releases( + &self, + ) -> Result< + Vec, + StoreError, + > { + let tx = self.db.begin_read().map_err(storage)?; + let table = tx.open_table(PLANS).map_err(storage)?; + let mut releases = Vec::new(); + for entry in table.iter().map_err(storage)? { + let (_, value) = entry.map_err(storage)?; + let plan: PlannedMedia = decode(value.value())?; + if !releases.contains(&plan.release) { + releases.push(plan.release); + } + } + Ok(releases) + } + + /// Whether interrupted text operations can recover without creation capability. + /// + /// # Errors + /// A failed store read. + pub fn has_text_operations(&self) -> Result { + let tx = self.db.begin_read().map_err(storage)?; + Ok(tx + .open_table(TEXT_OPERATIONS) + .map_err(storage)? + .len() + .map_err(storage)? + > 0) + } + + /// Whether an identity owns reconstruction inputs or a durable output. + /// + /// # Errors + /// A failed store read. + pub fn has_reconstruction(&self, identity: &str) -> Result { + let tx = self.db.begin_read().map_err(storage)?; + for table in [PLANS, TEXT_OPERATIONS, ARTIFACTS] { + if tx + .open_table(table) + .map_err(storage)? + .get(identity) + .map_err(storage)? + .is_some() + { + return Ok(true); + } + } + Ok(self + .root + .join("artifacts") + .join(format!("{identity}.json")) + .exists()) + } + + /// Drops failed pre-plan source references without creating a retirement or spent key. + /// + /// # Errors + /// A failed store transaction or file removal; uncertain bytes remain charged. + pub fn discard_unplanned_sources(&self, identity: &str) -> Result<(), StoreError> { + let _guard = self.objects.lock().map_err(storage)?; + if self.has_reconstruction(identity)? { + return Ok(()); + } + let tx = self.db.begin_write().map_err(storage)?; + { + let mut sources = tx.open_table(SOURCES).map_err(storage)?; + let mut changed = Vec::new(); + for entry in sources.iter().map_err(storage)? { + let (sha, value) = entry.map_err(storage)?; + let mut source: SourceRecord = decode(value.value())?; + source.references.retain(|reference| reference != identity); + source.public &= !source.references.is_empty(); + changed.push((sha.value().to_owned(), source)); + } + for (sha, source) in changed { + sources + .insert(sha.as_str(), encode(&source)?.as_slice()) + .map_err(storage)?; + } + } + tx.commit().map_err(storage)?; + self.remove_unreferenced_sources()?; + if self.retirement(identity)?.is_none() { + self.release(&format!("identity:{identity}"))?; + } + Ok(()) + } + + /// Records a text attempt before its first network write. Returns false + /// for an existing operation, which must use recovery rather than signing. + /// + /// # Errors + /// Refuses retired identities, conflicting requests and exhausted storage. + pub fn begin_text( + &self, + identity: &str, + request: &trusted_server_provenance_encypher::preparation::pipeline::TextSignRequest, + ) -> Result { + let _guard = self.objects.lock().map_err(storage)?; + if self.retirement(identity)?.is_some() { + return Err(storage("text identity retired")); + } + self.reserve_identity(identity)?; + let file = self.write_text_file(identity, request)?; + let tx = self.db.begin_write().map_err(storage)?; + if tx + .open_table(RETIRED) + .map_err(storage)? + .get(identity) + .map_err(storage)? + .is_some() + { + return Err(storage("text identity retired")); + } + let created = { + let mut operations = tx.open_table(TEXT_OPERATIONS).map_err(storage)?; + let existing = operations + .get(identity) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + if let Some(existing) = existing { + if existing.sha256 != file.sha256 || existing.bytes != file.bytes { + return Err(storage("text operation conflict")); + } + false + } else { + operations + .insert(identity, encode(&file)?.as_slice()) + .map_err(storage)?; + true + } + }; + tx.commit().map_err(storage)?; + Ok(created) + } + + /// The identities retaining a pinned probe release, for lifecycle checks. + /// + /// # Errors + /// A store read failure. + pub fn probe_references(&self, sha256: &str) -> Result, StoreError> { + Ok(self + .read_value(PROBE_REFERENCES, sha256)? + .unwrap_or_default()) + } + + fn put( + &self, + table: TableDefinition<&str, &[u8]>, + key: &str, + value: &T, + ) -> Result<(), StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + tx.open_table(table) + .map_err(storage)? + .insert(key, encode(value)?.as_slice()) + .map_err(storage)?; + tx.commit().map_err(storage) + } + + /// Records a published artifact, unless its identity was retired after + /// it was prepared: a late result never outlives a retirement. + /// Whether it was recorded. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn record_artifact(&self, artifact: &Artifact) -> Result { + let identity = artifact.envelope.identity.as_str(); + let _guard = self.objects.lock().map_err(storage)?; + if self.retirement(identity)?.is_some() { + return Ok(false); + } + self.reserve_identity(identity)?; + // Stream JSON into an owned filesystem object, never a large redb value. + let file = self.write_artifact_file(identity, artifact)?; + let tx = self.db.begin_write().map_err(storage)?; + tx.open_table(ARTIFACTS) + .map_err(storage)? + .insert(identity, encode(&file)?.as_slice()) + .map_err(storage)?; + tx.commit().map_err(storage)?; + self.shrink_reservation( + &format!("artifact:{identity}"), + file.bytes + encode(&file)?.len() as u64, + )?; + Ok(true) + } + + /// The artifact of an identity. + /// + /// # Errors + /// + /// A [`StoreError`] where the read fails. + pub fn artifact(&self, identity: &str) -> Result, StoreError> { + let _guard = self.objects.lock().map_err(storage)?; + let Some(file) = self.read_value::(ARTIFACTS, identity)? else { + return Ok(None); + }; + self.read_artifact_file(identity, &file).map(Some) + } + + /// Records publication acknowledgement without rewriting immutable artifact bytes. + /// + /// # Errors + /// A failed store transaction. + pub fn acknowledge_artifact(&self, identity: &str) -> Result<(), StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + { + let mut artifacts = tx.open_table(ARTIFACTS).map_err(storage)?; + let current = artifacts + .get(identity) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + if let Some(mut file) = current { + file.acknowledged = true; + artifacts + .insert(identity, encode(&file)?.as_slice()) + .map_err(storage)?; + } + } + tx.commit().map_err(storage) + } + + /// The current retirement epoch. + /// + /// # Errors + /// + /// A [`StoreError`] where the read fails. + pub fn retirement_epoch(&self) -> Result { + Ok(self + .read_value::(META, "retirement_epoch")? + .unwrap_or(0)) + } + + /// Retires `identity` in one transaction: increments the epoch, records + /// the tombstone, drops its artifact and plan, and queues its marker in + /// the outbox before anything else is cleaned up. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn retire(&self, identity: &str, deployment_id: &str) -> Result { + let _guard = self.objects.lock().map_err(storage)?; + if let Some(retirement) = self.retirement(identity)? { + return Ok(retirement.epoch); + } + self.reserve_identity(identity)?; + let tx = self.db.begin_write().map_err(storage)?; + let epoch = { + let mut meta = tx.open_table(META).map_err(storage)?; + let epoch = meta + .get("retirement_epoch") + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()? + .unwrap_or(0) + + 1; + meta.insert("retirement_epoch", encode(&epoch)?.as_slice()) + .map_err(storage)?; + tx.open_table(RETIRED) + .map_err(storage)? + .insert( + identity, + encode(&Retirement { + epoch, + acknowledged: false, + })? + .as_slice(), + ) + .map_err(storage)?; + let marker = MarkerEnvelope { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: deployment_id.to_owned(), + identity: identity.to_owned(), + epoch, + }; + tx.open_table(OUTBOX) + .map_err(storage)? + .insert(identity, encode(&marker)?.as_slice()) + .map_err(storage)?; + tx.open_table(ARTIFACTS) + .map_err(storage)? + .remove(identity) + .map_err(storage)?; + tx.open_table(PLANS) + .map_err(storage)? + .remove(identity) + .map_err(storage)?; + tx.open_table(QUOTA) + .map_err(storage)? + .remove(format!("plan:{identity}").as_str()) + .map_err(storage)?; + tx.open_table(TEXT_OPERATIONS) + .map_err(storage)? + .remove(identity) + .map_err(storage)?; + let mut sources = tx.open_table(SOURCES).map_err(storage)?; + let mut changed = Vec::new(); + for entry in sources.iter().map_err(storage)? { + let (sha, value) = entry.map_err(storage)?; + let mut source: SourceRecord = decode(value.value())?; + source.references.retain(|reference| reference != identity); + source.public &= !source.references.is_empty(); + changed.push((sha.value().to_owned(), source)); + } + for (sha, source) in changed { + sources + .insert(sha.as_str(), encode(&source)?.as_slice()) + .map_err(storage)?; + } + drop(sources); + let mut probes = tx.open_table(PROBE_REFERENCES).map_err(storage)?; + let mut changed = Vec::new(); + for entry in probes.iter().map_err(storage)? { + let (sha, value) = entry.map_err(storage)?; + let mut references: Vec = decode(value.value())?; + references.retain(|reference| reference != identity); + changed.push((sha.value().to_owned(), references)); + } + for (sha, references) in changed { + probes + .insert(sha.as_str(), encode(&references)?.as_slice()) + .map_err(storage)?; + } + epoch + }; + tx.commit().map_err(storage)?; + // The tombstone and outbox commit first. A failed filesystem removal + // stays charged and can be retried safely on restart. + self.remove_artifact_file(identity)?; + self.remove_text_file(identity)?; + self.remove_unreferenced_sources()?; + Ok(epoch) + } + + /// Whether an identity is retired. + /// + /// # Errors + /// + /// A [`StoreError`] where the read fails. + pub fn retirement(&self, identity: &str) -> Result, StoreError> { + self.read_value(RETIRED, identity) + } + + /// Markers waiting for the delivery instance's acknowledgement. + /// + /// # Errors + /// + /// A [`StoreError`] where the read fails. + pub fn outbox(&self) -> Result, StoreError> { + Ok(self + .read_all::(OUTBOX)? + .into_iter() + .map(|(_, marker)| marker) + .collect()) + } + + /// A bounded round-robin marker batch; failed markers cannot starve later ones. + /// + /// # Errors + /// A failed cursor transaction or outbox read. + pub fn outbox_batch(&self, limit: usize) -> Result, StoreError> { + let cursor: String = self.read_value(META, "outbox_cursor")?.unwrap_or_default(); + let markers = self.outbox()?; + let batch: Vec<_> = markers + .iter() + .filter(|marker| marker.identity > cursor) + .chain(markers.iter().filter(|marker| marker.identity <= cursor)) + .take(limit.min(16)) + .cloned() + .collect(); + if let Some(last) = batch.last() { + self.put(META, "outbox_cursor", &last.identity)?; + } + Ok(batch) + } + + /// Records the delivery instance's acknowledgement of a marker at + /// `epoch`; an older epoch's acknowledgement settles nothing newer. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn acknowledge_marker(&self, identity: &str, epoch: u64) -> Result<(), StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + { + let mut retired = tx.open_table(RETIRED).map_err(storage)?; + let current = retired + .get(identity) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + if let Some(mut retirement) = current + && retirement.epoch == epoch + { + retirement.acknowledged = true; + retired + .insert(identity, encode(&retirement)?.as_slice()) + .map_err(storage)?; + tx.open_table(OUTBOX) + .map_err(storage)? + .remove(identity) + .map_err(storage)?; + } + } + tx.commit().map_err(storage) + } + + /// Registers inventory targets, within the bound. How many were added. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn register(&self, targets: &[Target]) -> Result { + let tx = self.db.begin_write().map_err(storage)?; + let mut added = 0; + { + let mut inventory = tx.open_table(INVENTORY).map_err(storage)?; + let existing = inventory.len().map_err(storage)? as usize; + for target in targets { + let key = target_key(target); + if inventory.get(key.as_str()).map_err(storage)?.is_some() { + continue; + } + if existing + added >= self.bounds.max_inventory { + return Err(StoreError::InventoryFull); + } + let entry = InventoryEntry { + target: target.clone(), + last_scan: 0, + last_identities: Vec::new(), + }; + let encoded = encode(&entry)?; + if !reserve_in( + &tx, + &format!("inventory:{key}"), + encoded.len() as u64, + self.bounds.quota_bytes, + )? { + return Err(StoreError::Quota); + } + inventory + .insert(key.as_str(), encoded.as_slice()) + .map_err(storage)?; + added += 1; + } + } + tx.commit().map_err(storage)?; + Ok(added) + } + + /// Up to `limit` inventory targets after the persisted cursor whose last + /// reconciliation is older than `interval`, advancing the cursor. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn due_inventory( + &self, + now: u64, + interval: u64, + limit: usize, + ) -> Result, StoreError> { + let cursor: String = self + .read_value(META, "inventory_cursor")? + .unwrap_or_default(); + let entries = self.read_all::(INVENTORY)?; + let limit = limit.min(128); + if limit == 0 { + return Ok(Vec::new()); + } + // Leave one cursor revolution of slack so a target cannot miss the + // configured maximum merely by falling just before the scan cursor. + let threshold = + interval.saturating_sub((entries.len().div_ceil(limit) as u64).saturating_mul(60)); + let mut ordered: Vec<&(String, InventoryEntry)> = + entries.iter().filter(|(key, _)| *key > cursor).collect(); + ordered.extend(entries.iter().filter(|(key, _)| *key <= cursor)); + let mut due = Vec::new(); + let mut last = cursor.clone(); + for (key, entry) in ordered.into_iter().take(limit) { + last.clone_from(key); + if entry.last_scan == 0 || now.saturating_sub(entry.last_scan) >= threshold { + due.push(entry.target.clone()); + } + } + self.put(META, "inventory_cursor", &last)?; + Ok(due) + } + + fn reserve_identity(&self, identity: &str) -> Result<(), StoreError> { + // This survives artifact deletion and funds the spent key and immutable + // retirement outbox even when the rest of the hard quota is full. + if self.reserve(&format!("identity:{identity}"), 1024)? { + Ok(()) + } else { + Err(StoreError::Quota) + } + } + + /// The identities of a target's last snapshot, and records this one's. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn observe_snapshot( + &self, + target: &Target, + identities: &[String], + now: u64, + ) -> Result>, StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + let previous = Self::observe_in(&tx, target, identities, now, self.bounds.quota_bytes)?; + tx.commit().map_err(storage)?; + Ok(previous) + } + + /// Observes an admitted snapshot only for the live claim and desired generation. + /// A stale completion cannot overwrite stability evidence for its follow-up. + /// + /// # Errors + /// A failed transaction. + pub fn observe_claim_snapshot( + &self, + claim: &Claim, + identities: &[String], + now: u64, + ) -> Result>, StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + let current = tx + .open_table(JOBS) + .map_err(storage)? + .get(claim.job.key.as_str()) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + if !current.is_some_and(|job| job.generation == claim.job.generation + && matches!(job.state, JobState::Processing { token, lease_until } if token == claim.token && lease_until > now)) + { + return Ok(None); + } + { + let mut outcomes = tx.open_table(OUTCOMES).map_err(storage)?; + let key = outcome_key(&claim.job.target, "*"); + let terminal = outcomes + .get(key.as_str()) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()? + .is_some_and(|outcome| outcome.class == OutcomeClass::Terminal); + if terminal { + outcomes.remove(key.as_str()).map_err(storage)?; + tx.open_table(QUOTA) + .map_err(storage)? + .remove(format!("outcome:{key}").as_str()) + .map_err(storage)?; + } + } + let previous = Self::observe_in( + &tx, + &claim.job.target, + identities, + now, + self.bounds.quota_bytes, + )?; + tx.commit().map_err(storage)?; + Ok(previous) + } + + fn observe_in( + tx: &redb::WriteTransaction, + target: &Target, + identities: &[String], + now: u64, + limit: u64, + ) -> Result>, StoreError> { + let key = target_key(target); + let mut inventory = tx.open_table(INVENTORY).map_err(storage)?; + let current = inventory + .get(key.as_str()) + .map_err(storage)? + .map(|value| decode::(value.value())) + .transpose()?; + let Some(mut entry) = current else { + return Ok(None); + }; + let previous = std::mem::replace(&mut entry.last_identities, identities.to_vec()); + entry.last_scan = now; + let encoded = encode(&entry)?; + if !reserve_in(tx, &format!("inventory:{key}"), encoded.len() as u64, limit)? { + return Err(StoreError::Quota); + } + inventory + .insert(key.as_str(), encoded.as_slice()) + .map_err(storage)?; + Ok(Some(previous)) + } + + /// Records a source snapshot, public only after page and asset + /// admission, referenced by `identity`. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn record_source( + &self, + sha256: &str, + mime: &str, + public: bool, + identity: &str, + ) -> Result<(), StoreError> { + let _guard = self.objects.lock().map_err(storage)?; + self.reserve_identity(identity)?; + self.record_source_locked(sha256, mime, public, identity) + } + + fn record_source_locked( + &self, + sha256: &str, + mime: &str, + public: bool, + identity: &str, + ) -> Result<(), StoreError> { + if self.retirement(identity)?.is_some() { + return Err(storage("source owner retired")); + } + let tx = self.db.begin_write().map_err(storage)?; + { + let mut sources = tx.open_table(SOURCES).map_err(storage)?; + let mut record: SourceRecord = sources + .get(sha256) + .map_err(storage)? + .map(|value| decode(value.value())) + .transpose()? + .unwrap_or(SourceRecord { + mime: mime.to_owned(), + public, + references: Vec::new(), + }); + if record.mime != mime { + return Err(storage("source media type conflict")); + } + record.public |= public; + if !record + .references + .iter() + .any(|existing| existing == identity) + { + record.references.push(identity.to_owned()); + } + let encoded = encode(&record)?; + if !reserve_in( + &tx, + &format!("source-record:{sha256}"), + encoded.len() as u64, + self.bounds.quota_bytes, + )? { + return Err(StoreError::Quota); + } + sources + .insert(sha256, encoded.as_slice()) + .map_err(storage)?; + } + tx.commit().map_err(storage) + } + + /// A source snapshot's record. + /// + /// # Errors + /// + /// A [`StoreError`] where the read fails. + pub fn source(&self, sha256: &str) -> Result, StoreError> { + self.read_value(SOURCES, sha256) + } + + /// Counts for status. + /// + /// # Errors + /// + /// A [`StoreError`] where a read fails. + pub fn counts(&self) -> Result { + let tx = self.db.begin_read().map_err(storage)?; + let mut counts = Counts::default(); + for entry in tx + .open_table(JOBS) + .map_err(storage)? + .iter() + .map_err(storage)? + { + let (_, value) = entry.map_err(storage)?; + match decode::(value.value())?.state { + JobState::Pending { .. } => counts.pending += 1, + JobState::Processing { .. } => counts.processing += 1, + } + } + for entry in tx + .open_table(ARTIFACTS) + .map_err(storage)? + .iter() + .map_err(storage)? + { + let (_, value) = entry.map_err(storage)?; + counts.artifacts += 1; + if decode::(value.value())?.acknowledged { + counts.publication_acknowledged += 1; + } else { + counts.publication_pending += 1; + } + } + for entry in tx + .open_table(RETIRED) + .map_err(storage)? + .iter() + .map_err(storage)? + { + let (_, value) = entry.map_err(storage)?; + if decode::(value.value())?.acknowledged { + counts.retirement_acknowledged += 1; + } else { + counts.retirement_pending += 1; + } + } + counts.inventory = usize::try_from( + tx.open_table(INVENTORY) + .map_err(storage)? + .len() + .map_err(storage)?, + ) + .map_err(storage)?; + for entry in tx + .open_table(QUOTA) + .map_err(storage)? + .iter() + .map_err(storage)? + { + counts.reserved_bytes = counts + .reserved_bytes + .saturating_add(entry.map_err(storage)?.1.value()); + } + Ok(counts) + } +} + +impl Store { + /// Reserves `bytes` for `owner` in one transaction, against every + /// reservation every task holds. Whether it fit: a full quota refuses + /// the work rather than evicting a retained result. An owner that + /// already holds a reservation keeps the larger of the two. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn reserve(&self, owner: &str, bytes: u64) -> Result { + let tx = self.db.begin_write().map_err(storage)?; + let fits = reserve_in(&tx, owner, bytes, self.bounds.quota_bytes)?; + tx.commit().map_err(storage)?; + Ok(fits) + } + + /// Returns conservative surplus after its durable object has been measured. + /// + /// # Errors + /// Refuses growth or a failed transaction. + pub fn shrink_reservation(&self, owner: &str, bytes: u64) -> Result<(), StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + { + let mut quota = tx.open_table(QUOTA).map_err(storage)?; + let held = quota + .get(owner) + .map_err(storage)? + .map_or(0, |value| value.value()); + if held < bytes { + return Err(storage("cannot grow by shrinking reservation")); + } + quota.insert(owner, bytes).map_err(storage)?; + } + tx.commit().map_err(storage) + } + + /// Releases `owner`'s reservation. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + pub fn release(&self, owner: &str) -> Result<(), StoreError> { + let tx = self.db.begin_write().map_err(storage)?; + tx.open_table(QUOTA) + .map_err(storage)? + .remove(owner) + .map_err(storage)?; + tx.commit().map_err(storage) + } + + fn reservation(&self, owner: &str) -> Result, StoreError> { + let tx = self.db.begin_read().map_err(storage)?; + Ok(tx + .open_table(QUOTA) + .map_err(storage)? + .get(owner) + .map_err(storage)? + .map(|value| value.value())) + } + + /// The bytes reserved now. + /// + /// # Errors + /// + /// A [`StoreError`] where the read fails. + pub fn reserved(&self) -> Result { + let tx = self.db.begin_read().map_err(storage)?; + let quota = tx.open_table(QUOTA).map_err(storage)?; + let mut used = 0_u64; + for entry in quota.iter().map_err(storage)? { + used = used.saturating_add(entry.map_err(storage)?.1.value()); + } + Ok(used) + } + + /// Reconciles reservations on restart: every temporary reservation is + /// released, since no task survives a restart and its file is removed + /// with it, and every artifact reservation whose artifact is gone, as + /// after a retirement, is released. Retained material keeps its bytes. + /// How many were released. + /// + /// # Errors + /// + /// A [`StoreError`] where the transaction fails. + fn reconcile_reservations(&self) -> Result { + let tx = self.db.begin_write().map_err(storage)?; + let released = { + let artifacts = tx.open_table(ARTIFACTS).map_err(storage)?; + let plans = tx.open_table(PLANS).map_err(storage)?; + let text = tx.open_table(TEXT_OPERATIONS).map_err(storage)?; + let sources = tx.open_table(SOURCES).map_err(storage)?; + let probes = tx.open_table(PROBE_REFERENCES).map_err(storage)?; + let retired = tx.open_table(RETIRED).map_err(storage)?; + let mut quota = tx.open_table(QUOTA).map_err(storage)?; + let mut stale = Vec::new(); + for entry in quota.iter().map_err(storage)? { + let (key, _) = entry.map_err(storage)?; + let key = key.value().to_owned(); + let orphan = match key.split_once(':') { + Some(("temp", _)) => true, + Some(("artifact", identity)) => { + artifacts.get(identity).map_err(storage)?.is_none() + && !self + .root + .join("artifacts") + .join(format!("{identity}.json")) + .try_exists() + .map_err(storage)? + && plans.get(identity).map_err(storage)?.is_none() + && text.get(identity).map_err(storage)?.is_none() + } + Some(("identity", identity)) if is_digest(identity) => { + retired.get(identity).map_err(storage)?.is_none() + && artifacts.get(identity).map_err(storage)?.is_none() + && plans.get(identity).map_err(storage)?.is_none() + && text.get(identity).map_err(storage)?.is_none() + && !self + .root + .join("artifacts") + .join(format!("{identity}.json")) + .try_exists() + .map_err(storage)? + } + Some(("text", identity)) if is_digest(identity) => { + text.get(identity).map_err(storage)?.is_none() + && !self + .root + .join("texts") + .join(format!("{identity}.json")) + .try_exists() + .map_err(storage)? + } + Some(("source", digest)) if is_digest(digest) => { + sources.get(digest).map_err(storage)?.is_none() + && !self + .root + .join("sources") + .join(digest) + .try_exists() + .map_err(storage)? + } + Some(("probe", digest)) if is_digest(digest) => { + probes.get(digest).map_err(storage)?.is_none() + && !self + .root + .join("probes") + .join(format!("{digest}.wasm")) + .try_exists() + .map_err(storage)? + } + _ => false, + }; + if orphan { + stale.push(key); + } + } + for key in &stale { + quota.remove(key.as_str()).map_err(storage)?; + } + stale.len() + }; + tx.commit().map_err(storage)?; + Ok(released) + } +} + +/// How a pipeline failure damps the item. +#[must_use] +pub fn outcome_class(class: FailureClass) -> Option { + match class { + FailureClass::Transient | FailureClass::PolicyChanged | FailureClass::Missing => None, + FailureClass::Integrity + | FailureClass::Privacy + | FailureClass::Unsupported + | FailureClass::ExistingProvenance => Some(OutcomeClass::Terminal), + } +} + +#[cfg(test)] +mod tests { + use trusted_server_provenance_encypher::preparation::pipeline::TextCacheScope; + use trusted_server_provenance_encypher::preparation::protocol::ItemKind; + + use super::*; + + fn target(path: &str) -> Target { + Target { + path: path.to_owned(), + variant: String::new(), + } + } + + fn item(identity: &str) -> ObservedItem { + ObservedItem { + kind: ItemKind::Embedded, + identity: identity.repeat(64 / identity.len()), + } + } + + fn open(dir: &tempfile::TempDir, bounds: Bounds) -> Store { + Store::open(&dir.path().join("prep.redb"), bounds).expect("should open the store") + } + + #[test] + fn quota_reservations_are_shared_hard_and_reconciled_after_restart() { + let dir = tempfile::tempdir().expect("should make a directory"); + let bounds = Bounds { + quota_bytes: 100, + ..Bounds::default() + }; + { + let store = open(&dir, bounds); + assert!( + store.reserve("source:a", 40).expect("reserve"), + "should fit" + ); + assert!(store.reserve("temp:b", 50).expect("reserve"), "should fit"); + assert!( + !store.reserve("artifact:c", 20).expect("reserve"), + "a reservation past the quota is refused, never squeezed in" + ); + assert!( + store.reserve("source:a", 30).expect("reserve"), + "an owner keeps the larger of its reservations" + ); + assert_eq!(store.reserved().expect("read"), 90); + assert!(store.reserve("artifact:c", 10).expect("reserve")); + } + let store = open(&dir, bounds); + assert_eq!( + store.reconcile_reservations().expect("reconcile"), + 2, + "a temporary reservation and an artifact reservation without its artifact are released" + ); + assert_eq!( + store.reserved().expect("read"), + 40, + "retained source bytes stay reserved across a restart" + ); + } + + #[test] + fn a_second_owner_cannot_open_the_store() { + let dir = tempfile::tempdir().expect("should make a directory"); + let _owner = open(&dir, Bounds::default()); + let second = Store::open(&dir.path().join("prep.redb"), Bounds::default()); + assert_eq!( + second.expect_err("should refuse a second owner"), + StoreError::AlreadyOwned + ); + } + + #[test] + fn identical_hints_coalesce_and_a_hint_during_processing_schedules_one_follow_up() { + let dir = tempfile::tempdir().expect("should make a directory"); + let store = open(&dir, Bounds::default()); + let page = target("/news/a"); + assert_eq!( + store + .admit(&page, JobOrigin::Signal, &[item("a")], 10) + .expect("admit"), + Admission::Queued { + key: target_key(&page) + } + ); + assert_eq!( + store + .admit(&page, JobOrigin::Signal, &[item("a")], 11) + .expect("admit"), + Admission::Coalesced { + key: target_key(&page) + }, + "an identical hint should coalesce" + ); + let claim = store.claim(12, 7).expect("claim").expect("should claim"); + store + .admit(&page, JobOrigin::Signal, &[item("b")], 13) + .expect("admit"); + assert!(store.complete(&claim.job.key, 7, 14).expect("complete")); + let job = store + .job(&claim.job.key) + .expect("read") + .expect("should keep a follow-up"); + assert!( + matches!(job.state, JobState::Pending { .. }), + "follow-up is pending" + ); + assert!(!job.follow_up, "exactly one follow-up is scheduled"); + assert!( + job.observed.contains(&item("b")), + "the newer observation is kept" + ); + } + + #[test] + fn a_stale_claim_cannot_settle_or_renew_a_newer_one() { + let dir = tempfile::tempdir().expect("should make a directory"); + let store = open(&dir, Bounds::default()); + let page = target("/news/a"); + store.admit(&page, JobOrigin::Warm, &[], 0).expect("admit"); + let first = store.claim(0, 1).expect("claim").expect("should claim"); + assert_eq!( + store.recover_expired(1000).expect("recover"), + 1, + "expired lease returns" + ); + let second = store + .claim(1000, 2) + .expect("claim") + .expect("should reclaim"); + assert!( + !store.complete(&first.job.key, 1, 1001).expect("complete"), + "stale token" + ); + assert!( + !store.renew(&first.job.key, 1, 1001).expect("renew"), + "stale renewal" + ); + assert!( + store.job(&second.job.key).expect("read").is_some(), + "newer claim survives" + ); + assert!(store.complete(&second.job.key, 2, 1002).expect("complete")); + } + + #[test] + fn reader_hints_cannot_take_reserved_capacity() { + let dir = tempfile::tempdir().expect("should make a directory"); + let bounds = Bounds { + max_jobs: 3, + reserved_jobs: 1, + ..Bounds::default() + }; + let store = open(&dir, bounds); + for index in 0..2 { + let admitted = store + .admit( + &target(&format!("/r/{index}")), + JobOrigin::Signal, + &[item("a")], + 0, + ) + .expect("admit"); + assert!(matches!(admitted, Admission::Queued { .. })); + } + assert_eq!( + store + .admit(&target("/r/9"), JobOrigin::Signal, &[item("a")], 0) + .expect("admit"), + Admission::QueueFull, + "a hint should not take the reserved slot" + ); + assert!( + matches!( + store + .admit(&target("/w/1"), JobOrigin::Warm, &[], 0) + .expect("admit"), + Admission::Queued { .. } + ), + "publication work should use the reserved slot" + ); + } + + #[test] + fn damping_is_per_item_and_retirement_survives_restart() { + let dir = tempfile::tempdir().expect("should make a directory"); + let page = target("/news/a"); + { + let store = open(&dir, Bounds::default()); + store + .record_outcome( + &page, + &item("a").identity, + "source_unstable", + OutcomeClass::SourceUnstable, + 100, + ) + .expect("record"); + assert_eq!( + store + .admit(&page, JobOrigin::Signal, &[item("a")], 101) + .expect("admit"), + Admission::Damped, + "the unstable item is damped" + ); + assert!( + matches!( + store + .admit(&page, JobOrigin::Signal, &[item("a"), item("b")], 102) + .expect("admit"), + Admission::Queued { .. } + ), + "its stable sibling still queues" + ); + assert_eq!( + store + .retire(&item("c").identity, "deployment") + .expect("retire"), + 1 + ); + } + let store = open(&dir, Bounds::default()); + assert!( + store + .retirement(&item("c").identity) + .expect("read") + .is_some() + ); + assert_eq!( + store.outbox().expect("outbox").len(), + 1, + "the marker stays queued" + ); + store + .acknowledge_marker(&item("c").identity, 1) + .expect("ack"); + assert!(store.outbox().expect("outbox").is_empty()); + let artifact = Artifact { + envelope: ResultEnvelope { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: "deployment".to_owned(), + identity: item("c").identity, + retirement_epoch: 0, + variant: String::new(), + item: trusted_server_provenance_encypher::preparation::protocol::EnvelopeItem::Text { + request: trusted_server_provenance_encypher::preparation::pipeline::TextSignRequest { + text: "t".to_owned(), + page_url: "https://www.example.com/news/a".to_owned(), + document_title: None, + boundary_selector: "article".to_owned(), + expected_policy_digest: "a".repeat(64), + cache_scope: TextCacheScope::ExactRequest, + }, + idempotency_key: "k".to_owned(), + }, + }, + target: page.clone(), + acknowledged: true, + }; + assert!( + !store.record_artifact(&artifact).expect("record"), + "a late result never outlives its retirement" + ); + } + + #[test] + fn crash_after_artifact_rename_before_metadata_commit_adopts_exact_old_epoch() { + let dir = tempfile::tempdir().expect("should create durable storage"); + let identity = "a".repeat(64); + let artifact = Artifact { + envelope: ResultEnvelope { + protocol: PREPARATION_PROTOCOL.to_owned(), deployment_id: "deployment".to_owned(), + identity: identity.clone(), retirement_epoch: 0, variant: String::new(), + item: trusted_server_provenance_encypher::preparation::protocol::EnvelopeItem::Text { + request: trusted_server_provenance_encypher::preparation::pipeline::TextSignRequest { + text: "durable exact text".to_owned(), page_url: "https://www.example.com/article".to_owned(), + document_title: None, boundary_selector: "article".to_owned(), expected_policy_digest: "b".repeat(64), + cache_scope: TextCacheScope::ExactRequest, + }, + idempotency_key: "exact-operation".to_owned(), + }, + }, + target: target("/article"), acknowledged: false, + }; + { + let store = open(&dir, Bounds::default()); + { + let _guard = store + .objects + .lock() + .expect("should own filesystem mutation"); + store + .reserve_identity(&identity) + .expect("should fund immutable identity"); + store + .write_artifact_file(&identity, &artifact) + .expect("should fsync and rename real artifact"); + // Simulate death before ARTIFACTS metadata insertion, exactly + // between the two durable stages of record_artifact. + } + assert!( + store + .artifact(&identity) + .expect("should lack metadata before recovery") + .is_none() + ); + store + .retire(&"c".repeat(64), "deployment") + .expect("should advance unrelated retirement epoch"); + } + let store = open(&dir, Bounds::default()); + store + .recover_storage() + .expect("should adopt owned orphan after restart"); + let restored = store + .artifact(&identity) + .expect("should read durable result") + .expect("should recover completed artifact"); + assert_eq!( + restored.envelope, artifact.envelope, + "recovery preserves exact operation, bytes and old epoch" + ); + assert_eq!( + store + .retirement_epoch() + .expect("should retain unrelated retirement"), + 1 + ); + assert_eq!(restored.envelope.retirement_epoch, 0); + assert!( + store + .reserved() + .expect("should retain artifact and spent-key charges") + > 2048 + ); + } +} diff --git a/crates/trusted-server-preparation-worker/src/store_files.rs b/crates/trusted-server-preparation-worker/src/store_files.rs new file mode 100644 index 0000000000..bfea5ffa70 --- /dev/null +++ b/crates/trusted-server-preparation-worker/src/store_files.rs @@ -0,0 +1,549 @@ +//! Filesystem ownership of quota-charged objects. All mutations hold `Store::objects`. +use std::collections::HashSet; +use std::fs::{File, OpenOptions}; +use std::io::{Read as _, Write}; +use std::path::Path; + +use redb::{ReadableDatabase as _, ReadableTable as _}; +use serde::Serialize; +use sha2::{Digest as _, Sha256}; + +use super::{ + Artifact, ArtifactFile, PLANS, PROBE_REFERENCES, RETIRED, SOURCES, SourceRecord, Store, + StoreError, TEXT_OPERATIONS, encode, storage, +}; +use trusted_server_provenance_encypher::preparation::protocol::is_digest; + +struct Measure { + bytes: u64, + hash: Sha256, +} + +impl Default for Measure { + fn default() -> Self { + Self { + bytes: 0, + hash: Sha256::new(), + } + } +} + +impl Write for Measure { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.bytes = self + .bytes + .checked_add(bytes.len() as u64) + .ok_or_else(|| std::io::Error::other("artifact size overflow"))?; + self.hash.update(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn hash_file(path: &Path) -> Result<(u64, String), StoreError> { + let mut input = File::open(path).map_err(storage)?; + let mut measure = Measure::default(); + let mut buffer = [0; 64 * 1024]; + loop { + let read = input.read(&mut buffer).map_err(storage)?; + if read == 0 { + break; + } + measure.write_all(&buffer[..read]).map_err(storage)?; + } + Ok((measure.bytes, crate::hex_lower(&measure.hash.finalize()))) +} + +fn sync_directory(path: &Path) -> Result<(), StoreError> { + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(storage) +} + +impl Store { + fn object_path(&self, kind: &str, digest: &str) -> Result { + if !is_digest(digest) || !matches!(kind, "source" | "probe" | "artifact" | "text") { + return Err(StoreError::Storage("invalid object identity".to_owned())); + } + let (directory, suffix) = match kind { + "source" => ("sources", ""), + "probe" => ("probes", ".wasm"), + "text" => ("texts", ".json"), + _ => ("artifacts", ".json"), + }; + Ok(self.root.join(directory).join(format!("{digest}{suffix}"))) + } + + /// Keeps digest-addressed source or probe bytes, with a durable hard reservation. + /// + /// # Errors + /// Refuses invalid bytes, quota exhaustion or filesystem failure. + pub fn keep_blob(&self, kind: &str, digest: &str, bytes: &[u8]) -> Result<(), StoreError> { + let _guard = self.objects.lock().map_err(storage)?; + self.keep_blob_locked(kind, digest, bytes) + } + + fn keep_blob_locked(&self, kind: &str, digest: &str, bytes: &[u8]) -> Result<(), StoreError> { + let path = self.object_path(kind, digest)?; + if crate::hex_lower(&Sha256::digest(bytes)) != digest { + return Err(StoreError::Storage("object digest mismatch".to_owned())); + } + self.write_object(kind, digest, &path, bytes.len() as u64, |file| { + file.write_all(bytes).map_err(storage) + }) + } + + /// Keeps public source bytes and their owner reference under one retirement guard. + /// + /// # Errors + /// A retired owner, invalid source, full quota or storage failure. + pub fn keep_source( + &self, + digest: &str, + mime: &str, + bytes: &[u8], + identity: &str, + ) -> Result<(), StoreError> { + let _guard = self.objects.lock().map_err(storage)?; + if self.retirement(identity)?.is_some() { + return Err(storage("source owner retired")); + } + self.reserve_identity(identity)?; + self.keep_blob_locked("source", digest, bytes)?; + self.record_source_locked(digest, mime, true, identity) + } + + /// Reads and checks a retained source or probe object without releasing its charge on error. + /// + /// # Errors + /// Missing/corrupt files and invalid identities refuse explicitly. + pub fn blob(&self, kind: &str, digest: &str) -> Result, StoreError> { + let _guard = self.objects.lock().map_err(storage)?; + let path = self.object_path(kind, digest)?; + let bytes = std::fs::read(path).map_err(storage)?; + if crate::hex_lower(&Sha256::digest(&bytes)) != digest { + return Err(StoreError::Storage( + "retained object digest mismatch".to_owned(), + )); + } + Ok(bytes) + } + + fn write_object( + &self, + kind: &str, + identity: &str, + path: &Path, + bytes: u64, + write: impl FnOnce(&mut File) -> Result<(), StoreError>, + ) -> Result<(), StoreError> { + let owner = format!("{kind}:{identity}"); + if !self.reserve(&owner, bytes)? { + return Err(StoreError::Quota); + } + let parent = path + .parent() + .ok_or_else(|| storage("missing object directory"))?; + std::fs::create_dir_all(parent).map_err(storage)?; + // A second writer never creates a second copy of an already valid object. + if matches!(kind, "source" | "probe") + && path.exists() + && hash_file(path)? == (bytes, identity.to_owned()) + { + return Ok(()); + } + // A replacement must charge both the old file and its temporary replacement. + let temporary_owner = format!("temp:replace:{kind}:{identity}"); + let replacement = path.exists(); + if replacement && !self.reserve(&temporary_owner, bytes)? { + return Err(StoreError::Quota); + } + let pending = path.with_extension("pending"); + let result = (|| { + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&pending) + .map_err(storage)?; + write(&mut file)?; + file.sync_all().map_err(storage)?; + std::fs::rename(&pending, path).map_err(storage)?; + sync_directory(parent) + })(); + if result.is_err() && pending.exists() { + // Do not release accounting until removal is confirmed. + std::fs::remove_file(&pending).map_err(storage)?; + sync_directory(parent)?; + } + if replacement { + self.release(&temporary_owner)?; + } + result + } + + pub(super) fn write_artifact_file( + &self, + identity: &str, + artifact: &Artifact, + ) -> Result { + self.write_json_file("artifact", identity, artifact, artifact.acknowledged) + } + + pub(super) fn write_text_file( + &self, + identity: &str, + request: &trusted_server_provenance_encypher::preparation::pipeline::TextSignRequest, + ) -> Result { + self.write_json_file("text", identity, request, false) + } + + fn write_json_file( + &self, + kind: &str, + identity: &str, + value: &T, + acknowledged: bool, + ) -> Result { + let mut measure = Measure::default(); + serde_json::to_writer(&mut measure, value).map_err(storage)?; + let record = ArtifactFile { + bytes: measure.bytes, + sha256: crate::hex_lower(&measure.hash.finalize()), + acknowledged, + }; + let path = self.object_path(kind, identity)?; + if path.exists() { + let (bytes, digest) = hash_file(&path)?; + if bytes != record.bytes || digest != record.sha256 { + return Err(storage("immutable artifact conflict")); + } + if !self.reserve( + &format!("{kind}:{identity}"), + record.bytes + encode(&record)?.len() as u64, + )? { + return Err(StoreError::Quota); + } + return Ok(record); + } + self.write_object( + kind, + identity, + &path, + record.bytes + encode(&record)?.len() as u64, + |file| serde_json::to_writer(file, value).map_err(storage), + )?; + Ok(record) + } + + pub(super) fn read_artifact_file( + &self, + identity: &str, + record: &ArtifactFile, + ) -> Result { + let path = self.object_path("artifact", identity)?; + if hash_file(&path)? != (record.bytes, record.sha256.clone()) { + return Err(storage("retained artifact digest mismatch")); + } + let mut artifact: Artifact = + serde_json::from_reader(File::open(path).map_err(storage)?).map_err(storage)?; + if artifact.envelope.identity != identity { + return Err(storage("retained artifact identity mismatch")); + } + artifact.acknowledged = record.acknowledged; + Ok(artifact) + } + + /// Removes crash-only temporary files before releasing their ownership records. + /// Retained objects, including probes written by the earlier unmetered cache, + /// are measured and charged before the worker can report ready. + /// + /// # Errors + /// Any unreadable directory, unsafe entry, deletion failure or existing quota excess blocks startup. + pub fn recover_storage(&self) -> Result<(), StoreError> { + let _guard = self.objects.lock().map_err(storage)?; + // A committed retirement remains authoritative if file cleanup was interrupted. + for (identity, _) in self.read_all::(RETIRED)? { + self.remove_artifact_file(&identity)?; + self.remove_text_file(&identity)?; + self.reserve_identity(&identity)?; + } + // No task survives exclusive-owner restart. References without a + // persisted plan/result never reached finalize and can be reacquired. + let tx = self.db.begin_write().map_err(storage)?; + { + let mut sources = tx.open_table(SOURCES).map_err(storage)?; + let mut changed = Vec::new(); + for entry in sources.iter().map_err(storage)? { + let (sha, value) = entry.map_err(storage)?; + let mut source: SourceRecord = super::decode(value.value())?; + let mut kept = Vec::new(); + for identity in &source.references { + if self.has_reconstruction(identity)? { + kept.push(identity.clone()); + } + } + source.references = kept; + source.public &= !source.references.is_empty(); + changed.push((sha.value().to_owned(), source)); + } + for (sha, source) in changed { + sources + .insert(sha.as_str(), encode(&source)?.as_slice()) + .map_err(storage)?; + } + } + tx.commit().map_err(storage)?; + self.remove_unreferenced_sources()?; + let mut planned_sources = HashSet::new(); + { + let tx = self.db.begin_read().map_err(storage)?; + let plans = tx.open_table(PLANS).map_err(storage)?; + for entry in plans.iter().map_err(storage)? { + let (_, value) = entry.map_err(storage)?; + let plan: trusted_server_provenance_encypher::preparation::pipeline::PlannedMedia = + super::decode(value.value())?; + planned_sources.insert(plan.request.source.sha256); + } + } + let directories = [ + ("sources", "source", ""), + ("probes", "probe", ".wasm"), + ("artifacts", "artifact", ".json"), + ("texts", "text", ".json"), + ("large", "temp", ""), + ]; + // Remove owned crash-only objects before releasing their durable reservations. + for (directory, kind, _) in directories { + let dir = self.root.join(directory); + std::fs::create_dir_all(&dir).map_err(storage)?; + let metadata = std::fs::symlink_metadata(&dir).map_err(storage)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(storage("unsafe object directory")); + } + for entry in std::fs::read_dir(&dir).map_err(storage)? { + let entry = entry.map_err(storage)?; + if !entry.file_type().map_err(storage)?.is_file() { + return Err(storage("unsafe object entry")); + } + let name = entry + .file_name() + .into_string() + .map_err(|_| storage("invalid object filename"))?; + if directory == "large" { + if self.reservation(&format!("temp:large:{name}"))?.is_none() + && self.reservation(&format!("temp:{name}"))?.is_none() + { + return Err(storage("temporary source has no durable owner")); + } + std::fs::remove_file(entry.path()).map_err(storage)?; + } else if name.ends_with(".pending") || name.contains(".tmp-") { + let digest = name + .split('.') + .next() + .ok_or_else(|| storage("invalid temporary object"))?; + if !is_digest(digest) + || self.reservation(&format!("{kind}:{digest}"))?.is_none() + { + return Err(storage("temporary object has no durable owner")); + } + std::fs::remove_file(entry.path()).map_err(storage)?; + } else if directory == "sources" + && is_digest(&name) + && self.source(&name)?.is_none() + && !planned_sources.contains(&name) + { + std::fs::remove_file(entry.path()).map_err(storage)?; + sync_directory(&dir)?; + self.release(&format!("source:{name}"))?; + } else if directory == "texts" { + let identity = name + .strip_suffix(".json") + .ok_or_else(|| storage("invalid text filename"))?; + if !is_digest(identity) { + return Err(storage("invalid text filename")); + } + // Without the metadata commit, begin_text never returned and + // this owner could not have sent the signing request. + if self + .read_value::(TEXT_OPERATIONS, identity)? + .is_none() + { + std::fs::remove_file(entry.path()).map_err(storage)?; + sync_directory(&dir)?; + self.release(&format!("text:{identity}"))?; + } + } + } + sync_directory(&dir)?; + } + self.reconcile_reservations()?; + // Historical unmetered probes and every retained filesystem object are charged. + for (directory, kind, suffix) in directories { + if directory == "large" { + continue; + } + for entry in std::fs::read_dir(self.root.join(directory)).map_err(storage)? { + let entry = entry.map_err(storage)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| storage("invalid object filename"))?; + let digest = name + .strip_suffix(suffix) + .ok_or_else(|| storage("unknown object filename"))?; + if !is_digest(digest) { + return Err(storage("unknown object filename")); + } + if !self.reserve( + &format!("{kind}:{digest}"), + entry.metadata().map_err(storage)?.len(), + )? { + return Err(StoreError::Quota); + } + } + } + for (kind, table) in [ + ("plan", PLANS), + ("source-record", SOURCES), + ("probe-record", PROBE_REFERENCES), + ("inventory", super::INVENTORY), + ("outcome", super::OUTCOMES), + ] { + for (identity, value) in self.read_all::(table)? { + if kind == "plan" { + self.reserve_identity(&identity)?; + } + let bytes = encode(&value)?.len() as u64 + + if kind == "outcome" { + identity.len() as u64 + } else { + 0 + }; + if !self.reserve(&format!("{kind}:{identity}"), bytes)? { + return Err(StoreError::Quota); + } + } + } + // A crash may follow fsync+rename but precede the metadata transaction. + // Adopt that exact object instead of rebuilding it under a later + // retirement epoch or asking the API to sign it again. + for entry in std::fs::read_dir(self.root.join("artifacts")).map_err(storage)? { + let entry = entry.map_err(storage)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| storage("invalid artifact filename"))?; + let identity = name + .strip_suffix(".json") + .ok_or_else(|| storage("invalid artifact filename"))?; + if self + .read_value::(super::ARTIFACTS, identity)? + .is_some() + { + continue; + } + let (bytes, sha256) = hash_file(&entry.path())?; + let file = ArtifactFile { + bytes, + sha256, + acknowledged: false, + }; + let artifact = self.read_artifact_file(identity, &file)?; + if artifact.envelope.protocol + != trusted_server_provenance_encypher::preparation::protocol::PREPARATION_PROTOCOL + || !artifact.target.is_well_formed() + || self.retirement(identity)?.is_some() + { + return Err(storage("orphan artifact identity invalid")); + } + if !self.reserve( + &format!("artifact:{identity}"), + bytes + encode(&file)?.len() as u64, + )? { + return Err(StoreError::Quota); + } + self.put(super::ARTIFACTS, identity, &file)?; + } + for (identity, file) in self.read_all::(super::ARTIFACTS)? { + self.read_artifact_file(&identity, &file)?; + self.reserve_identity(&identity)?; + if !self.reserve( + &format!("artifact:{identity}"), + file.bytes + encode(&file)?.len() as u64, + )? { + return Err(StoreError::Quota); + } + } + for (identity, file) in self.read_all::(TEXT_OPERATIONS)? { + self.reserve_identity(&identity)?; + if hash_file(&self.object_path("text", &identity)?)? + != (file.bytes, file.sha256.clone()) + { + return Err(storage("retained text operation digest mismatch")); + } + if !self.reserve( + &format!("text:{identity}"), + file.bytes + encode(&file)?.len() as u64, + )? { + return Err(StoreError::Quota); + } + } + for (sha, source) in self.read_all::(SOURCES)? { + if !source.references.is_empty() { + let path = self.object_path("source", &sha)?; + if hash_file(&path)?.1 != sha { + return Err(storage("retained public source corrupt")); + } + } + } + Ok(()) + } + + pub(super) fn remove_artifact_file(&self, identity: &str) -> Result<(), StoreError> { + let path = self.object_path("artifact", identity)?; + match std::fs::remove_file(&path) { + Ok(()) => sync_directory( + path.parent() + .ok_or_else(|| storage("missing artifact directory"))?, + )?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(storage(error)), + } + self.release(&format!("artifact:{identity}")) + } + + pub(super) fn remove_text_file(&self, identity: &str) -> Result<(), StoreError> { + let path = self.object_path("text", identity)?; + match std::fs::remove_file(&path) { + Ok(()) => sync_directory( + path.parent() + .ok_or_else(|| storage("missing text directory"))?, + )?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(storage(error)), + } + self.release(&format!("text:{identity}")) + } + + pub(super) fn remove_unreferenced_sources(&self) -> Result<(), StoreError> { + for (sha, source) in self.read_all::(SOURCES)? { + if !source.references.is_empty() { + continue; + } + let path = self.object_path("source", &sha)?; + match std::fs::remove_file(&path) { + Ok(()) => sync_directory( + path.parent() + .ok_or_else(|| storage("missing source directory"))?, + )?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(storage(error)), + } + self.release(&format!("source:{sha}"))?; + // Keep the tiny nonpublic record as a durable admission tombstone. + } + Ok(()) + } +} diff --git a/crates/trusted-server-preparation-worker/src/store_maintenance.rs b/crates/trusted-server-preparation-worker/src/store_maintenance.rs new file mode 100644 index 0000000000..11474df695 --- /dev/null +++ b/crates/trusted-server-preparation-worker/src/store_maintenance.rs @@ -0,0 +1,81 @@ +//! Bounded maintenance of quota-charged negative outcomes. +use std::collections::BTreeMap; + +use redb::{ReadableDatabase as _, ReadableTable as _}; + +use super::{ + META, OUTCOMES, Outcome, OutcomeClass, QUOTA, Store, StoreError, decode, encode, storage, +}; + +impl Store { + /// Reclaims at most 256 expired outcomes in a cursor-bounded scan. + /// Terminal outcomes and retirement tombstones are never time-evicted. + /// + /// # Errors + /// A failed store transaction. Accounting is released with row deletion. + pub fn prune_expired_outcomes(&self, now: u64) -> Result { + let tx = self.db.begin_write().map_err(storage)?; + let cursor: String = tx + .open_table(META) + .map_err(storage)? + .get("outcome_prune_cursor") + .map_err(storage)? + .map(|value| decode(value.value())) + .transpose()? + .unwrap_or_default(); + let mut next = String::new(); + let mut stale = Vec::new(); + { + let mut outcomes = tx.open_table(OUTCOMES).map_err(storage)?; + let mut scanned = 0; + let mut last = String::new(); + for entry in outcomes.range(cursor.as_str()..).map_err(storage)? { + let (key, value) = entry.map_err(storage)?; + if key.value() <= cursor.as_str() { + continue; + } + if scanned == 256 { + next = last; + break; + } + last = key.value().to_owned(); + scanned += 1; + let outcome: Outcome = decode(value.value())?; + if outcome.class != OutcomeClass::Terminal && outcome.until <= now { + stale.push(key.value().to_owned()); + } + } + for key in &stale { + outcomes.remove(key.as_str()).map_err(storage)?; + tx.open_table(QUOTA) + .map_err(storage)? + .remove(format!("outcome:{key}").as_str()) + .map_err(storage)?; + } + } + tx.open_table(META) + .map_err(storage)? + .insert("outcome_prune_cursor", encode(&next)?.as_slice()) + .map_err(storage)?; + tx.commit().map_err(storage)?; + Ok(stale.len()) + } + + /// Counts current per-item/target refusals by stable code for authenticated status. + /// + /// # Errors + /// A failed store read. + pub fn active_outcome_counts(&self, now: u64) -> Result, StoreError> { + let tx = self.db.begin_read().map_err(storage)?; + let table = tx.open_table(OUTCOMES).map_err(storage)?; + let mut counts = BTreeMap::new(); + for entry in table.iter().map_err(storage)? { + let (_, value) = entry.map_err(storage)?; + let outcome: Outcome = decode(value.value())?; + if outcome.until > now { + *counts.entry(outcome.code).or_default() += 1; + } + } + Ok(counts) + } +} diff --git a/crates/trusted-server-preparation-worker/tests/durable_storage.rs b/crates/trusted-server-preparation-worker/tests/durable_storage.rs new file mode 100644 index 0000000000..232471ae44 --- /dev/null +++ b/crates/trusted-server-preparation-worker/tests/durable_storage.rs @@ -0,0 +1,488 @@ +//! Real redb/filesystem scenarios. Probe bytes here exercise storage, not probe execution. +use std::sync::{Arc, Barrier}; + +use bytes::Bytes; +use sha2::{Digest as _, Sha256}; +use trusted_server_preparation_worker::client::DiskProbeCache; +use trusted_server_preparation_worker::store::{Bounds, Store}; +use trusted_server_provenance_encypher::preparation::pipeline::{ + ProbeArtifactCache, TextCacheScope, TextSignRequest, +}; + +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[test] +fn four_tasks_cannot_overcommit_probe_files_and_restart_keeps_their_charge() { + let dir = tempfile::tempdir().expect("should create storage"); + let probe = Bytes::from( + std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../provenance/encypher/src/fixtures/prepared/probe.wasm"), + ) + .expect("should read the common real probe"), + ); + let probe_bytes = probe.len() as u64; + let bounds = Bounds { + quota_bytes: probe_bytes + 300, + ..Bounds::default() + }; + { + let store = Arc::new( + Store::open(&dir.path().join("preparation.redb"), bounds).expect("should open"), + ); + let barrier = Arc::new(Barrier::new(4)); + let tasks: Vec<_> = (0..4) + .map(|index| { + let store = Arc::clone(&store); + let barrier = Arc::clone(&barrier); + let bytes = probe.clone(); + std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("should build runtime"); + let cache = DiskProbeCache::new(Arc::clone(&store)); + let sha = digest(&bytes); + runtime + .block_on(cache.put(&sha, bytes)) + .expect("should retain shared real probe"); + barrier.wait(); + store + .reserve(&format!("temp:task:{index}"), 100) + .expect("should reserve shared capacity") + }) + }) + .collect(); + let admitted = tasks + .into_iter() + .map(|task| task.join().expect("should join")) + .filter(|admitted| *admitted) + .count(); + assert_eq!( + admitted, 3, + "real probe plus three task budgets exactly reaches the quota; the fourth refuses" + ); + assert_eq!(store.reserved().expect("should count"), probe_bytes + 300); + assert!(!store.reserve("temp:fourth", 1).expect("should reserve")); + } + let store = Store::open(&dir.path().join("preparation.redb"), bounds).expect("should reopen"); + store + .recover_storage() + .expect("should reconcile real files"); + assert_eq!( + store.reserved().expect("should count"), + probe_bytes, + "restart reclaims dead task budgets, never the retained real probe" + ); +} + +#[tokio::test] +async fn real_probe_cache_refuses_before_writing_when_its_durable_reservation_cannot_fit() { + let dir = tempfile::tempdir().expect("should create storage"); + let probe = Bytes::from( + std::fs::read( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../provenance/encypher/src/fixtures/prepared/probe.wasm"), + ) + .expect("should read common real probe"), + ); + let sha = digest(&probe); + let store = Arc::new( + Store::open( + &dir.path().join("preparation.redb"), + Bounds { + quota_bytes: probe.len() as u64 - 1, + ..Bounds::default() + }, + ) + .expect("should open"), + ); + let cache = DiskProbeCache::new(Arc::clone(&store)); + let failure = cache + .put(&sha, probe) + .await + .expect_err("should refuse unreserved probe retention"); + assert_eq!(failure.code, "quota_exhausted"); + assert!(cache.get(&sha).await.is_none()); + assert_eq!(store.reserved().expect("should count"), 0); +} + +#[tokio::test] +async fn duplicate_probe_writes_are_charged_once_and_corrupt_cache_is_not_returned() { + let dir = tempfile::tempdir().expect("should create storage"); + let store = Arc::new( + Store::open( + &dir.path().join("preparation.redb"), + Bounds { + quota_bytes: 10, + ..Bounds::default() + }, + ) + .expect("should open"), + ); + let cache = DiskProbeCache::new(Arc::clone(&store)); + let bytes = Bytes::from_static(b"probe-data"); + let sha = digest(&bytes); + cache + .put(&sha, bytes.clone()) + .await + .expect("should retain probe"); + cache + .put(&sha, bytes.clone()) + .await + .expect("should reuse probe"); + assert_eq!(cache.get(&sha).await, Some(bytes)); + assert_eq!(store.reserved().expect("should count"), 10); + std::fs::write( + dir.path().join("probes").join(format!("{sha}.wasm")), + b"tampered!!", + ) + .expect("should tamper"); + assert!( + cache.get(&sha).await.is_none(), + "digest-addressed files must verify on read" + ); + assert_eq!( + store.reserved().expect("should count"), + 10, + "corruption does not release a file's charge" + ); +} + +#[test] +fn restart_accounts_legacy_unreserved_probe_and_preserves_overquota_evidence() { + let dir = tempfile::tempdir().expect("should create storage"); + std::fs::create_dir(dir.path().join("probes")).expect("should create probes"); + let bytes = b"retained probe"; + let path = dir + .path() + .join("probes") + .join(format!("{}.wasm", digest(bytes))); + std::fs::write(&path, bytes).expect("should write legacy probe"); + let store = Store::open( + &dir.path().join("preparation.redb"), + Bounds { + quota_bytes: 5, + ..Bounds::default() + }, + ) + .expect("should open"); + assert!( + store.recover_storage().is_err(), + "cannot report ready with historical bytes over quota" + ); + assert_eq!( + std::fs::read(path).expect("should preserve retained file"), + bytes + ); +} + +#[test] +fn restart_releases_temporary_ownership_only_after_removing_its_actual_file() { + let dir = tempfile::tempdir().expect("should create storage"); + let bounds = Bounds { + quota_bytes: 100, + ..Bounds::default() + }; + { + let store = Store::open(&dir.path().join("preparation.redb"), bounds).expect("should open"); + assert!( + store + .reserve("temp:large:dead-claim", 80) + .expect("should reserve") + ); + std::fs::create_dir(dir.path().join("large")).expect("should create temporary directory"); + std::fs::write(dir.path().join("large/dead-claim"), [0; 80]) + .expect("should write temporary source"); + } + let store = Store::open(&dir.path().join("preparation.redb"), bounds).expect("should reopen"); + store + .recover_storage() + .expect("should reclaim inactive temporary source"); + assert!(!dir.path().join("large/dead-claim").exists()); + assert_eq!(store.reserved().expect("should count"), 0); + assert!( + store + .reserve("temp:new", 100) + .expect("should reserve reclaimed capacity") + ); +} + +#[test] +fn restart_refuses_legacy_or_unknown_text_scope_in_retained_publication() { + for scope in [None, Some(serde_json::json!("legacy"))] { + let dir = tempfile::tempdir().expect("should create storage"); + let path = dir.path().join("preparation.redb"); + let identity = "a".repeat(64); + let mut artifact = serde_json::json!({ + "envelope": { + "protocol": "encypher-preparation/1", + "deployment_id": "deployment", + "identity": identity, + "retirement_epoch": 0, + "variant": "", + "item": { + "kind": "text", + "request": { + "text": "A retained article", + "page_url": "https://www.example.com/article", + "document_title": "Harbor", + "boundary_selector": "article", + "expected_policy_digest": "b".repeat(64), + }, + "idempotency_key": "retained-operation", + }, + }, + "target": {"path": "/article", "variant": ""}, + "acknowledged": false, + }); + if let Some(scope) = scope { + artifact["envelope"]["item"]["request"]["cache_scope"] = scope; + } + { + let store = Store::open(&path, Bounds::default()).expect("should open storage"); + store + .recover_storage() + .expect("should initialize object directories"); + std::fs::write( + dir.path() + .join("artifacts") + .join(format!("{identity}.json")), + serde_json::to_vec(&artifact).expect("should encode the retained publication"), + ) + .expect("should retain a pre-cutover artifact at the filesystem boundary"); + } + let store = Store::open(&path, Bounds::default()).expect("should reopen storage"); + assert!( + store.recover_storage().is_err(), + "unsupported scope must block recovery, never adopt a legacy publication" + ); + assert!( + store + .artifact(&identity) + .expect("should read metadata") + .is_none(), + "unsupported text must not become a publishable retained result" + ); + } +} + +#[test] +fn large_text_attempt_is_streamed_retained_and_retired_without_losing_its_spent_key() { + let dir = tempfile::tempdir().expect("should create storage"); + let bounds = Bounds { + quota_bytes: 3 * 1024 * 1024, + ..Bounds::default() + }; + let identity = "a".repeat(64); + let request = TextSignRequest { + text: "a".repeat(2 * 1024 * 1024), + page_url: "https://www.example.com/article".to_owned(), + document_title: None, + boundary_selector: "article".to_owned(), + expected_policy_digest: "b".repeat(64), + cache_scope: TextCacheScope::ExactRequest, + }; + { + let store = + Store::open(&dir.path().join("preparation.redb"), bounds).expect("should own store"); + assert!( + store + .begin_text(&identity, &request) + .expect("should persist before first signing attempt") + ); + let path = dir.path().join("texts").join(format!("{identity}.json")); + let persisted: serde_json::Value = serde_json::from_slice( + &std::fs::read(&path).expect("should retain the exact text request"), + ) + .expect("should store a JSON request"); + assert_eq!(persisted["cache_scope"], "exact_request"); + assert_eq!( + serde_json::from_value::(persisted) + .expect("should recover the current cache contract"), + request + ); + assert!( + std::fs::metadata(path) + .expect("should stream a filesystem object") + .len() + > request.text.len() as u64 + ); + assert!( + store.reserved().expect("should charge retained input") > request.text.len() as u64 + ); + } + let store = Store::open(&dir.path().join("preparation.redb"), bounds).expect("should reopen"); + store + .recover_storage() + .expect("should retain reconstruction inputs across restart"); + assert!( + !store + .begin_text(&identity, &request) + .expect("should require non-signing recovery for an existing attempt") + ); + let mut substituted = request.clone(); + substituted.text.push('x'); + assert!( + store.begin_text(&identity, &substituted).is_err(), + "one identity cannot change its frozen request" + ); + let epoch = store + .retire(&identity, "deployment") + .expect("should retire through durable owner"); + assert!( + !dir.path() + .join("texts") + .join(format!("{identity}.json")) + .exists() + ); + assert_eq!( + store + .reserved() + .expect("should keep funded spent-key material"), + 1024 + ); + let marker = store + .outbox() + .expect("should commit delayed marker with spent key") + .pop() + .expect("should retain marker"); + assert_eq!(marker.epoch, epoch); + drop(store); + let store = Store::open(&dir.path().join("preparation.redb"), bounds) + .expect("should reopen after retirement"); + store + .recover_storage() + .expect("should preserve retirement while reconciling real files"); + assert_eq!( + store + .reserved() + .expect("should preserve funded spent key across restart"), + 1024 + ); + assert_eq!( + store + .outbox() + .expect("should retain pending marker across restart"), + vec![marker] + ); + let retirement = store + .retirement(&identity) + .expect("should read spent key") + .expect("should preserve tombstone"); + assert_eq!(retirement.epoch, epoch); + assert!(!retirement.acknowledged); + assert!( + store.begin_text(&identity, &request).is_err(), + "retirement must refuse the original signing request after restart" + ); + assert!( + store.begin_text(&identity, &substituted).is_err(), + "a changed request must not reuse the retired operation identity" + ); + assert!( + !dir.path() + .join("texts") + .join(format!("{identity}.json")) + .exists(), + "refused resurrection must not recreate the retained request" + ); + assert_eq!( + store + .reserved() + .expect("should not allocate a fresh signing operation"), + 1024 + ); +} + +#[test] +fn ambiguous_temporary_file_blocks_cleanup_without_releasing_any_reservation() { + let dir = tempfile::tempdir().expect("should create storage"); + let store = Store::open( + &dir.path().join("preparation.redb"), + Bounds { + quota_bytes: 100, + ..Bounds::default() + }, + ) + .expect("should open"); + store + .reserve("temp:known", 80) + .expect("should reserve known ownership"); + std::fs::create_dir(dir.path().join("large")).expect("should create temporary directory"); + let path = dir.path().join("large/ambiguous"); + std::fs::write(&path, b"unowned evidence").expect("should create uncertain crash evidence"); + assert!( + store.recover_storage().is_err(), + "unowned material must not be deleted to manufacture quota" + ); + assert!(path.exists()); + assert_eq!( + store + .reserved() + .expect("should preserve ownership accounting"), + 80 + ); +} + +#[test] +fn expired_negative_outcomes_release_their_charge_but_terminal_outcomes_do_not_time_expire() { + let dir = tempfile::tempdir().expect("should create storage"); + let store = + Store::open(&dir.path().join("preparation.redb"), Bounds::default()).expect("should open"); + let target = trusted_server_provenance_encypher::preparation::protocol::Target { + path: "/article".to_owned(), + variant: String::new(), + }; + store + .record_outcome( + &target, + &"a".repeat(64), + "source_unstable", + trusted_server_preparation_worker::store::OutcomeClass::SourceUnstable, + 0, + ) + .expect("should retain bounded negative state"); + store + .record_outcome( + &target, + &"b".repeat(64), + "source_private", + trusted_server_preparation_worker::store::OutcomeClass::Terminal, + 0, + ) + .expect("should retain terminal state"); + let before = store + .reserved() + .expect("should charge all retained outcome bytes"); + assert_eq!( + store + .prune_expired_outcomes(301) + .expect("should reclaim only expired state"), + 1 + ); + assert!( + store + .reserved() + .expect("should release expired bytes atomically") + < before + ); + assert_eq!( + store + .active_outcome_counts(301) + .expect("should expose refusal reasons") + .get("source_private"), + Some(&1) + ); + assert!( + store + .outcome(&target, &"b".repeat(64)) + .expect("should preserve terminal suppression") + .is_some() + ); +} diff --git a/crates/trusted-server-preparation-worker/tests/queue_lifecycle.rs b/crates/trusted-server-preparation-worker/tests/queue_lifecycle.rs new file mode 100644 index 0000000000..0e433516eb --- /dev/null +++ b/crates/trusted-server-preparation-worker/tests/queue_lifecycle.rs @@ -0,0 +1,808 @@ +//! Transactional scenarios use the real owner, durable queue and filesystem. +use std::sync::{Arc, Barrier}; + +use trusted_server_preparation_worker::store::{ + Admission, Artifact, Bounds, JobOrigin, JobState, OutcomeClass, Store, StoreError, target_key, +}; +use trusted_server_provenance_encypher::preparation::pipeline::{TextCacheScope, TextSignRequest}; +use trusted_server_provenance_encypher::preparation::protocol::{ + EnvelopeItem, ItemKind, ObservedItem, PREPARATION_PROTOCOL, ResultEnvelope, Target, +}; + +fn target(index: usize) -> Target { + Target { + path: format!("/article/{index:04}"), + variant: String::new(), + } +} +fn item(byte: char) -> ObservedItem { + ObservedItem { + identity: byte.to_string().repeat(64), + kind: ItemKind::Text, + } +} + +#[test] +fn identical_processing_hint_does_not_schedule_signing_churn_and_latest_replaces_history() { + let dir = tempfile::tempdir().expect("should create store"); + let store = Store::open(&dir.path().join("preparation.redb"), Bounds::default()) + .expect("should own store"); + let target = target(1); + store + .admit(&target, JobOrigin::Signal, &[item('a')], 1) + .expect("should admit"); + let first = store + .claim(1, 10) + .expect("should claim") + .expect("should have work"); + store + .admit(&target, JobOrigin::Signal, &[item('a')], 2) + .expect("should coalesce duplicate"); + assert!( + !store + .job(&first.job.key) + .expect("should read") + .expect("should remain claimed") + .follow_up + ); + for byte in ['b', 'c', 'd'] { + store + .admit(&target, JobOrigin::Signal, &[item(byte)], 3) + .expect("should replace desired generation"); + } + let desired = store + .job(&first.job.key) + .expect("should read") + .expect("should retain desired state"); + assert_eq!( + desired.observed, + vec![item('d')], + "one latest desired observation, not history that fills 64 slots" + ); + assert_eq!( + desired.generation, + first.job.generation + 3, + "each changed observation advances the desired version" + ); + store + .retry(&first.job.key, first.token, 4, "old_failure") + .expect("should settle old claim"); + let pending = store + .job(&first.job.key) + .expect("should read pending follow-up") + .expect("should preserve latest generation"); + assert!( + !store + .complete(&first.job.key, first.token, 4) + .expect("should refuse duplicate old completion") + ); + assert_eq!( + store + .job(&first.job.key) + .expect("should read after stale completion"), + Some(pending), + "a duplicate old success cannot clear the desired pending version" + ); + let next = store + .claim(4, 11) + .expect("should claim immediately") + .expect("should keep follow-up despite old failure"); + assert_eq!(next.job.observed, vec![item('d')]); + assert_eq!(next.job.generation, desired.generation); + assert_eq!(next.job.attempts, 1); + assert!( + !store + .complete(&first.job.key, first.token, 5) + .expect("should refuse stale completion") + ); + assert!( + store + .complete(&next.job.key, next.token, 5) + .expect("should settle latest generation") + ); +} + +#[test] +fn exhausted_old_claim_cannot_remove_new_desired_generation() { + let dir = tempfile::tempdir().expect("should create store"); + let store = Store::open( + &dir.path().join("preparation.redb"), + Bounds { + max_attempts: 1, + ..Bounds::default() + }, + ) + .expect("should open"); + let page = target(1); + store + .admit(&page, JobOrigin::Signal, &[item('a')], 0) + .expect("should admit"); + let first = store + .claim(0, 1) + .expect("should claim") + .expect("should have work"); + store + .admit(&page, JobOrigin::Signal, &[item('b')], 1) + .expect("should retain desired change"); + store + .retry(&first.job.key, first.token, 2, "offline") + .expect("should settle old attempt"); + let latest = store + .claim(2, 2) + .expect("should claim") + .expect("should keep latest despite exhausted old attempt"); + assert_eq!(latest.job.observed, vec![item('b')]); + assert!( + store + .outcome(&page, &item('b').identity) + .expect("should read") + .is_none() + ); +} + +#[test] +fn coalescing_cannot_exceed_the_metadata_reservation() { + let dir = tempfile::tempdir().expect("should create store"); + let bounds = Bounds { + max_queue_bytes: 1400, + reserved_bytes: 600, + max_jobs: 3, + reserved_jobs: 1, + ..Bounds::default() + }; + let store = Store::open(&dir.path().join("preparation.redb"), bounds).expect("should open"); + let page = target(1); + assert!(matches!( + store + .admit(&page, JobOrigin::Signal, &[item('a')], 0) + .expect("should admit"), + Admission::Queued { .. } + )); + let large: Vec<_> = ('a'..='f').map(item).collect(); + assert_eq!( + store + .admit(&page, JobOrigin::Signal, &large, 1) + .expect("should bound replacement"), + Admission::QueueFull + ); + assert_eq!( + store + .job(&target_key(&page)) + .expect("should read") + .expect("should preserve old job") + .observed, + vec![item('a')] + ); + assert!(matches!( + store + .admit(&target(2), JobOrigin::Warm, &[], 1) + .expect("should preserve publisher reservation"), + Admission::Queued { .. } + )); +} + +#[test] +fn concurrent_inventory_registration_never_silently_exceeds_or_partially_claims_capacity() { + let dir = tempfile::tempdir().expect("should create store"); + let store = Arc::new( + Store::open( + &dir.path().join("preparation.redb"), + Bounds { + max_inventory: 2, + ..Bounds::default() + }, + ) + .expect("should open"), + ); + let barrier = Arc::new(Barrier::new(4)); + let handles: Vec<_> = (0..4) + .map(|index| { + let store = Arc::clone(&store); + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || { + barrier.wait(); + store.register(&[target(index)]).is_ok() + }) + }) + .collect(); + let accepted = handles + .into_iter() + .map(|handle| handle.join().expect("should join")) + .filter(|accepted| *accepted) + .count(); + assert_eq!(accepted, 2); + assert_eq!(store.counts().expect("should count").inventory, 2); +} + +#[test] +fn inventory_cursor_and_consecutive_observations_survive_restart_without_reader_hints() { + let dir = tempfile::tempdir().expect("should create store"); + { + let store = Store::open(&dir.path().join("preparation.redb"), Bounds::default()) + .expect("should open"); + store + .register(&(0..6).map(target).collect::>()) + .expect("should register independent inventory"); + assert_eq!( + store.due_inventory(1000, 900, 2).expect("should scan"), + vec![target(0), target(1)] + ); + assert_eq!( + store + .observe_snapshot(&target(0), &[item('a').identity], 1000) + .expect("should observe"), + Some(vec![]) + ); + } + let store = Store::open(&dir.path().join("preparation.redb"), Bounds::default()) + .expect("should reopen"); + assert_eq!( + store + .due_inventory(1060, 900, 2) + .expect("should resume cursor"), + vec![target(2), target(3)] + ); + assert_eq!( + store + .observe_snapshot(&target(0), &[item('a').identity, item('b').identity], 1900) + .expect("should observe independently"), + Some(vec![item('a').identity]) + ); +} + +#[test] +fn retained_inventory_refuses_reduced_capacity_without_losing_targets_or_artifacts() { + let dir = tempfile::tempdir().expect("should create store"); + let path = dir.path().join("preparation.redb"); + let bounds = Bounds { + max_inventory: 15, + ..Bounds::default() + }; + let inventory: Vec<_> = (0..15).map(target).collect(); + let identity = item('a').identity; + let artifact = Artifact { + envelope: ResultEnvelope { + protocol: PREPARATION_PROTOCOL.to_owned(), + deployment_id: "retained-inventory-test".to_owned(), + identity: identity.clone(), + retirement_epoch: 0, + variant: String::new(), + item: EnvelopeItem::Text { + request: TextSignRequest { + text: "durable exact text".to_owned(), + page_url: "https://www.example.com/article/0000".to_owned(), + document_title: None, + boundary_selector: "article".to_owned(), + expected_policy_digest: "b".repeat(64), + cache_scope: TextCacheScope::ExactRequest, + }, + idempotency_key: "retained-operation".to_owned(), + }, + }, + target: inventory[0].clone(), + acknowledged: true, + }; + let job = { + let store = Store::open(&path, bounds).expect("should open at exact capacity"); + assert_eq!( + store + .register(&inventory) + .expect("should register all targets"), + 15 + ); + store + .observe_snapshot(&inventory[0], std::slice::from_ref(&identity), 1000) + .expect("should retain consecutive-observation evidence"); + assert_eq!( + store + .due_inventory(1000, 900, 1) + .expect("should advance cursor"), + vec![inventory[0].clone()] + ); + assert!( + store + .record_artifact(&artifact) + .expect("should retain real artifact bytes and metadata") + ); + store + .admit(&inventory[3], JobOrigin::Warm, &[], 1000) + .expect("should retain pending work"); + store + .job(&target_key(&inventory[3])) + .expect("should read pending work") + .expect("should retain job") + }; + let artifact_path = dir + .path() + .join("artifacts") + .join(format!("{identity}.json")); + let artifact_bytes = + std::fs::read(&artifact_path).expect("should read retained artifact before refusal"); + assert!(matches!( + Store::open( + &path, + Bounds { + max_inventory: 14, + ..bounds + } + ), + Err(StoreError::InventoryFull) + )); + assert_eq!( + std::fs::read(&artifact_path).expect("refused startup must preserve artifact bytes"), + artifact_bytes + ); + let store = Store::open(&path, bounds) + .expect("raising capacity back to the persisted count should release the refusal"); + store + .recover_storage() + .expect("normal startup recovery must retain valid stored work"); + assert_eq!(store.counts().expect("should count").inventory, 15); + assert_eq!( + store + .artifact(&identity) + .expect("should read durable artifact"), + Some(artifact) + ); + assert_eq!( + std::fs::read(&artifact_path).expect("adequate reopen must preserve exact artifact bytes"), + artifact_bytes + ); + assert_eq!( + store + .job(&target_key(&inventory[3])) + .expect("should read pending work after reopen"), + Some(job) + ); + assert_eq!( + store + .due_inventory(1001, 900, 1) + .expect("should resume the retained cursor"), + vec![inventory[1].clone()] + ); + assert_eq!( + store + .observe_snapshot(&inventory[0], std::slice::from_ref(&identity), 1001) + .expect("should read retained observation"), + Some(vec![identity]) + ); + let mut retained = store + .due_inventory(2000, 900, 128) + .expect("should enumerate every retained target without waiting"); + retained.sort_by(|left, right| left.path.cmp(&right.path)); + assert_eq!(retained, inventory); + assert_eq!( + store.register(&[target(15)]), + Err(StoreError::InventoryFull), + "reopening at the exact retained count must not create extra capacity" + ); +} + +#[test] +fn repeated_crashes_do_not_reset_attempts_or_the_one_hour_exhaustion_cooldown() { + let dir = tempfile::tempdir().expect("should create store"); + let bounds = Bounds { + max_attempts: 2, + ..Bounds::default() + }; + for token in 1..=2 { + let store = Store::open(&dir.path().join("preparation.redb"), bounds).expect("should open"); + if token == 1 { + store + .admit(&target(1), JobOrigin::Warm, &[], 10) + .expect("should admit"); + } + store + .recover_all(10) + .expect("should recover startup ownership"); + let claim = store + .claim(10, token) + .expect("should claim") + .expect("should retain unfinished work"); + assert_eq!(claim.job.attempts, token as u32); + } + let store = Store::open(&dir.path().join("preparation.redb"), bounds).expect("should reopen"); + store + .recover_all(11) + .expect("should recover exhausted claim"); + assert!( + store + .claim(11, 3) + .expect("should refuse third attempt") + .is_none() + ); + assert_eq!( + store + .admit(&target(1), JobOrigin::Reconcile, &[], 12) + .expect("should respect target cooldown"), + Admission::Damped + ); + assert_eq!( + store + .outcome(&target(1), "*") + .expect("should retain terminal damping") + .expect("should damp") + .class, + OutcomeClass::Exhausted + ); + assert!(matches!( + store + .admit(&target(1), JobOrigin::Reconcile, &[], 3612) + .expect("should allow after cooldown"), + Admission::Queued { .. } + )); +} + +#[test] +fn lease_boundary_and_restart_preserve_new_desired_generation_against_every_stale_settlement() { + let dir = tempfile::tempdir().expect("should create store"); + let bounds = Bounds { + lease_seconds: 10, + max_attempts: 1, + ..Bounds::default() + }; + let page = target(1); + let old = { + let store = Store::open(&dir.path().join("preparation.redb"), bounds).expect("should open"); + store + .admit(&page, JobOrigin::Signal, &[item('a')], 100) + .expect("should admit"); + let old = store + .claim(100, 1) + .expect("should claim") + .expect("should have work"); + assert!( + !store + .renew(&old.job.key, 2, 109) + .expect("should refuse another owner") + ); + assert!( + store + .renew(&old.job.key, old.token, 109) + .expect("should renew just before expiry") + ); + store + .admit(&page, JobOrigin::Signal, &[item('b')], 118) + .expect("should replace desired version during renewed lease"); + let desired = store + .job(&old.job.key) + .expect("should read") + .expect("should retain follow-up"); + assert_eq!(desired.generation, old.job.generation + 1); + assert_eq!( + store + .recover_expired(118) + .expect("should leave live owner alone"), + 0 + ); + assert!( + !store + .renew(&old.job.key, old.token, 119) + .expect("should refuse renewal at exact expiry") + ); + assert!( + !store + .complete(&old.job.key, old.token, 119) + .expect("should refuse success at exact expiry") + ); + assert!( + !store + .retry(&old.job.key, old.token, 119, "late_failure") + .expect("should refuse failure at exact expiry") + ); + assert_eq!( + store + .job(&old.job.key) + .expect("should preserve desired state"), + Some(desired) + ); + old + }; + let store = Store::open(&dir.path().join("preparation.redb"), bounds) + .expect("should reopen real owner"); + assert_eq!( + store + .recover_expired(119) + .expect("should recover exactly expired lease"), + 1 + ); + let pending = store + .job(&old.job.key) + .expect("should read") + .expect("should retain latest despite exhausted old attempt"); + assert_eq!(pending.state, JobState::Pending { ready_at: 119 }); + assert_eq!(pending.observed, vec![item('b')]); + let latest = store + .claim(119, 2) + .expect("should claim immediately") + .expect("should retain follow-up after restart"); + assert_eq!(latest.job.generation, old.job.generation + 1); + assert_eq!( + latest.job.attempts, 1, + "the old exhausted generation cannot spend the new generation's attempts" + ); + store + .record_claim_outcome( + &old, + &item('b').identity, + "stale_terminal", + OutcomeClass::Terminal, + 120, + ) + .expect("should fence old negative outcome"); + assert!( + !store + .renew(&old.job.key, old.token, 120) + .expect("should refuse old renewal") + ); + assert!( + !store + .complete(&old.job.key, old.token, 120) + .expect("should refuse old completion") + ); + assert!( + !store + .retry(&old.job.key, old.token, 120, "stale_retry") + .expect("should refuse old failure") + ); + assert_eq!( + store + .job(&latest.job.key) + .expect("should read current ownership"), + Some(latest.job.clone()) + ); + assert!( + store + .outcome(&page, &item('b').identity) + .expect("should read current outcome") + .is_none() + ); + assert!( + store + .complete(&latest.job.key, latest.token, 120) + .expect("should complete latest version") + ); + assert!( + store + .claim(120, 3) + .expect("should have no duplicate work") + .is_none() + ); +} + +#[test] +fn relevant_context_changes_clear_terminal_damping_but_never_retirement_or_retry_cooldown() { + let dir = tempfile::tempdir().expect("should create store"); + let store = + Store::open(&dir.path().join("preparation.redb"), Bounds::default()).expect("should open"); + store + .refresh_context("probe", "release-a") + .expect("should record authenticated release"); + store + .record_outcome( + &target(1), + &item('a').identity, + "unsupported_carrier", + OutcomeClass::Terminal, + 0, + ) + .expect("should damp terminal outcome"); + store + .record_outcome( + &target(1), + &item('b').identity, + "offline", + OutcomeClass::Exhausted, + 0, + ) + .expect("should damp exhausted attempt"); + store + .retire(&item('c').identity, "deployment") + .expect("should retire independently"); + store + .refresh_context("probe", "release-b") + .expect("should invalidate old terminal context"); + assert!( + store + .outcome(&target(1), &item('a').identity) + .expect("should read") + .is_none() + ); + assert!( + store + .outcome(&target(1), &item('b').identity) + .expect("should retain cooldown") + .is_some() + ); + assert!( + store + .retirement(&item('c').identity) + .expect("should retain retirement") + .is_some() + ); + assert_eq!( + store + .admit(&target(1), JobOrigin::Signal, &[item('c')], 1) + .expect("should refuse spent identity"), + Admission::Damped + ); +} + +#[test] +fn a_failed_marker_cannot_starve_later_retirements_and_cursor_survives_restart() { + let dir = tempfile::tempdir().expect("should create store"); + { + let store = Store::open(&dir.path().join("preparation.redb"), Bounds::default()) + .expect("should open"); + for index in 0..20 { + store + .retire(&format!("{index:064x}"), "deployment") + .expect("should queue marker"); + } + let first = store + .outbox_batch(16) + .expect("should publish bounded batch"); + assert_eq!(first.len(), 16); + assert_eq!(first[0].identity, format!("{:064x}", 0)); + // All sixteen sends fail: no acknowledgement, but the fair cursor is durable. + } + let store = Store::open(&dir.path().join("preparation.redb"), Bounds::default()) + .expect("should reopen"); + let next = store.outbox_batch(16).expect("should resume cursor"); + assert_eq!(next[0].identity, format!("{:064x}", 16)); + assert!( + next.iter() + .any(|marker| marker.identity == format!("{:064x}", 19)) + ); + assert_eq!( + store + .counts() + .expect("should count pending versus acknowledged") + .retirement_pending, + 20 + ); +} + +#[test] +fn operator_retry_fences_late_negative_outcomes_and_schedules_one_fresh_generation() { + let dir = tempfile::tempdir().expect("should create store"); + let store = + Store::open(&dir.path().join("preparation.redb"), Bounds::default()).expect("should open"); + let page = target(1); + store + .admit(&page, JobOrigin::Signal, &[item('a')], 1) + .expect("should admit"); + let old = store + .claim(1, 1) + .expect("should claim") + .expect("should have work"); + store + .clear_outcomes(&page, 2) + .expect("should authorize explicit operator retry"); + store + .record_claim_outcome( + &old, + &item('a').identity, + "late_integrity_failure", + OutcomeClass::Terminal, + 3, + ) + .expect("should fence old failure"); + assert!( + store + .outcome(&page, &item('a').identity) + .expect("should read") + .is_none() + ); + store + .complete(&old.job.key, old.token, 4) + .expect("should settle old claim"); + let fresh = store + .claim(4, 2) + .expect("should claim retry") + .expect("should preserve retry"); + assert!(fresh.job.generation > old.job.generation); + store + .record_claim_outcome( + &fresh, + &item('a').identity, + "current_failure", + OutcomeClass::Terminal, + 5, + ) + .expect("should record live failure"); + assert_eq!( + store + .outcome(&page, &item('a').identity) + .expect("should read") + .expect("should damp live outcome") + .code, + "current_failure" + ); +} + +#[test] +fn stale_snapshot_completion_cannot_replace_follow_up_stability_evidence() { + let dir = tempfile::tempdir().expect("should create store"); + let store = + Store::open(&dir.path().join("preparation.redb"), Bounds::default()).expect("should open"); + let page = target(1); + store + .register(std::slice::from_ref(&page)) + .expect("should register independent target"); + store + .admit(&page, JobOrigin::Signal, &[item('a')], 1) + .expect("should admit"); + let old = store + .claim(1, 1) + .expect("should claim") + .expect("should have work"); + assert_eq!( + store + .observe_claim_snapshot(&old, &[item('a').identity], 2) + .expect("should observe live claim"), + Some(Vec::new()) + ); + store + .admit(&page, JobOrigin::Signal, &[item('b')], 3) + .expect("should retain desired follow-up"); + assert_eq!( + store + .observe_claim_snapshot(&old, &[item('c').identity], 4) + .expect("should fence out-of-order snapshot"), + None + ); + store + .complete(&old.job.key, old.token, 5) + .expect("should schedule follow-up"); + let next = store + .claim(5, 2) + .expect("should claim") + .expect("should have follow-up"); + assert_eq!( + store + .observe_claim_snapshot(&next, &[item('b').identity], 6) + .expect("should observe current generation"), + Some(vec![item('a').identity]) + ); +} + +#[test] +fn reader_follow_up_cannot_consume_publisher_reservation_through_coalescing() { + let dir = tempfile::tempdir().expect("should create store"); + let store = Store::open( + &dir.path().join("preparation.redb"), + Bounds { + max_jobs: 2, + reserved_jobs: 1, + ..Bounds::default() + }, + ) + .expect("should open"); + let publication = target(0); + store + .admit(&publication, JobOrigin::Warm, &[], 0) + .expect("should admit publisher job"); + let publisher = store + .claim(0, 1) + .expect("should claim") + .expect("should process publisher job"); + store + .admit(&target(1), JobOrigin::Signal, &[item('a')], 1) + .expect("should use the reader slot"); + assert_eq!( + store + .admit(&publication, JobOrigin::Signal, &[item('b')], 2) + .expect("should account desired reader generation"), + Admission::QueueFull + ); + let retained = store + .job(&publisher.job.key) + .expect("should read") + .expect("should keep publisher work"); + assert_eq!(retained.origin, JobOrigin::Warm); + assert!( + retained.reserved && !retained.follow_up, + "refusal preserves publisher capacity and its in-flight claim" + ); +} diff --git a/crates/trusted-server-preparation-worker/tests/worker_flow.rs b/crates/trusted-server-preparation-worker/tests/worker_flow.rs new file mode 100644 index 0000000000..6a33b1461a --- /dev/null +++ b/crates/trusted-server-preparation-worker/tests/worker_flow.rs @@ -0,0 +1,900 @@ +//! Worker behavior over real redb and local HTTP boundaries. The API fixture +//! persists exact text receipts; it never stands in for the worker or probe. +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use axum::Router; +use axum::body::to_bytes; +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use serde_json::{Value, json}; +use trusted_server_preparation_worker::client::{Api, Edge, Secrets, WorkerConfig}; +use trusted_server_preparation_worker::prepare::{Context, now, run}; +use trusted_server_preparation_worker::store::{ + Admission, Artifact, Bounds, Claim, JobOrigin, OutcomeClass, Store, target_key, +}; +use trusted_server_provenance_encypher::preparation::pipeline::FailureClass; +use trusted_server_provenance_encypher::preparation::protocol::{ + ItemKind, ObservedItem, PREPARATION_PROTOCOL, ResultEnvelope, SNAPSHOT_META_HEADER, + SnapshotMeta, SnapshotMode, SnapshotOrigin, SnapshotRequest, Target, +}; + +#[derive(Default)] +struct Boundary { + documents: HashMap, + receipts: HashMap, + signs: usize, + sign_requests: usize, + recovers: usize, + capabilities: usize, + publications: Vec, + creation_closed: bool, + lose_sign_reply: bool, + reject_publication: bool, + private: bool, + ranges: Vec<(u64, u64)>, + wrong_range_length: bool, +} + +struct Fixture { + state: Arc>, + url: String, + server: tokio::task::JoinHandle<()>, +} + +impl Fixture { + async fn start() -> Self { + let state = Arc::new(Mutex::new(Boundary::default())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind boundary"); + let url = format!( + "http://{}", + listener.local_addr().expect("should read address") + ); + let app = Router::new() + .fallback(boundary) + .with_state(Arc::clone(&state)); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("should serve boundary"); + }); + Self { state, url, server } + } + + fn document(&self, path: &str, text: &str) { + self.state.lock().expect("should lock boundary").documents.insert(path.to_owned(), format!("Harbor
{text}
")); + } + + fn context(&self, dir: &tempfile::TempDir) -> Context { + let config: WorkerConfig = toml::from_str(&format!( + r#" +listen = "0.0.0.0:8112" +store_dir = {:?} +deployment_id = "fixture" +api_base_url = {:?} +domain = "example.com" +public_host = "www.example.com" +edge_url = {:?} +min_text_length = 1 +article_selector = "article" +max_large_media_bytes = 1048576 +max_snapshot_bytes = 1048576 +max_media_bytes = 1048576 +[secrets] +worker_token = "FIXTURE_WORKER" +signal_secret = "FIXTURE_SIGNAL" +publication_secret = "FIXTURE_PUBLICATION" +operator_secret = "FIXTURE_OPERATOR" +"#, + dir.path().to_str().expect("should have path"), + self.url, + self.url + )) + .expect("should parse worker configuration"); + let store = Arc::new( + Store::open(&dir.path().join("preparation.redb"), Bounds::default()) + .expect("should open real store"), + ); + store.recover_storage().expect("should recover real files"); + store.recover_all(now()).expect("should recover claims"); + let secrets = Secrets { + worker_token: "cwk_fixture".to_owned(), + signal: "signal".to_owned(), + publication: "publication".to_owned(), + operator: "operator".to_owned(), + canary: None, + }; + Context::new( + store, + Api::new(&self.url, &secrets), + Edge::new(&self.url, &secrets, "fixture"), + Arc::new(config), + &secrets.worker_token, + ) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.server.abort(); + } +} + +fn response(status: StatusCode, value: Value) -> Response { + (status, axum::Json(value)).into_response() +} + +async fn boundary(State(state): State>>, request: Request) -> Response { + let path = request.uri().path().to_owned(); + let headers = request.headers().clone(); + let bytes = to_bytes(request.into_body(), 8 * 1024 * 1024) + .await + .expect("should read bounded request"); + let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + let mut state = state.lock().expect("should lock boundary"); + if path.ends_with("/policy") { + return response(StatusCode::OK, json!({"policy_digest": "a".repeat(64)})); + } + if path.ends_with("/capabilities") { + state.capabilities += 1; + return response( + StatusCode::FORBIDDEN, + json!({"error":{"code":"CREATION_DISABLED"}}), + ); + } + if path == "/api/v1/public/cdn/sign" || path == "/api/v1/public/cdn/sign/recover" { + assert_eq!( + value["cache_scope"], "exact_request", + "both signing and recovery must use the exact-request API cache partition" + ); + assert_eq!( + headers + .get("x-encypher-worker-token") + .expect("should authenticate API"), + "cwk_fixture" + ); + let key = headers + .get("idempotency-key") + .expect("should carry operation identity") + .to_str() + .expect("should be text") + .to_owned(); + if path.ends_with("/recover") { + state.recovers += 1; + return match state.receipts.get(&key) { + Some((frozen, answer)) if frozen == &value => { + response(StatusCode::OK, answer.clone()) + } + Some(_) => response(StatusCode::CONFLICT, json!({"error":{"code":"CONFLICT"}})), + None => response(StatusCode::NOT_FOUND, json!({"error":{"code":"MISSING"}})), + }; + } + state.sign_requests += 1; + if state.creation_closed { + return response( + StatusCode::FORBIDDEN, + json!({"error":{"code":"CREATION_DISABLED"}}), + ); + } + assert!( + !state.receipts.contains_key(&key), + "the worker must recover a completed operation, not call sign as replay" + ); + state.signs += 1; + let answer = json!({"success":true,"policy_digest":"a".repeat(64),"embedding_plan":{"index_unit":"codepoint","operations":[{"insert_after_index":0,"marker":"\u{fe00}"}]},"manifest_url":"https://manifest.example.com/receipt"}); + state.receipts.insert(key, (value, answer.clone())); + if state.lose_sign_reply { + state.lose_sign_reply = false; + return response( + StatusCode::SERVICE_UNAVAILABLE, + json!({"error":{"code":"LOST_REPLY"}}), + ); + } + return response(StatusCode::OK, answer); + } + assert_eq!( + headers + .get("x-encypher-preparation-token") + .expect("should authenticate edge"), + "publication" + ); + if path == "/_encypher/preparation/snapshot" { + let request: SnapshotRequest = + serde_json::from_value(value).expect("should send snapshot request"); + let Some(document) = state.documents.get(&request.target.path) else { + return response( + StatusCode::FORBIDDEN, + json!({"error":{"code":"target_not_configured"}}), + ); + }; + let mut body = document.as_bytes().to_vec(); + let mut origin_status = 200; + let length = body.len() as u64; + match request.read { + SnapshotMode::Whole => {} + SnapshotMode::Head => body.clear(), + SnapshotMode::Range { first, last, .. } => { + body = body[first as usize..=last as usize].to_vec(); + if state.wrong_range_length { + body.pop(); + } + state.ranges.push((first, last)); + origin_status = 206; + } + } + let meta = SnapshotMeta { + protocol: PREPARATION_PROTOCOL.to_owned(), + page_url: format!("https://www.example.com{}", request.target.path), + content_type: "text/html".to_owned(), + etag: Some("\"stable\"".to_owned()), + length: Some(length), + origin: SnapshotOrigin { + status: origin_status, + private: state.private, + no_transform: false, + manifest_link: false, + content_encoding: String::new(), + origin_host: "origin.example.com".to_owned(), + origin_identity_sha256: String::new(), + source_reference: Some(format!( + "https://origin.example.com{}", + request.target.path + )), + }, + }; + let encoded = STANDARD.encode(serde_json::to_vec(&meta).expect("should encode metadata")); + return ( + [ + (SNAPSHOT_META_HEADER, encoded), + ("cache-control", "private, no-store".to_owned()), + ], + body, + ) + .into_response(); + } + if path.starts_with("/_encypher/preparation/results/") { + if state.reject_publication { + return response( + StatusCode::SERVICE_UNAVAILABLE, + json!({"error":{"code":"UNAVAILABLE"}}), + ); + } + state + .publications + .push(serde_json::from_value(value).expect("should publish a real envelope")); + return response(StatusCode::OK, json!({"stored":true})); + } + if path.starts_with("/_encypher/preparation/markers/") { + return response(StatusCode::OK, json!({"stored":true})); + } + response( + StatusCode::NOT_FOUND, + json!({"error":{"code":"unexpected_route"}}), + ) +} + +fn target(path: &str) -> Target { + Target { + path: path.to_owned(), + variant: String::new(), + } +} + +fn claim( + context: &Context, + target: &Target, + origin: JobOrigin, + items: &[ObservedItem], + token: u64, +) -> Claim { + assert!(matches!( + context + .store + .admit(target, origin, items, now()) + .expect("should admit"), + Admission::Queued { .. } | Admission::Coalesced { .. } + )); + context + .store + .claim(now(), token) + .expect("should claim") + .expect("should have work") +} + +async fn warm(context: &Context, target: &Target, token: u64) { + let claim = claim(context, target, JobOrigin::Warm, &[], token); + run(context, &claim).await.expect("should finish warm"); + assert!( + context + .store + .complete(&claim.job.key, claim.token, now()) + .expect("should settle") + ); +} + +#[tokio::test] +async fn changed_only_preparation_reuses_stable_siblings_and_restores_evicted_delivery_records() { + let fixture = Fixture::start().await; + let dir = tempfile::tempdir().expect("should create store"); + fixture.document("/a", "The harbor vote passed."); + fixture.document("/b", "The ferry leaves at noon."); + let context = fixture.context(&dir); + warm(&context, &target("/a"), 1).await; + warm(&context, &target("/b"), 2).await; + assert_eq!(fixture.state.lock().expect("should lock").signs, 2); + let original = fixture + .state + .lock() + .expect("should lock") + .publications + .clone(); + fixture.document("/a", "The harbor vote was amended."); + warm(&context, &target("/a"), 3).await; + warm(&context, &target("/b"), 4).await; + let state = fixture.state.lock().expect("should lock"); + assert_eq!(state.signs, 3, "only the changed exact identity signs"); + assert_ne!(state.publications[2].identity, original[0].identity); + assert_eq!( + state.publications[3], original[1], + "delivery eviction restores exactly the retained envelope" + ); + assert_eq!(context.store.counts().expect("should count").artifacts, 3); +} + +#[tokio::test] +async fn title_and_mirror_url_changes_take_new_exact_operations_but_unchanged_text_reuses() { + let fixture = Fixture::start().await; + let dir = tempfile::tempdir().expect("should create store"); + fixture.document("/a", "The harbor vote passed."); + let context = fixture.context(&dir); + warm(&context, &target("/a"), 1).await; + { + let mut state = fixture.state.lock().expect("should lock boundary"); + let original = state.documents["/a"].clone(); + state.documents.insert( + "/a".to_owned(), + original.replace( + "Harbor", + "Revised harbor title", + ), + ); + } + warm(&context, &target("/a"), 2).await; + { + let mut state = fixture.state.lock().expect("should lock boundary"); + let revised = state.documents["/a"].clone(); + state.documents.insert("/mirror".to_owned(), revised); + } + warm(&context, &target("/mirror"), 3).await; + warm(&context, &target("/mirror"), 4).await; + let state = fixture.state.lock().expect("should lock boundary"); + assert_eq!(state.signs, 3, "only new exact text requests should sign"); + assert_eq!( + state.receipts.len(), + 3, + "each metadata context has its own operation" + ); + assert_ne!( + state.publications[0].identity, + state.publications[1].identity + ); + assert_ne!( + state.publications[1].identity, + state.publications[2].identity + ); + assert_eq!(state.publications[2], state.publications[3]); + let requests: Vec<&Value> = state + .receipts + .values() + .map(|(request, _)| request) + .collect(); + assert!( + requests + .iter() + .all(|request| request["text"] == "The harbor vote passed.") + ); + assert!( + requests + .iter() + .any(|request| request["document_title"] == "Harbor") + ); + assert!(requests.iter().any(|request| { + request["document_title"] == "Revised harbor title" + && request["page_url"] == "https://www.example.com/a" + })); + assert!( + requests + .iter() + .any(|request| request["page_url"] == "https://www.example.com/mirror") + ); +} + +#[tokio::test] +async fn six_hour_store_maintenance_and_restart_keep_exact_artifact_reusable_with_creation_closed() +{ + let fixture = Fixture::start().await; + let dir = tempfile::tempdir().expect("should create store"); + let page = target("/a"); + fixture.document( + "/a", + "Unchanged editorial content needs no hourly signing renewal.", + ); + let context = fixture.context(&dir); + warm(&context, &page, 1).await; + let original = fixture.state.lock().expect("should lock").publications[0].clone(); + let retained = context + .store + .artifact(&original.identity) + .expect("should read") + .expect("should retain durable artifact"); + let charge = context + .store + .reserved() + .expect("should charge artifact and reconstruction"); + let maintenance_start = now(); + fixture.state.lock().expect("should lock").creation_closed = true; + // Store maintenance accepts explicit Unix seconds. This advances those + // controls only; preparation's SystemTime clock does not advance. + for hours in [1, 3, 6] { + let at = maintenance_start + hours * 3600; + assert_eq!( + context + .store + .recover_expired(at) + .expect("should sweep expired claims"), + 0 + ); + assert_eq!( + context + .store + .prune_expired_outcomes(at) + .expect("should sweep expired outcomes"), + 0 + ); + assert_eq!( + context + .store + .artifact(&original.identity) + .expect("should read retained result"), + Some(retained.clone()), + "multi-hour maintenance must not time-expire an unchanged artifact" + ); + assert_eq!( + context + .store + .reserved() + .expect("should preserve quota charge"), + charge + ); + } + drop(context); + let context = fixture.context(&dir); + assert_eq!( + context + .store + .artifact(&original.identity) + .expect("should read after recovery"), + Some(retained) + ); + assert_eq!( + context + .store + .reserved() + .expect("should retain charged reconstruction after restart"), + charge + ); + let recovers_before = fixture.state.lock().expect("should lock").recovers; + for token in 2..=4 { + warm(&context, &page, token).await; + } + let state = fixture.state.lock().expect("should lock"); + assert_eq!(state.signs, 1); + assert_eq!( + state.sign_requests, 1, + "unchanged reuse must not even attempt a denied signing request" + ); + assert_eq!( + state.recovers, recovers_before, + "a retained artifact needs no worker-side API replay or renewal" + ); + assert_eq!(state.capabilities, 0); + assert_eq!(state.publications.len(), 4); + assert!( + state + .publications + .iter() + .all(|envelope| envelope == &original), + "delivery eviction is repaired by the same immutable artifact and operation" + ); + assert_eq!( + context + .store + .counts() + .expect("should retain one version") + .artifacts, + 1 + ); +} + +#[tokio::test] +async fn crash_after_api_commit_recovers_with_creation_closed_and_never_calls_sign_again() { + let fixture = Fixture::start().await; + let dir = tempfile::tempdir().expect("should create store"); + fixture.document("/a", "A durable exact text receipt."); + { + let context = fixture.context(&dir); + fixture.state.lock().expect("should lock").lose_sign_reply = true; + let claim = claim(&context, &target("/a"), JobOrigin::Warm, &[], 1); + assert_eq!( + run(&context, &claim) + .await + .expect_err("should lose API reply") + .class, + FailureClass::Transient + ); + assert_eq!(fixture.state.lock().expect("should lock").signs, 1); + // Simulate process death without settling: redb and attempted operation survive. + } + fixture.state.lock().expect("should lock").creation_closed = true; + let context = fixture.context(&dir); + let claim = context + .store + .claim(now(), 2) + .expect("should reclaim") + .expect("should recover unfinished job"); + run(&context, &claim) + .await + .expect("should recover with signing disabled"); + let state = fixture.state.lock().expect("should lock"); + assert_eq!(state.signs, 1); + assert_eq!(state.publications.len(), 1); + assert!(state.recovers >= 2); + assert_eq!( + state.capabilities, 0, + "text recovery never depends on creation capability" + ); +} + +#[tokio::test] +async fn durable_artifact_survives_lost_publication_and_restart_without_api_creation() { + let fixture = Fixture::start().await; + let dir = tempfile::tempdir().expect("should create store"); + fixture.document("/a", "Retained output survives restart."); + { + let context = fixture.context(&dir); + fixture + .state + .lock() + .expect("should lock") + .reject_publication = true; + let claim = claim(&context, &target("/a"), JobOrigin::Warm, &[], 1); + run(&context, &claim) + .await + .expect_err("should retain before failed push"); + assert_eq!(context.store.counts().expect("should count").artifacts, 1); + } + { + let mut state = fixture.state.lock().expect("should lock"); + state.reject_publication = false; + state.creation_closed = true; + } + let context = fixture.context(&dir); + let claim = context + .store + .claim(now(), 2) + .expect("should reclaim") + .expect("should have work"); + run(&context, &claim) + .await + .expect("should republish durable artifact"); + let state = fixture.state.lock().expect("should lock"); + assert_eq!(state.signs, 1); + assert_eq!(state.publications.len(), 1); + assert!( + context + .store + .artifact(&state.publications[0].identity) + .expect("should read") + .expect("should retain") + .acknowledged + ); +} + +#[tokio::test] +async fn private_snapshot_refuses_before_policy_or_signing_and_is_damped_per_observation() { + let fixture = Fixture::start().await; + let dir = tempfile::tempdir().expect("should create store"); + fixture.document("/a", "Private source must never be admitted."); + fixture.state.lock().expect("should lock").private = true; + let context = fixture.context(&dir); + let observed = ObservedItem { + identity: "b".repeat(64), + kind: ItemKind::Text, + }; + let claim = claim( + &context, + &target("/a"), + JobOrigin::Signal, + std::slice::from_ref(&observed), + 1, + ); + run(&context, &claim) + .await + .expect("should record privacy refusal"); + let outcome = context + .store + .outcome(&target("/a"), &observed.identity) + .expect("should read") + .expect("should damp"); + assert_eq!(outcome.class, OutcomeClass::Terminal); + assert_eq!(outcome.code, "source_private"); + assert_eq!(fixture.state.lock().expect("should lock").signs, 0); +} + +#[tokio::test] +async fn unstable_child_does_not_suppress_stable_text_or_create_signing_churn() { + let fixture = Fixture::start().await; + let dir = tempfile::tempdir().expect("should create store"); + fixture.document("/a", "Stable editorial text alongside a rotating child."); + let context = fixture.context(&dir); + warm(&context, &target("/a"), 1).await; + let identity = fixture.state.lock().expect("should lock").publications[0] + .identity + .clone(); + let rotating = ObservedItem { + identity: "c".repeat(64), + kind: ItemKind::Embedded, + }; + let stable = ObservedItem { + identity: identity.clone(), + kind: ItemKind::Text, + }; + let claim = claim( + &context, + &target("/a"), + JobOrigin::Signal, + &[rotating.clone(), stable], + 2, + ); + run(&context, &claim) + .await + .expect("should reuse stable sibling"); + assert_eq!( + context + .store + .outcome(&target("/a"), &rotating.identity) + .expect("should read") + .expect("should damp") + .class, + OutcomeClass::SourceUnstable + ); + let state = fixture.state.lock().expect("should lock"); + assert_eq!(state.signs, 1); + assert_eq!(state.publications.len(), 2); + assert_eq!(state.publications[1].identity, identity); +} + +#[tokio::test] +async fn independent_inventory_requires_consecutive_stability_and_survives_restart() { + let fixture = Fixture::start().await; + let dir = tempfile::tempdir().expect("should create store"); + fixture.document( + "/registered", + "An unseen page with independent publisher inventory.", + ); + { + let context = fixture.context(&dir); + context + .store + .register(&[target("/registered")]) + .expect("should register without reader hint"); + let due = context + .store + .due_inventory(1000, 900, 128) + .expect("should scan"); + assert_eq!(due, vec![target("/registered")]); + let claim = claim(&context, &due[0], JobOrigin::Reconcile, &[], 1); + run(&context, &claim).await.expect("should observe only"); + context + .store + .complete(&claim.job.key, claim.token, now()) + .expect("should settle"); + assert_eq!(fixture.state.lock().expect("should lock").signs, 0); + } + let context = fixture.context(&dir); + let claim = claim( + &context, + &target("/registered"), + JobOrigin::Reconcile, + &[], + 2, + ); + run(&context, &claim) + .await + .expect("should prepare consecutive stable snapshot"); + assert_eq!(fixture.state.lock().expect("should lock").signs, 1); +} + +#[tokio::test] +async fn retired_result_cannot_be_recovered_by_warm_or_late_claim_across_restart() { + let fixture = Fixture::start().await; + let dir = tempfile::tempdir().expect("should create store"); + let page = target("/a"); + fixture.document("/a", "An explicitly retired identity stays spent."); + let (artifact, late, epoch) = { + let context = fixture.context(&dir); + warm(&context, &page, 1).await; + let envelope = fixture.state.lock().expect("should lock").publications[0].clone(); + let artifact = Artifact { + envelope, + target: page.clone(), + acknowledged: true, + }; + let late = claim(&context, &page, JobOrigin::Warm, &[], 2); + let epoch = context + .store + .retire(&artifact.envelope.identity, "fixture") + .expect("should retire"); + let counts = context + .store + .counts() + .expect("should report local retirement separately"); + assert_eq!(counts.artifacts, 0); + assert_eq!(counts.retirement_pending, 1); + assert_eq!(counts.retirement_acknowledged, 0); + assert_eq!( + context + .store + .outbox() + .expect("should persist marker before delivery") + .len(), + 1 + ); + (artifact, late, epoch) + }; + fixture.state.lock().expect("should lock").creation_closed = true; + { + let context = fixture.context(&dir); + let identity = &artifact.envelope.identity; + let marker = context + .store + .outbox() + .expect("should retain delayed marker across restart") + .pop() + .expect("should have marker"); + assert_eq!(marker.identity, *identity); + assert_eq!(marker.epoch, epoch); + assert_eq!( + context + .store + .retire(identity, "fixture") + .expect("should preserve existing retirement"), + epoch + ); + assert!( + !context + .store + .record_artifact(&artifact) + .expect("should reject delayed old artifact") + ); + assert!( + context + .store + .artifact(identity) + .expect("should refuse retired result") + .is_none() + ); + assert_eq!( + run(&context, &late) + .await + .expect_err("should fence pre-restart worker") + .code, + "lease_lost" + ); + let recovered = context + .store + .claim(now(), 3) + .expect("should claim unfinished warm") + .expect("should retain unfinished target"); + run(&context, &recovered) + .await + .expect("should refuse retired identity without signing"); + assert!( + context + .store + .complete(&recovered.job.key, recovered.token, now()) + .expect("should settle retired work") + ); + warm(&context, &page, 4).await; + let observed = ObservedItem { + identity: identity.clone(), + kind: ItemKind::Text, + }; + assert_eq!( + context + .store + .admit(&page, JobOrigin::Signal, &[observed], now()) + .expect("should refuse reader resurrection"), + Admission::Damped + ); + context + .store + .acknowledge_marker(identity, epoch + 1) + .expect("should ignore wrong acknowledgement"); + assert_eq!( + context + .store + .outbox() + .expect("should keep pending exact epoch"), + vec![marker] + ); + assert_eq!( + context + .store + .counts() + .expect("should report still pending") + .retirement_pending, + 1 + ); + context + .store + .acknowledge_marker(identity, epoch) + .expect("should acknowledge exact marker"); + assert!( + context + .store + .outbox() + .expect("should settle outbox") + .is_empty() + ); + let counts = context + .store + .counts() + .expect("should distinguish acknowledged retirement"); + assert_eq!(counts.retirement_pending, 0); + assert_eq!(counts.retirement_acknowledged, 1); + } + let context = fixture.context(&dir); + assert!( + context + .store + .retirement(&artifact.envelope.identity) + .expect("should read tombstone after second restart") + .expect("should remain retired") + .acknowledged + ); + assert!( + context + .store + .outbox() + .expect("should retain acknowledged state") + .is_empty() + ); + assert!( + !context + .store + .record_artifact(&artifact) + .expect("should reject late result even after marker acknowledgement") + ); + warm(&context, &page, 5).await; + assert!( + context + .store + .job(&target_key(&page)) + .expect("should read") + .is_none() + ); + let state = fixture.state.lock().expect("should lock"); + assert_eq!(state.signs, 1); + assert_eq!(state.sign_requests, 1); + assert_eq!( + state.publications.len(), + 1, + "no late claim, warm or restart can publish retired bytes again" + ); +} diff --git a/crates/trusted-server-preparation-worker/worker.example.toml b/crates/trusted-server-preparation-worker/worker.example.toml new file mode 100644 index 0000000000..d62428bb7f --- /dev/null +++ b/crates/trusted-server-preparation-worker/worker.example.toml @@ -0,0 +1,43 @@ +# Launch: trusted-server-axum preparation-worker --config worker.toml +# Store ownership is exclusive. Operator commands use HTTP, not a second DB open. +listen = "0.0.0.0:8112" +store_dir = "/var/lib/trusted-server/preparation" +deployment_id = "publisher-preparation" +api_base_url = "https://api.example.com" +domain = "example.com" +public_host = "www.example.com" +edge_url = "https://delivery.example.com" + +# Match the delivery instance's projection, source ceilings and variants. +article_selector = "article" +min_text_length = 50 +max_media_bytes = 5000000 +max_snapshot_bytes = 67108864 +max_large_media_bytes = 1073741824 +max_preparation_seconds = 120 +variants = [] + +# Sources, temporary assembly, probes, plans, outputs and their identity, +# outcome and reference metadata share this hard quota. Full storage refuses +# new work; retained recovery material is never evicted. Conservative +# workspace is reserved before signing. +workers = 4 +artifact_quota_bytes = 10737418240 +scan_per_minute = 128 +scan_interval_seconds = 900 + +# Independent publisher inventory survives lost reader hints and process restarts. +# An unregistered page is opportunistic coverage only, not universal discovery. +[[inventory]] +path = "/news/harbor-vote" +variant = "" + +[secrets] +# Values come from the process environment, not this file. The signal and +# publication values must equal the corresponding delivery secret-store entries. +worker_token = "TS_PREPARATION_WORKER_TOKEN" +signal_secret = "TS_PREPARATION_SIGNAL_SECRET" +publication_secret = "TS_PREPARATION_PUBLICATION_SECRET" +operator_secret = "TS_PREPARATION_OPERATOR_SECRET" +# Optional, for an authorized staging creation canary only: +# canary_token = "TS_PREPARATION_CANARY_TOKEN" diff --git a/docs/guide/asset-routes.md b/docs/guide/asset-routes.md index fa91596123..6e31d591ea 100644 --- a/docs/guide/asset-routes.md +++ b/docs/guide/asset-routes.md @@ -247,6 +247,13 @@ and SVG created later by scripts remain outside these routes. See the [provenance coverage limits](./configuration#placing-page-changes) before claiming page-wide coverage. +With Encypher's `execution = "async_prepared"`, a routed or rehosted file is +never signed during delivery. It carries provenance only after the preparation +worker prepared that exact path, as its own target or from a reader hint, and +the route prefix must be listed in the preparation `routes`. Preparing the page +that links the file does not prepare it. See +[Encypher prepared signing](./configuration#encypher-prepared-signing). + ```toml [[proxy.asset_routes]] prefix = "/_rehost/assets.example.com/library/" diff --git a/docs/guide/axum-dev.md b/docs/guide/axum-dev.md index d70213e2ea..7b5f0ede53 100644 --- a/docs/guide/axum-dev.md +++ b/docs/guide/axum-dev.md @@ -69,6 +69,32 @@ at different paths do not see each other's data. If the variable is set but the store cannot be opened, the server exits with an error. When it is unset, runtime KV reports unavailable and a warning is logged once. +Encypher's `execution = "async_prepared"` stores published results and +retirement markers in this store, so set `TRUSTED_SERVER_KV_PATH` for that +mode. + +## Listener address + +The bind address comes from `axum.toml`, or from `PORT`, which binds that port +on loopback. `TS_LISTEN_ADDR`, a full socket address such as `10.0.0.5:8080`, +overrides both, for example to reach the server from a private network. A +value that does not parse as a socket address exits with status `2`. + +## Preparation worker + +The same binary runs Encypher's publisher-operated preparation worker as a +separate long-lived process with its own store: + +```bash +trusted-server-axum preparation-worker --config worker.toml +``` + +Any other argument form prints the usage line and exits with status `2`; a +configuration, store or listener failure exits with status `1`. The worker +does not serve publisher traffic. Its configuration, secrets, readiness and +operator commands are described under +[Encypher prepared signing](./configuration#encypher-prepared-signing). + ## Runtime boundaries Axum sanitizes forwarded client-IP headers but does not resolve diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 76f78b9320..4573435a22 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2407,11 +2407,15 @@ generated configuration places it in. | `robots_txt` | `[[fetch]]` | Makes the `robots.txt` that `[robots-txt]` describes, in place of whatever the origin answers, on `text/plain` and at `/robots.txt` alone | | `service_version` | `[[serve]]` | Writes `X-TS-Version`, the git version the build compiled in, and `X-TS-Service-Version`, the platform's version number of the service, when each is known. Core supplies it, so no section selects it | -Encypher identifies a reusable signing plan by canonical article text, policy -and credential context. Page URL and title are deliberately not part of that -identity: the matching service deduplicates equal canonical text, so a reused -manifest can retain the first canonical source URL. Configure the middleware -only where that content identity is correct for the publisher. +In the default synchronous mode, Encypher identifies a reusable signing plan +by canonical article text, policy and credential context. Page URL and title +are deliberately not part of that identity: the matching service deduplicates +equal canonical text, so a reused manifest can retain the first canonical +source URL. Configure the middleware only where that content identity is +correct for the publisher. [Prepared signing](#encypher-prepared-signing) +binds the page URL and admitted variant into every identity instead, so its +results are page-bound. Prepared text identities also bind the page title; +changing only that title does not invalidate an unchanged media item. `max_document_bytes` also bounds the final signed HTML. The module counts inserted marker bytes and the escaped manifest meta element before allocating @@ -2431,9 +2435,10 @@ bounded media signing after this upgrade. The defaults are 5,000,000 bytes per item and 8 uncached embedded calls per document. There is no separate bounded media off switch; removing the module disables its article signing too. Deploy the matching API first and confirm media upload approval, API capacity -and cold-page latency before upgrading an enabled edge. The complete settings, -validation bounds and rollout contract are in the -[module design](../superpowers/specs/2026-10-24-encypher-provenance-module-design#_3-configuration). +and cold-page latency before upgrading an enabled edge. Repository maintainers +can find the complete settings, validation bounds and rollout contract in +`docs/superpowers/specs/2026-10-24-encypher-provenance-module-design.md`, +section 3. That internal design document is not published on this site. `article_selector` accepts a single `.class`, exact `[attr=value]` (optionally quoted) or tag name, up to 91 characters. Unsupported or malformed syntax @@ -2454,15 +2459,21 @@ It bounds uncached embedded-media signing calls in each HTML or CSS document. Cache hits do not consume the budget. Once exhausted, remaining items stay unchanged without a negative cache entry. Raising this value does not increase `max_hold_ms`, body limits or API rate limits; verify the complete cold page -under the deployed adapter before selecting a larger value. +under the deployed adapter before selecting a larger value. The budget governs +synchronous signing only; in `async_prepared` mode delivery makes no signing +calls. `max_large_media_bytes = 0` keeps prepared large-media signing off by default. -Enabling it requires the matching prepared API and signed probe, a -version-pinned source and a runtime timer that bounds the complete hashing and -signing operation under `max_large_media_ms`. Fastly has no such total timer, -so it refuses this lane even where the API supports the file. Its first-byte -and between-byte timeouts are not substitutes. A refused large-media signing -attempt delivers a fresh origin response without adding provenance. +In synchronous mode, enabling it requires the matching prepared API and signed +probe, a version-pinned source and a runtime timer that bounds the complete +hashing and signing operation under `max_large_media_ms`. Fastly has no such +total timer, so it refuses this lane even where the API supports the file. Its +first-byte and between-byte timeouts are not substitutes. A refused large-media +signing attempt delivers a fresh origin response without adding provenance. In +`async_prepared` mode the worker hashes outside delivery and delivery only +looks up the published result, so this timer requirement does not apply to the +lookup; the source requirements under +[prepared signing](#encypher-prepared-signing) do. Coverage follows the delivered bytes through configured publisher and asset routes. A page can still load unsigned media directly from a third-party frame @@ -2488,6 +2499,304 @@ names it. The sections below give each module's settings, and the integration guides describe what each one does. +### Encypher Prepared Signing + +**Section**: `execution` in `[provenance.encypher]`, with +`[provenance.encypher.preparation]` + +`execution = "synchronous"` is the default and keeps the behavior above. +`execution = "async_prepared"` moves signing out of reader delivery into a +publisher-operated native worker. Delivery looks up a result the worker +published for the exact text or bytes it is about to send, applies it, and +sends every other item unsigned. It never signs, prepares, finalizes or +uploads, and it never falls back to synchronous signing. The worker uses the +same `cwk_` credential, so the claim, host policy and signing identity are +those of synchronous mode; only where signing runs changes. + +Each identity binds the credential context (`api_base_url`, `domain` and the +worker token), the current policy digest, the page address +`https://` without its query, the admitted variant and the +exact item: projected article text with its title and boundary selector, or a +media item's carrier, type and source SHA-256. A changed item, policy or +credential therefore has a new identity and stays unsigned until the worker +prepares it. Article lookups use the cached policy digest; media lookups ask +the policy endpoint live, as synchronous media does, so a revoked credential +stops every published media result. + +```toml +[provenance.encypher] +api_base_url = "https://api.example.com" +domain = "publisher.example.com" +worker_token = "encypher_worker_token" +execution = "async_prepared" + +[provenance.encypher.preparation] +worker_url = "https://prep.publisher.example.com" +loopback_url = "https://publisher.example.com" +deployment_id = "publisher-preparation" +public_host = "publisher.example.com" +signal_secret = "encypher_preparation_signal" +publication_secret = "encypher_preparation_publication" +routes = ["/news/", "/media/"] +reader_trigger = false + +[[provenance.encypher.preparation.variants]] +name = "edition" +values = ["uk", "us"] +``` + +| Field | Default | Contract | +| -------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `worker_url` | Required | Worker origin: `https`, or `http` on `127.0.0.1`, `localhost` or `[::1]`; no path, query or user information. Receives hints and source reads | +| `loopback_url` | Required | This deployment's own public origin, same rules. Snapshots are read through it, so they take the reader pipeline | +| `deployment_id` | Required | 1 to 64 lower-case letters, digits or `-`; equal to the worker's | +| `public_host` | Required | `domain` or its `www.` name. Every identity names it, so a reader on another host finds no result | +| `signal_secret` | Required | Secret-store entry name. Its value authorizes reader hints only | +| `publication_secret` | Required | Secret-store entry name. Its value authorizes snapshots, result and marker publication, and public source reads | +| `reader_trigger` | `false` | Send a bounded hint to the worker for unprepared items | +| `admission_ms` | `50` | `10..=200`; the longest a hint waits on an adapter with a timer | +| `routes` | Required | 1 to 64 path prefixes, matched as string prefixes, so `/news` also matches `/newsletter`. Only these paths are snapshotted or hinted | +| `variants` | `[]` | Up to 8 query parameters, each with 1 to 32 values; names and values are 1 to 64 letters, digits, `-`, `_` or `.` | + +`async_prepared` without the table, a `public_host` outside the claim, or +`reader_trigger = true` on an adapter that offers neither a timer nor an +after-response lifetime refuses startup. Results and retirement markers live +in the runtime KV store as `encypher:prep:v1:result:` and +`encypher:prep:v1:retired:`, so the delivery instance needs runtime +KV (`TRUSTED_SERVER_KV_PATH` on Axum). The module also serves +`/_encypher/preparation/snapshot`, `/_encypher/preparation/results/` +and `/_encypher/preparation/markers/` to the worker, authenticated +by the publication secret, and the public +`/_encypher/preparation/source/`. + +Prepared text signing and recovery require `"cache_scope": "exact_request"` +alongside the frozen policy digest. The API then reuses only a result for +that exact request, including its title, page URL and boundary selector, +rather than one with the same text and rights alone. The worker retains +this scope in every publication envelope; missing or unknown scopes are +refused, never treated as legacy signing. Synchronous signing keeps its +existing request and cache behavior. + +The text identity hash domain is `encypher-preparation/2:text`. Results made +under the former text cache contract cannot satisfy current reader lookups +or be published under an old identity. The preparation protocol and media +identities are unchanged, so this change alone does not require media to +be signed again. Upgrade the worker and delivery instance together against +an API that supports exact-request cache isolation. Retained legacy text +envelopes without the required scope also fail closed during recovery; +they are not migrated by assigning a scope to an old receipt. + +#### Run the worker + +```bash +trusted-server-axum preparation-worker --config worker.toml +``` + +Start from `crates/trusted-server-preparation-worker/worker.example.toml`. +`deployment_id`, `api_base_url`, `domain`, `public_host`, `article_selector`, +`min_text_length`, `max_media_bytes` and `variants` must equal the delivery +settings. `edge_url` is the delivery origin whose `/_encypher/preparation/` +routes the worker calls. `[secrets]` names environment variables, never +values: `worker_token` (the `cwk_` credential), `signal_secret` and +`publication_secret` (equal to the delivery secret-store values) and +`operator_secret` (for `ts preparation`). The four values must differ. The +loader also requires `workers` `1..=4`, `max_snapshot_bytes` at most 64 MiB and +not below `max_media_bytes`, `max_preparation_seconds` `1..=3600`, +`scan_per_minute` `1..=128`, `scan_interval_seconds` `60..=900`, and at most +1,024 `[[inventory]]` entries. Registered inventory capacity is the lesser of +1,024 and `scan_per_minute` times the whole minutes in +`scan_interval_seconds`. At one target per minute and 900 seconds, capacity +is 15 targets; the defaults of 128 and 900 retain the 1,024-target limit. +The same capacity covers distinct configured targets, `--register` additions +and retained inventory. Startup refuses a store above the configured capacity +without deleting its records; raise the scan capacity to reopen it. + +A second process cannot open `store_dir/preparation.redb`, and the worker +refuses to start without it. `GET /v1/preparation/ready` answers `503` until +unfinished jobs are recovered, the delivery instance accepts the publication +secret, the API answers a policy for `https:///`, and a signed +probe release loads: a fresh store fetches one through the API's media +capabilities, and a store with retained work reloads its own. The worker +retries every 10 seconds, logging `preparation: not ready` with the failing +codes, and prepares nothing until ready. It drains on `SIGINT`; any other stop +is a crash that the next start recovers by reconciling byte reservations and +reclaiming unfinished claims. Delivery must reach `worker_url`, and the worker +must reach `edge_url` and `api_base_url`. The worker's warm, retire and status +routes require the operator secret. + +#### Supply the inventory + +The worker prepares only the targets it is given. It does not crawl the +origin, read sitemaps or discover uploads. A target is a path and an optional +variant. Supply the full inventory through `[[inventory]]` entries, which are +registered at every start, through `ts preparation warm --register`, or from a +publication hook that runs `ts preparation warm` for each changed path. + +```bash +export TS_PREPARATION_OPERATOR_SECRET='' +ts preparation warm --worker-url https://prep.publisher.example.com --register \ + --target /news/harbor-vote --target /news/budget-2027 --target /media/flood.jpg +``` + +The CLI has no file option. For a publisher-supplied list, one path per line: + +```bash +args=() +while IFS= read -r path; do args+=(--target "$path"); done < targets.txt +ts preparation warm --worker-url https://prep.publisher.example.com --register "${args[@]}" +``` + +One call takes up to 1,024 targets (`413 too_many_targets` above that). Every +target in a call shares `--variant`, which must be the canonical admitted form, +pairs sorted by name such as `--variant edition=uk`; run one call per variant. +The worker URL must be `https`, or `http` on a loopback host. The command +prints one outcome per target: `queued`, `coalesced` (merged into a waiting +job), `damped` (published, retired or suppressed), `queue_full`, or +`not_admitted` (a malformed path or a variant the worker's schema refuses). It +exits nonzero when any target was `queue_full` or `not_admitted`; repeating the +call is safe. `--retry` first clears the targets' suppressed outcomes. +`--register` records the admitted targets before queueing them, so a +`queue_full` target stays registered. Registration only adds: no command +removes a target, and deleting an `[[inventory]]` entry does not unregister +it. A full inventory answers `409 inventory_full` without partially registering +the batch. Duplicate targets do not consume additional capacity. + +Once a minute the worker queues up to `scan_per_minute` registered targets not +reconciled within about `scan_interval_seconds`, from a persisted cursor. A +reconciliation prepares only items its target's previous snapshot also held, +so a new or changed item is prepared on the second pass that sees it; a warm +prepares the current snapshot at once. + +A page target covers its article text and the eligible `data:` URI media and +inline SVG in that page. A stylesheet target under a +`provenance.encypher.stylesheet` entry covers its embedded `data:` media. A +media target covers that file. Images, video, fonts and stylesheets that a page +links by URL are separate resources: each needs its own target, or a reader +request that hints it. A target must lie under `routes` and reach a +`[[serve]]` entry that names the module or, for media, a publisher or asset +route; otherwise the snapshot is refused as `target_not_configured` or +`snapshot_unobserved`. + +#### Reader hints + +With `reader_trigger = false`, delivery only looks up results. With `true`, a +document or standalone item with misses sends one hint of at most 8 KiB, for a +path under `routes` with an admitted variant. A hint is never retried or +stored on the edge; only the worker's admission is durable, and warming and +reconciliation recover a lost hint. A hint prepares only the items the reader +saw, and only while a fresh snapshot still holds them; otherwise the item is +recorded as `source_unstable` for five minutes. + +| Adapter | Hint lifetime | +| ------------------------------- | ---------------------------------------------------------------------------------------------- | +| Axum | Real timer; the hint waits at most `admission_ms` | +| Cloudflare Workers (WASM) | Real timer; the hint waits at most `admission_ms`. `waitUntil` is not used | +| Spin (WASM) | Real timer; the hint waits at most `admission_ms` | +| Fastly | No timer. The hint is sent after the response, at most 8 per response, best effort | +| Native Cloudflare or Spin build | No timer and no after-response lifetime: `reader_trigger = true` refuses startup; `false` runs | + +Delivery still reads the runtime KV store and the policy endpoint, so no fixed +latency is promised. Where the adapter has a timer, `max_hold_ms` bounds that +work; on Fastly only backend timeouts do. Only the Axum adapter has run this +mode against a live worker and API. + +#### Media + +The worker hashes media locally and never uploads it. All media preparation +uses worker contract version 2, so it needs an API that advertises that +version; text does not. Embedded `data:` media and inline SVG up to +`max_media_bytes` get an embedded manifest, and their source is named by a +digest-addressed publisher snapshot at +`https:///_encypher/preparation/source/`. A bounded +standalone file keeps its bytes and gets a detached manifest named by a `Link` +header. Its source is named by its fetched `https` origin URL where that URL +has no query, and by a publisher snapshot otherwise. Delivery serves a +snapshot only after the page and asset passed public admission, as the exact +bytes with `Content-Disposition: attachment` and a sandbox CSP. A copy of a +standalone file taken without that `Link` carries no provenance. + +Large items need `max_large_media_bytes` above zero on both delivery and +worker. A source larger than the whole-snapshot bound is read in ranges pinned +to its strong `ETag` and length, hashed whole under `store_dir/large` and +signed with a detached manifest. Delivery decorates the origin's own answer +when the configured origin, type, length, `ETag` and query match. The fetched +source URL must be `https` without a query (`source_reference_required` +otherwise), so a query-forwarding route cannot use this lane, and the reader's +raw query must equal its canonical admitted variant. Reuse trusts the origin's +strong validators; an `ETag` is not a cryptographic proof. + +Privacy and existing provenance fail closed. A snapshot the origin marked +private (`source_private`), one with its own manifest `Link` +(`source_has_provenance_link`), `no-transform` (`no_transform`) or a content +coding (`content_encoded`) is refused and not prepared. + +#### Status and capacity + +```bash +ts preparation status --worker-url https://prep.publisher.example.com +``` + +The answer has `ready`; `counts` of `pending` and `processing` jobs, +`artifacts`, `publication_pending`, `publication_acknowledged`, +`retirement_pending`, `retirement_acknowledged`, `inventory` and +`reserved_bytes`; `active_refusals_by_code`; and `capacity`: 1,024 `jobs`, of +which 768 `reader_jobs` (256 slots are kept from reader hints), 8 MiB +`queue_bytes`, 6 MiB `reader_queue_bytes`, the configured scan capacity as +`inventory_targets` (at most 1,024), and `artifact_bytes`, the configured +`artifact_quota_bytes`. + +A transient failure retries with backoff, up to six attempts within 24 hours, +then is suppressed for one hour. An integrity, privacy, unsupported or +existing-provenance refusal stays until the item's identity changes or +`warm --retry`. `active_refusals_by_code` counts these retained outcomes. It +also keeps one diagnostic outcome per extraction refusal reason and exact page +version, such as `malformed_data_uri`, while valid siblings still prepare. The +counts record what extraction saw; they are not proof that every medium on a +page was covered, because linked media appear only when targeted. The worker +logs each item refusal as `preparation: item refused kind=... code=...`. + +`artifact_quota_bytes` is a hard reservation over sources, temporary large +files, probes, plans, outputs and their metadata, taken before work starts. A +full quota refuses new work with `quota_exhausted` and never evicts retained +recovery material; raise the quota and restart, or retire identities. A full +queue answers `queue_full`. + +#### Retirement + +```bash +ts preparation retire --worker-url https://prep.publisher.example.com --identity <64 hex> +``` + +An identity is the suffix of its delivery KV key +`encypher:prep:v1:result:`. Retirement records a tombstone, raises +the retirement epoch, drops the item's artifact and plan, and queues a marker. +Each one-minute housekeeping pass attempts up to 16 queued markers until the +delivery instance acknowledges them. Bulk retirement and failed deliveries +can leave markers pending for multiple passes. The command answers `marker` +`pending` or `acknowledged`, and `status` counts both. A reader refuses a +result whose marker is present or unreadable. Acknowledgement means the +delivery KV write succeeded, not that +every KV replica, downstream cache or downloaded copy changed. Hints, warming +and reconciliation never prepare a retired identity again; changed content has +a new identity. + +Policy withdrawal is separate from an identity's retirement marker. Text +lookups retain the existing `policy_ttl_seconds` policy cache; media lookups +request current policy. Neither path makes withdrawal retroactive for copies +already downloaded. + +#### Durable state + +`store_dir` holds `preparation.redb` and the retained files: the only durable +copy of the queue, inventory, outcomes, tombstones, marker outbox and +artifacts. When a hint, warm or reconciliation finds a published result +missing from the delivery KV store, the worker republishes it from its +artifact without signing again. Back up `store_dir` as one unit while the +worker is stopped, restore it to the same path, and do not run a restored copy +beside the original. Never delete `encypher:prep:v1:retired:` entries from the +delivery KV store. Changing the worker token, `api_base_url` or `domain` +changes every identity, so all content is prepared again. + ### DataDome Integration **Section**: `[bot-protection.datadome]` diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 3a098b6e06..7ea4f938a1 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -30,6 +30,16 @@ yielding each 8 KiB chunk. Fastly's host `select` operation does not preserve the per-request limit metadata, so the adapter rejects bounded or streaming pending handles passed to `select`; wait for those handles directly. +With Encypher's `execution = "async_prepared"`, delivery makes no signing +call. Lookups of published results and the live media policy check have the +transport bounds above and no elapsed deadline. A large-media lookup needs no +timer, because the worker already hashed the source. With +`reader_trigger = true`, a hint is handed to the after-response lifetime: the +adapter sends at most 8 handed-over requests of at most 8 KiB each after the +reader's response, best effort and never retried. This hint path has not run +against a live worker. See +[Encypher prepared signing](./configuration#encypher-prepared-signing). + ## Create a Fastly Account 1. Go to [manage.fastly.com](https://manage.fastly.com) and create an account if you don't have one diff --git a/docs/guide/spin.md b/docs/guide/spin.md index d58fc11167..24a8074c6a 100644 --- a/docs/guide/spin.md +++ b/docs/guide/spin.md @@ -129,6 +129,17 @@ whole store to remove Encypher entries. In particular, the current `spin cloud key-value` plugin can delete a store but cannot enumerate and delete individual entries safely. +With `execution = "async_prepared"`, the same store also holds published +results under `encypher:prep:v1:result:` and retirement markers under +`encypher:prep:v1:retired:`; probe releases for result verification are cached +under `encypher:probe:v1:`, as in the large-media lane. Results and markers +carry no `expires_at`. Never delete a retirement marker: a reader refuses a +retired result only while its marker is present. A result the preparation +worker still retains is republished when a hint, warm or reconciliation finds +it missing. The WASM component has a real timer, so it accepts +`reader_trigger = true`; a native host build has none and refuses it at +startup. + This cache split retires the old `encypher:v3:` article prefix and `encypher:policy:v2:` policy prefix. They are deletion candidates only after the same checks below. Before deleting `encypher:v3:`, wait the greater of diff --git a/docs/superpowers/specs/2026-10-24-encypher-provenance-module-design.md b/docs/superpowers/specs/2026-10-24-encypher-provenance-module-design.md index 368b39d9af..6d3a512ba9 100644 --- a/docs/superpowers/specs/2026-10-24-encypher-provenance-module-design.md +++ b/docs/superpowers/specs/2026-10-24-encypher-provenance-module-design.md @@ -2,9 +2,11 @@ Status: implemented scope on `encypher/all-media-provenance`, extending the text-provenance module described below. Local Axum, real HTTPS API and browser -verification covers the routed subset (section 4). Full repository landing -gates remain separate. This document claims neither complete page-media -coverage nor a production deployment. +verification covers the routed subset (section 4). Opt-in asynchronous +prepared execution (section 2.6) is implemented on +`encypher/async-prepared-signing`. Full repository landing gates remain +separate. This document claims neither complete page-media coverage nor a +production deployment. ## 1. Problem @@ -100,11 +102,12 @@ to move onto those interfaces, and the core seams it needs have to be neutral. `encypher:policy:v3:` and `encypher:policy-skip:v3:`. Malformed or structurally invalid entries are deleted best effort. An alignment failure for one HTML representation does not delete a valid plan. -- **Content identity** remains the policy digest, credential context and - canonical article-text hash, not the page URL. The matching Encypher server - contract deliberately deduplicates equal canonical text across URL or title - changes. A reused plan's manifest URL can therefore retain the first - canonical source URL. +- **Content identity** in the default synchronous mode remains the policy + digest, credential context and canonical article-text hash, not the page + URL. The matching Encypher server contract deliberately deduplicates equal + canonical text across URL or title changes. A reused plan's manifest URL can + therefore retain the first canonical source URL. Prepared text identities + (section 2.6) also bind the page URL, title and admitted variant. - **Original SVG preservation** uses `embedded::scan_html` and its existing namespace-aware provenance/equivalence classifier for both source protection and signing decisions. A carrier or provenance reference after an editable @@ -148,11 +151,13 @@ time and output-size limits still apply. The prepared large-media lane is opt-in (`max_large_media_bytes = 0` by default). It hashes a version-pinned source in bounded chunks and requests detached provenance without uploading or buffering the complete media. -Admission requires the source/version checks, matching prepared API and signed -probe, streaming support, and one enforceable wall-clock budget for hashing -and signing. Fastly supplies no such total timer, so this lane stays closed -there even when the API can sign the source. A refusal delivers an unsigned -fresh origin response; it does not weaken deadline or source-binding guards. +In synchronous mode, admission requires the source/version checks, matching +prepared API and signed probe, streaming support, and one enforceable +wall-clock budget for hashing and signing. Fastly supplies no such total +timer, so this lane stays closed there even when the API can sign the source. +A refusal delivers an unsigned fresh origin response; it does not weaken +deadline or source-binding guards. In prepared execution the worker hashes and +delivery only looks up the result (section 2.6). ### 2.4 Permission contract @@ -176,6 +181,36 @@ processor factory and request context, the shared-template refusal (the serve phase runs on each reader's assembled copy), and the synchronous publisher body path. +### 2.6 Asynchronous prepared execution + +`execution = "async_prepared"`, with `[provenance.encypher.preparation]`, is +opt-in; `synchronous` stays the default. A publisher-operated native worker +(`crates/trusted-server-preparation-worker`, launched as +`trusted-server-axum preparation-worker --config worker.toml`) owns one +exclusive redb store: queue, fenced leases, bounded retries, byte quota, +retained sources, probes and results, inventory reconciliation and a +retirement outbox. It reads snapshots through the delivery instance's reader +pipeline, extracts items with the same code delivery runs, hashes media +locally under worker contract version 2, signs through the API, and asks the +delivery instance to publish each result. Delivery recomputes the identity, +recovers the exact receipt from the API without signing and checks it against +the actual source before storing it under an immutable identity in runtime KV. + +Reader delivery only looks up results bound to the exact item, page URL, +admitted variant, policy and credential context. Text identities also bind +the page title and boundary selector. Delivery never signs, prepares, +finalizes, uploads or falls back to synchronous signing; a miss is delivered +unsigned. Optional reader hints (`reader_trigger`) are bounded and +best effort: Axum, and Cloudflare and Spin WASM builds, race the hint against +a real timer (`admission_ms`); Fastly sends it after the response; Cloudflare +does not use `waitUntil`; native Cloudflare and Spin builds refuse +`reader_trigger = true`. Coverage comes from supplied targets, registered +inventory and publication-triggered warming, not from crawling or upload +discovery. Large sources need a query-free `https` fetched URL and strong +validators; no path uploads source bytes. The +[configuration guide](../../guide/configuration.md#encypher-prepared-signing) +holds the operator contract. + ## 3. Configuration ```toml @@ -213,6 +248,9 @@ The settings below belong to `[provenance.encypher]`. Unknown keys are refused. | `max_large_media_bytes` | `0` | Disabled at `0`; otherwise `16777216..=9007199246352383` bytes | | `max_large_media_ms` | `60000` | `5000..=600000`; one budget for namespace resolution, cache lookup and newly launched hash/sign work | +`execution` defaults to `synchronous`; `async_prepared` requires +`[provenance.encypher.preparation]` (section 2.6). + ### Upgrade and rollout An existing deployment selecting the Encypher module begins sending eligible @@ -291,6 +329,10 @@ behavior. Verification using a locally supplied detached manifest does not prove that the emitted manifest URL is publicly retrievable. Those are distinct deployment checks, not results established by this run. +Prepared execution has actual-network evidence only on the native Axum +delivery instance with the native worker, a real API and signed probe. The +Fastly, Cloudflare and Spin hint paths have not run against a live worker. + ## 5. Limits and operator obligations 1. Spin's declared KV store is `default`, which also holds the settings. @@ -298,20 +340,26 @@ distinct deployment checks, not results established by this run. TTL, so `expires_at` prevents stale reads and on-read deletion removes encountered stale entries, but abandoned content keys need the namespace-retirement procedure in the Spin deployment guide. -2. Holding the page delays its first byte by the signing time on a cache miss. - `max_hold_ms` is a total module bound on Cloudflare, Spin and Axum. Fastly - has only first-byte and between-byte transport bounds, as described above. +2. In synchronous mode, holding the page delays its first byte by the signing + time on a cache miss. `max_hold_ms` is a total module bound on Cloudflare, + Spin and Axum. Fastly has only first-byte and between-byte transport bounds, + as described above. Prepared execution holds no signing work, but its + lookups have no fixed latency bound either. 3. The module skips pages the origin marks private, but cannot detect other personalized pages. A deployment places it on public article routes. 4. Routes cover only bytes that pass through them. Direct third-party frame or script requests and SVG created later by scripts require separate evidence and are not covered by this implementation. -5. Enabling a large-media size limit does not make the lane available without - an enforceable total deadline. Fastly remains closed; do not substitute its - first-byte or between-byte timeout for that deadline. +5. Enabling a large-media size limit does not make the synchronous lane + available without an enforceable total deadline. Fastly remains closed; do + not substitute its first-byte or between-byte timeout for that deadline. 6. A detached-format deployment must prove public retrieval of its emitted manifest URL. A local sidecar backchannel proves neither public routing nor deployment availability. +7. Prepared execution covers only supplied targets and hinted items. A page + target covers its text and inline media; linked media needs its own target + or hint. Worker refusal counts are retained outcomes, not proof of page + coverage. ## 6. Landing evidence checklist diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 64c317301a..ff6509b6f5 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -930,6 +930,30 @@ auction_timeout_ms = 500 # api_base_url = "https://api.example.com" # domain = "publisher.example.com" # worker_token = "encypher_worker_token" +# +# Optional background preparation, instead of signing on reader delivery. +# Leave this unset for the existing synchronous mode. Async mode never +# falls back to synchronous signing: a missing result stays unsigned. +# Run a publisher-owned native worker using +# crates/trusted-server-preparation-worker/worker.example.toml. +# execution = "async_prepared" +# +# [provenance.encypher.preparation] +# worker_url = "https://prep.publisher.example.com" +# loopback_url = "https://publisher.example.com" +# deployment_id = "publisher-preparation" +# public_host = "publisher.example.com" +# signal_secret = "encypher_preparation_signal" +# publication_secret = "encypher_preparation_publication" +# routes = ["/news/", "/media/"] +# reader_trigger = false +# +# Register publisher URLs with `ts preparation warm --register`, or put +# them in the worker's inventory. Publish/update jobs can warm changed URLs. +# Inventory reconciliation is independent of reader traffic; no eight-page +# limit applies, but worker capacity is bounded. This does not crawl origins. +# Set reader_trigger = true only to add best-effort hints on cache misses. +# The two secret-store values must match the worker's corresponding scopes. # Permutive DMP. `organization_id` and `workspace_id` are required. # [audience] From 99dee52d496f24cce2ebdba2de68c7d2c6dec191 Mon Sep 17 00:00:00 2001 From: Erik Svilich Date: Mon, 2 Nov 2026 22:59:48 +0000 Subject: [PATCH 17/17] fix(encypher): bind prepared snapshots to the public cache origin --- CHANGELOG.md | 1 + crates/provenance/encypher/src/lib.rs | 53 +++++- .../encypher/src/preparation/items.rs | 22 ++- .../encypher/src/preparation/mod.rs | 5 +- .../encypher/src/processor/published.rs | 10 +- crates/provenance/encypher/src/tests.rs | 179 ++++++++++++++++++ docs/guide/configuration.md | 17 +- 7 files changed, 273 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e9c1d003..7f3f3a96ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prepared Encypher text requests now require exact-request API cache isolation for signing, recovery and publication, so changing only a title or page URL cannot reuse an older text-and-rights-only result. The text identity hash domain is versioned to exclude old prepared records; media identities and synchronous signing remain unchanged. Missing or unknown text cache scopes are refused without a legacy fallback. - Preparation diagnostics retain one extraction-refusal outcome per admitted page scope and reason. Unrelated HTML changes, such as comments or titles, no longer accumulate permanent duplicate outcomes or consume additional diagnostic quota. - Preparation inventory capacity now follows the configured scan rate and whole-minute interval, up to 1,024 targets. Dynamic registration and restart enforce the same bound as configured inventory. A retained store above that capacity refuses startup without deleting records. Intervals below 60 seconds are refused because housekeeping runs once a minute; the default settings still admit 1,024 targets. +- Async preparation with a selected asset-cache module requires snapshots through `https://`, so preparation addresses the reader's held-copy cache. The equivalent trailing slash and `:443` forms are normalized; other origins refuse configuration and startup. Cache-free local development and synchronous mode are unchanged. Reader text lookup computes its shared identity without constructing an owned signing request. - Encypher can sign bounded media with source ingredients and executed transformation actions. The opt-in large-media lane hashes a version-pinned origin locally and uses prepared signing without uploading or buffering the complete asset. It retains existing embedded, XMP-referenced and HTTP-linked provenance rather than publishing a competing manifest. Enable a bounded `max_large_media_bytes` only after the matching signed probe and backend release are available and the adapter can enforce the whole-operation deadline; the default is `0`. Fastly has no total timer and keeps this lane closed. - Encypher's `max_media_sign_calls_per_document` limits uncached embedded-media signing calls per HTML or CSS document. It defaults to 8 and accepts 1-128. Cache hits do not consume calls; exceeding the limit leaves the remaining media unchanged without negatively caching it. Raising the limit does not extend the document's time or output-size bounds. - Large-media namespace resolution, cache lookup and new hash/sign work share one remaining-time budget. An already exhausted budget starts no hashing. Malformed `article_selector` overrides now fail settings admission instead of silently choosing the normal article boundary; empty and valid-but-absent overrides keep that fallback. diff --git a/crates/provenance/encypher/src/lib.rs b/crates/provenance/encypher/src/lib.rs index 6812c22c9f..622bbf606a 100644 --- a/crates/provenance/encypher/src/lib.rs +++ b/crates/provenance/encypher/src/lib.rs @@ -377,6 +377,56 @@ fn require_claimed_public_host(config: &EncypherConfig) -> Result<(), Report bool { + settings + .sections + .section(trusted_server_core::asset_cache::MODULE_TYPE) + .is_some_and(|modules| !modules.selected().is_empty()) +} + +/// With an asset cache selected, requires an `async_prepared` table's +/// `loopback_url` to be `https://`, so a snapshot reads the +/// cached copy a reader would get, under the host and scheme the cache keys +/// it by. A trailing `/` and the default port `:443` are the only other +/// spellings admitted, and the registered origin drops both, so a snapshot +/// addresses exactly what a reader does. Synchronous mode, and an +/// `async_prepared` deployment with no asset cache, keep the general origin +/// rules, including local `http` on a loopback host. +fn require_public_snapshot_origin( + settings: &Settings, + config: &mut EncypherConfig, +) -> Result<(), Report> { + if config.execution != preparation::Execution::AsyncPrepared || !selects_asset_cache(settings) { + return Ok(()); + } + let Some(preparation) = config.preparation.as_mut() else { + return Ok(()); + }; + let written = preparation.loopback_url.as_str(); + let origin = written.strip_suffix('/').unwrap_or(written); + let authority = origin.strip_prefix("https://"); + if authority.is_some_and(|authority| { + authority == preparation.public_host + || authority.strip_suffix(":443") == Some(preparation.public_host.as_str()) + }) { + preparation + .loopback_url + .truncate("https://".len() + preparation.public_host.len()); + return Ok(()); + } + Err(Report::new(TrustedServerError::Settings { + message: format!( + "[provenance.encypher.preparation] loopback_url must be `https://{}` while \ + [asset-cache] selects a module: snapshots read through it, and the asset cache \ + keys each held copy by the host and scheme a reader asked, so any other origin \ + would prepare content readers are not served", + preparation.public_host + ), + })) +} + /// What the platform the module registers on can do for a reader's hint, /// as its adapter declared before serving. #[derive(Debug, Clone, Copy)] @@ -476,12 +526,13 @@ fn require_scoped_stylesheet_entries( /// The module's settings when a section selects it. fn config(settings: &Settings) -> Result, Report> { - let Some(config) = settings.module_config::(MODULE)? else { + let Some(mut config) = settings.module_config::(MODULE)? else { return Ok(None); }; require_an_entry(settings)?; require_scoped_stylesheet_entries(settings)?; require_claimed_public_host(&config)?; + require_public_snapshot_origin(settings, &mut config)?; Ok(Some(config)) } diff --git a/crates/provenance/encypher/src/preparation/items.rs b/crates/provenance/encypher/src/preparation/items.rs index c653e23a9f..35d559272f 100644 --- a/crates/provenance/encypher/src/preparation/items.rs +++ b/crates/provenance/encypher/src/preparation/items.rs @@ -113,20 +113,30 @@ pub struct DocumentItems { pub refused: Vec<&'static str>, } -/// The text item of `article` in `html`. +/// The identity of an article, without constructing an owned signing request. #[must_use] -pub(crate) fn text_item(context: &ItemContext<'_>, html: &str, article: &Article) -> TextItem { - let title = text::title_from_range(html, article.title_range); - let identity = TextIdentity { +pub(crate) fn text_identity( + context: &ItemContext<'_>, + article: &Article, + document_title: Option<&str>, +) -> String { + TextIdentity { credential_context: context.credential_context, policy_digest: context.policy_digest, page_url: context.page_url, variant: context.variant, text: &article.text, - document_title: title.as_deref(), + document_title, boundary_selector: &article.boundary.selector, } - .digest(); + .digest() +} + +/// The text item of `article` in `html`. +#[must_use] +pub(crate) fn text_item(context: &ItemContext<'_>, html: &str, article: &Article) -> TextItem { + let title = text::title_from_range(html, article.title_range); + let identity = text_identity(context, article, title.as_deref()); TextItem { identity, request: TextSignRequest { diff --git a/crates/provenance/encypher/src/preparation/mod.rs b/crates/provenance/encypher/src/preparation/mod.rs index 6e599260c0..82b5c7d014 100644 --- a/crates/provenance/encypher/src/preparation/mod.rs +++ b/crates/provenance/encypher/src/preparation/mod.rs @@ -53,7 +53,10 @@ pub struct PreparationConfig { #[validate(custom(function = validate_service_url))] pub worker_url: String, /// The origin the delivery instance reads its own snapshots from: its - /// public `https` address, or `http` on a loopback host. + /// public `https` address, or `http` on a loopback host. While + /// `[asset-cache]` selects a module, exactly `https://`, with + /// at most a trailing `/` or the default port `:443`, so a snapshot reads + /// the copy the asset cache holds for readers. #[validate(custom(function = validate_service_url))] pub loopback_url: String, /// The deployment's identifier, which every message carries. diff --git a/crates/provenance/encypher/src/processor/published.rs b/crates/provenance/encypher/src/processor/published.rs index 1bce7200c9..0c895242e8 100644 --- a/crates/provenance/encypher/src/processor/published.rs +++ b/crates/provenance/encypher/src/processor/published.rs @@ -40,7 +40,7 @@ use crate::INTEGRATION_ID; use crate::api; use crate::media_api::{canonical_media_type, sha256_hex, valid_origin_host}; use crate::preparation::items::{ - DocumentKind, EmbeddedItem, ItemContext, embedded_edit, embedded_items, text_item, + DocumentKind, EmbeddedItem, ItemContext, embedded_edit, embedded_items, text_identity, }; use crate::preparation::protocol::{ Carrier, ItemKind, LargeLocator, MediaIdentity, ObservedItem, PREPARATION_PROTOCOL, @@ -48,6 +48,7 @@ use crate::preparation::protocol::{ marker_key, result_key, }; use crate::preparation::{Execution, PreparationConfig}; +use crate::text; /// What one lookup found. enum Lookup { @@ -163,8 +164,9 @@ impl Signer { }; if let Some(policy_digest) = policy_digest { let context = self.item_context(&page_url, &policy_digest, lookup_variant); - let item = text_item(&context, document, article); - match self.published(kv, &item.identity, work).await { + let title = text::title_from_range(document, article.title_range); + let identity = text_identity(&context, article, title.as_deref()); + match self.published(kv, &identity, work).await { Ok(Lookup::Found(PublishedResult::Text { plan, manifest_url, @@ -192,7 +194,7 @@ impl Signer { log_outcome(false, Reason::NotPrepared); misses.push(ObservedItem { kind: ItemKind::Text, - identity: item.identity, + identity, }); } Err(reason) => log_outcome(false, reason), diff --git a/crates/provenance/encypher/src/tests.rs b/crates/provenance/encypher/src/tests.rs index 4ba445c2d8..5ad95b5baa 100644 --- a/crates/provenance/encypher/src/tests.rs +++ b/crates/provenance/encypher/src/tests.rs @@ -1819,6 +1819,185 @@ fn async_mode_needs_its_table_a_claimed_public_host_and_a_real_hint_lifetime() { ); } +/// An asset cache module a build links beside this one, under this name. +const HELD_COPIES: &str = "asset-cache.held-copies"; + +/// An asset cache that leaves every file to its origin: these tests need +/// only the registry to take it once `[asset-cache]` selects it. +struct HeldCopies; + +#[async_trait::async_trait(?Send)] +impl trusted_server_core::asset_cache::AssetCacheModule for HeldCopies { + async fn answer( + &self, + _services: &RuntimeServices, + _file: trusted_server_core::asset_cache::AssetFile<'_>, + ) -> trusted_server_core::asset_cache::AssetAnswer { + trusted_server_core::asset_cache::AssetAnswer::AskOrigin + } +} + +fn held_copies_register( + _settings: &Settings, +) -> Result, Report> { + Ok(Some( + IntegrationRegistration::builder("held_copies") + .with_asset_cache_module(HELD_COPIES, Arc::new(HeldCopies)) + .build(), + )) +} + +fn held_copies_validate(settings: &Settings) -> Result> { + Ok(settings.selects_module(HELD_COPIES)) +} + +/// The builders of a deployment that links this module and an asset cache. +fn linked_with_an_asset_cache() -> [IntegrationBuilder; 2] { + [ + builder(), + IntegrationBuilder::new( + "held_copies", + "held-copies", + held_copies_register, + held_copies_validate, + ) + .with_module_name(HELD_COPIES), + ] +} + +/// A placed deployment in `execution` whose preparation table reads +/// snapshots from `loopback_url`, with `[asset-cache]` selecting the linked +/// asset cache when `cached`. +fn snapshot_origin_settings( + execution: &str, + public_host: &str, + loopback_url: &str, + token: &str, + cached: bool, +) -> Settings { + let table = format!( + "api_base_url = \"https://api.example.com\"\ndomain = \"publisher.example.com\"\n\ + worker_token = \"{token}\"\nexecution = \"{execution}\"\n\n\ + [provenance.encypher.preparation]\n\ + worker_url = \"https://prep.publisher.example.com\"\n\ + loopback_url = \"{loopback_url}\"\ndeployment_id = \"test\"\n\ + public_host = \"{public_host}\"\n\ + signal_secret = \"signal-secret-for-tests\"\n\ + publication_secret = \"{PUBLICATION}\"\nroutes = [\"/news/\"]\n" + ); + let mut settings = page_settings(&module_settings(&table, true)); + if cached { + settings.select_module("asset-cache", HELD_COPIES); + } + settings +} + +/// Whether config validation, the module's registration and the registry an +/// adapter builds at startup each admit `settings`. +fn admitted_everywhere(settings: &Settings) -> [bool; 3] { + [ + validate(settings).is_ok(), + register(settings).is_ok(), + IntegrationRegistry::with_registrations(settings, &linked_with_an_asset_cache()).is_ok(), + ] +} + +#[test] +fn with_an_asset_cache_async_snapshots_must_read_the_public_https_origin() { + const APEX: &str = "publisher.example.com"; + const WWW: &str = "www.publisher.example.com"; + for (public_host, loopback_url) in [ + (APEX, "https://publisher.example.com"), + (APEX, "https://publisher.example.com/"), + (APEX, "https://publisher.example.com:443"), + (APEX, "https://publisher.example.com:443/"), + (WWW, "https://www.publisher.example.com"), + (WWW, "https://www.publisher.example.com:443/"), + ] { + let settings = + snapshot_origin_settings("async_prepared", public_host, loopback_url, TOKEN, true); + assert_eq!( + admitted_everywhere(&settings), + [true; 3], + "{loopback_url} is the reader origin of {public_host}" + ); + assert!( + register(&settings).expect("should register").is_some(), + "{loopback_url} should register the module" + ); + let admitted = config(&settings) + .expect("should admit the public origin") + .expect("should select the module"); + assert_eq!( + admitted + .preparation + .expect("should retain preparation") + .loopback_url, + format!("https://{public_host}"), + "equivalent origins must normalize to one cache-key authority" + ); + } + + for (public_host, loopback_url) in [ + (APEX, "http://127.0.0.1:8181"), + (APEX, "http://localhost:8181"), + (APEX, "http://[::1]:8181"), + (APEX, "http://publisher.example.com"), + (APEX, "https://publisher.example.com:8443"), + (APEX, "https://www.publisher.example.com"), + (WWW, "https://publisher.example.com"), + (APEX, "https://edge.publisher.example.com"), + (APEX, "https://cdn.other.example"), + (APEX, "https://user@publisher.example.com"), + (APEX, "https://publisher.example.com/news"), + (APEX, "https://publisher.example.com/?edition=uk"), + (APEX, "https://publisher.example.com#top"), + ] { + for token in [TOKEN, ""] { + let settings = + snapshot_origin_settings("async_prepared", public_host, loopback_url, token, true); + assert_eq!( + admitted_everywhere(&settings), + [false; 3], + "{loopback_url} would snapshot past the reader cache of {public_host} \ + (worker token {token:?})" + ); + } + } +} + +#[test] +fn local_snapshot_origins_still_start_without_an_asset_cache_or_async_preparation() { + for (execution, cached) in [ + ("async_prepared", false), + ("synchronous", true), + ("synchronous", false), + ] { + for loopback_url in [ + "http://127.0.0.1:8181", + "http://localhost:8181", + "https://edge.publisher.example.com:8443", + ] { + let settings = snapshot_origin_settings( + execution, + "publisher.example.com", + loopback_url, + TOKEN, + cached, + ); + assert_eq!( + admitted_everywhere(&settings), + [true; 3], + "{execution} with {loopback_url}, asset cache selected: {cached}" + ); + assert!( + register(&settings).expect("should register").is_some(), + "{execution} with {loopback_url} should register the module" + ); + } + } +} + fn snapshot_request(capability: Option<&str>) -> Request { let mut builder = Request::builder() .method(Method::GET) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index f9cee2b1b4..aa439484b8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2632,7 +2632,7 @@ values = ["uk", "us"] | Field | Default | Contract | | -------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `worker_url` | Required | Worker origin: `https`, or `http` on `127.0.0.1`, `localhost` or `[::1]`; no path, query or user information. Receives hints and source reads | -| `loopback_url` | Required | This deployment's own public origin, same rules. Snapshots are read through it, so they take the reader pipeline | +| `loopback_url` | Required | Own public origin, same rules, and `https://` with an asset cache (below). Snapshots read through it take the reader pipeline | | `deployment_id` | Required | 1 to 64 lower-case letters, digits or `-`; equal to the worker's | | `public_host` | Required | `domain` or its `www.` name. Every identity names it, so a reader on another host finds no result | | `signal_secret` | Required | Secret-store entry name. Its value authorizes reader hints only | @@ -2644,7 +2644,20 @@ values = ["uk", "us"] `async_prepared` without the table, a `public_host` outside the claim, or `reader_trigger = true` on an adapter that offers neither a timer nor an -after-response lifetime refuses startup. Results and retirement markers live +after-response lifetime refuses startup. + +When `[asset-cache]` selects a module, `loopback_url` must be exactly +`https://`. The asset cache keeps each reader copy under the host +and scheme the reader asked, so a snapshot read through any other origin +would miss the copy readers get and prepare content they are not served. A +trailing `/` or the default port, as in `https://publisher.example.com:443/`, +is accepted and read as `https://publisher.example.com`. Any other host, `http`, +another port, user information, a path, a query or a fragment refuses +`ts config validate` and startup. Synchronous mode, and `async_prepared` +without an asset cache, keep the general rule above, so local development can +still read snapshots from `http://127.0.0.1:`. + +Results and retirement markers live in the runtime KV store as `encypher:prep:v1:result:` and `encypher:prep:v1:retired:`, so the delivery instance needs runtime KV (`TRUSTED_SERVER_KV_PATH` on Axum). The module also serves