diff --git a/.github/workflows/scripts/test/locate-pinned-sdk.sh b/.github/workflows/scripts/test/locate-pinned-sdk.sh index 6ba3b34..2b8a6fb 100644 --- a/.github/workflows/scripts/test/locate-pinned-sdk.sh +++ b/.github/workflows/scripts/test/locate-pinned-sdk.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -pin=598f0a2a490eb83dd31c06c024a0cefa8e049628 +pin=a7d7fd0f2face027e3810ed67e80f6341fcf68b6 cargo metadata --locked --offline --format-version 1 > "$RUNNER_TEMP/qualification-metadata.json" manifest=$(jq -er --arg source "git+https://github.com/IABTechLab/edgezero.git?rev=$pin#$pin" ' [.packages[] | select(.name == "edgezero-cli")] diff --git a/Cargo.lock b/Cargo.lock index 0cdef8c..dcfdf05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1133,7 +1133,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero.git?rev=598f0a2a490eb83dd31c06c024a0cefa8e049628#598f0a2a490eb83dd31c06c024a0cefa8e049628" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=a7d7fd0f2face027e3810ed67e80f6341fcf68b6#a7d7fd0f2face027e3810ed67e80f6341fcf68b6" dependencies = [ "base16ct", "edgezero-core", @@ -1147,7 +1147,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero.git?rev=598f0a2a490eb83dd31c06c024a0cefa8e049628#598f0a2a490eb83dd31c06c024a0cefa8e049628" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=a7d7fd0f2face027e3810ed67e80f6341fcf68b6#a7d7fd0f2face027e3810ed67e80f6341fcf68b6" dependencies = [ "anyhow", "async-stream", @@ -1181,7 +1181,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero.git?rev=598f0a2a490eb83dd31c06c024a0cefa8e049628#598f0a2a490eb83dd31c06c024a0cefa8e049628" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=a7d7fd0f2face027e3810ed67e80f6341fcf68b6#a7d7fd0f2face027e3810ed67e80f6341fcf68b6" dependencies = [ "anyhow", "async-stream", @@ -1207,7 +1207,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero.git?rev=598f0a2a490eb83dd31c06c024a0cefa8e049628#598f0a2a490eb83dd31c06c024a0cefa8e049628" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=a7d7fd0f2face027e3810ed67e80f6341fcf68b6#a7d7fd0f2face027e3810ed67e80f6341fcf68b6" dependencies = [ "anyhow", "async-stream", @@ -1239,7 +1239,7 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero.git?rev=598f0a2a490eb83dd31c06c024a0cefa8e049628#598f0a2a490eb83dd31c06c024a0cefa8e049628" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=a7d7fd0f2face027e3810ed67e80f6341fcf68b6#a7d7fd0f2face027e3810ed67e80f6341fcf68b6" dependencies = [ "anyhow", "async-stream", @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero.git?rev=598f0a2a490eb83dd31c06c024a0cefa8e049628#598f0a2a490eb83dd31c06c024a0cefa8e049628" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=a7d7fd0f2face027e3810ed67e80f6341fcf68b6#a7d7fd0f2face027e3810ed67e80f6341fcf68b6" dependencies = [ "chrono", "clap", @@ -1290,7 +1290,7 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero.git?rev=598f0a2a490eb83dd31c06c024a0cefa8e049628#598f0a2a490eb83dd31c06c024a0cefa8e049628" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=a7d7fd0f2face027e3810ed67e80f6341fcf68b6#a7d7fd0f2face027e3810ed67e80f6341fcf68b6" dependencies = [ "adler2", "alloc-no-stdlib", @@ -1334,7 +1334,7 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero.git?rev=598f0a2a490eb83dd31c06c024a0cefa8e049628#598f0a2a490eb83dd31c06c024a0cefa8e049628" +source = "git+https://github.com/IABTechLab/edgezero.git?rev=a7d7fd0f2face027e3810ed67e80f6341fcf68b6#a7d7fd0f2face027e3810ed67e80f6341fcf68b6" dependencies = [ "log", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 1c2254e..b63d354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,12 +60,12 @@ jsonschema-macros-core = "=0.58.6" testing_logger = "0.1" clap = { version = "4", features = ["derive"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } -edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-adapter-axum", rev = "598f0a2a490eb83dd31c06c024a0cefa8e049628", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-adapter-cloudflare", rev = "598f0a2a490eb83dd31c06c024a0cefa8e049628", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-adapter-fastly", rev = "598f0a2a490eb83dd31c06c024a0cefa8e049628", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-adapter-spin", rev = "598f0a2a490eb83dd31c06c024a0cefa8e049628", default-features = false } -edgezero-cli = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-cli", rev = "598f0a2a490eb83dd31c06c024a0cefa8e049628", default-features = false } -edgezero-core = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-core", rev = "598f0a2a490eb83dd31c06c024a0cefa8e049628", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-adapter-axum", rev = "a7d7fd0f2face027e3810ed67e80f6341fcf68b6", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-adapter-cloudflare", rev = "a7d7fd0f2face027e3810ed67e80f6341fcf68b6", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-adapter-fastly", rev = "a7d7fd0f2face027e3810ed67e80f6341fcf68b6", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-adapter-spin", rev = "a7d7fd0f2face027e3810ed67e80f6341fcf68b6", default-features = false } +edgezero-cli = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-cli", rev = "a7d7fd0f2face027e3810ed67e80f6341fcf68b6", default-features = false } +edgezero-core = { git = "https://github.com/IABTechLab/edgezero.git", package = "edgezero-core", rev = "a7d7fd0f2face027e3810ed67e80f6341fcf68b6", default-features = false } fastly = "=0.13.1" flate2 = { version = "1", default-features = false, features = ["rust_backend"] } # GVL-archive build gate: brotli-11 compressed-bundle pass in diff --git a/crates/auctioneer-adapter-axum/src/resource_profile.rs b/crates/auctioneer-adapter-axum/src/resource_profile.rs index f995588..034cb3a 100644 --- a/crates/auctioneer-adapter-axum/src/resource_profile.rs +++ b/crates/auctioneer-adapter-axum/src/resource_profile.rs @@ -21,7 +21,7 @@ use sha2::{Digest as _, Sha256}; use std::io::{self, Write as _}; const MAX_OUTPUT_BYTES: usize = 0x8000; -const EDGEZERO_PIN: &str = "598f0a2a490eb83dd31c06c024a0cefa8e049628"; +const EDGEZERO_PIN: &str = "a7d7fd0f2face027e3810ed67e80f6341fcf68b6"; // Exhaustive destructuring makes new runtime terms a compile error here. The // original fields retain their owning types, including every integer width. diff --git a/crates/auctioneer-cli/src/qualification/harness/commands.rs b/crates/auctioneer-cli/src/qualification/harness/commands.rs index da01598..6cc6527 100644 --- a/crates/auctioneer-cli/src/qualification/harness/commands.rs +++ b/crates/auctioneer-cli/src/qualification/harness/commands.rs @@ -360,7 +360,7 @@ mod tests { assert_eq!(cases.len(), 14_usize); let digest = "a".repeat(64_usize); let identity = json!({"auctioneer_commit":"a".repeat(40_usize), - "edgezero_pin":"598f0a2a490eb83dd31c06c024a0cefa8e049628", + "edgezero_pin":"a7d7fd0f2face027e3810ed67e80f6341fcf68b6", "artifact_sha256":digest,"source_sha256":digest, "build_features":["spin","auctioneer-config-data/test-harness-override"], "target":"wasm32-wasip2","runtime_version":{"major":3_u32,"minor":0_u32,"patch":0_u32}, diff --git a/crates/auctioneer-cli/src/qualification/harness/providers/mod.rs b/crates/auctioneer-cli/src/qualification/harness/providers/mod.rs index 2c25847..9b40e02 100644 --- a/crates/auctioneer-cli/src/qualification/harness/providers/mod.rs +++ b/crates/auctioneer-cli/src/qualification/harness/providers/mod.rs @@ -11,7 +11,7 @@ pub mod sdk; #[cfg(test)] mod recovery; -const SDK_PIN: &str = "598f0a2a490eb83dd31c06c024a0cefa8e049628"; +const SDK_PIN: &str = "a7d7fd0f2face027e3810ed67e80f6341fcf68b6"; const OVERRIDE_FEATURE: &str = "auctioneer-config-data/test-harness-override"; #[derive(Deserialize)] @@ -686,7 +686,7 @@ mod tests { fn identity() -> Value { json!({"auctioneer_commit":"a".repeat(40_usize), - "edgezero_pin":"598f0a2a490eb83dd31c06c024a0cefa8e049628", + "edgezero_pin":"a7d7fd0f2face027e3810ed67e80f6341fcf68b6", "artifact_sha256":"a".repeat(64_usize),"source_sha256":"a".repeat(64_usize), "build_features":["spin","auctioneer-config-data/test-harness-override"],"target":"wasm32-wasip2", "runtime_version":{"major":3_usize,"minor":0_usize,"patch":0_usize},"harness_sha256":"a".repeat(64_usize), diff --git a/crates/auctioneer-cli/tests/qualification_harness.rs b/crates/auctioneer-cli/tests/qualification_harness.rs index 33ccfa1..10257f9 100644 --- a/crates/auctioneer-cli/tests/qualification_harness.rs +++ b/crates/auctioneer-cli/tests/qualification_harness.rs @@ -126,7 +126,7 @@ mod tests { }).collect(); assert_eq!(cases.len(), 14); let digest = "a".repeat(64); - let identity = json!({"auctioneer_commit":"a".repeat(40),"edgezero_pin":"598f0a2a490eb83dd31c06c024a0cefa8e049628", + let identity = json!({"auctioneer_commit":"a".repeat(40),"edgezero_pin":"a7d7fd0f2face027e3810ed67e80f6341fcf68b6", "artifact_sha256":digest,"source_sha256":digest,"build_features":["spin","auctioneer-config-data/test-harness-override"], "target":"wasm32-wasip2","runtime_version":{"major":3_i32,"minor":0_i32,"patch":0_i32},"harness_sha256":digest, "fixtures_sha256":digest,"reference_data_sha256":digest,"baked_config_sha256":digest,"fixture_metadata_sha256":digest}); diff --git a/crates/auctioneer-core/src/handlers/cookie_sync.rs b/crates/auctioneer-core/src/handlers/cookie_sync.rs index b733c43..acdefbe 100644 --- a/crates/auctioneer-core/src/handlers/cookie_sync.rs +++ b/crates/auctioneer-core/src/handlers/cookie_sync.rs @@ -6,7 +6,7 @@ use crate::{ handlers::{ aux_egress::with_aux_egress_preserving_body, egress_deadline::EgressDeadline, - sync_http::{self, BidderSyncStatus, SyncDebugStatus, SyncUrl}, + sync_http::{self, BidderSyncStatus, ResponseBuildError, SyncDebugStatus, SyncUrl}, usersync_keys::{self, KeyringResult}, }, host_primitives::{clock_handle, rng_handle}, @@ -349,7 +349,7 @@ async fn respond_with_registry( None, sync_http::response_body_cap(), ) - .unwrap_or_else(|_| bad("response limit exceeded")); + .unwrap_or_else(response_build_error); } } let Ok(filter_value) = request.filter_settings_value() else { @@ -531,7 +531,7 @@ fn chosen_response( debug_enabled.then_some(debug.as_slice()), sync_http::response_body_cap(), ) - .unwrap_or_else(|_| bad("response limit exceeded")) + .unwrap_or_else(response_build_error) } pub(crate) fn push_selected_url( @@ -550,6 +550,14 @@ pub(crate) fn push_selected_url( Ok(()) } +fn response_build_error(error: ResponseBuildError) -> Response { + let status = match error { + ResponseBuildError::BodyLimit | ResponseBuildError::HeaderLimit => StatusCode::BAD_REQUEST, + ResponseBuildError::Serialization => StatusCode::INTERNAL_SERVER_ERROR, + }; + sync_http::cookie_sync_error(status, "response limit exceeded") +} + fn bad(message: &'static str) -> Response { sync_http::cookie_sync_error(StatusCode::BAD_REQUEST, message) } @@ -645,6 +653,23 @@ pub(super) mod tests { use std::cell::Cell; use std::future::ready; + #[test] + fn response_build_failures_distinguish_server_failure_from_resource_limits() { + for (error, status) in [ + (ResponseBuildError::BodyLimit, StatusCode::BAD_REQUEST), + (ResponseBuildError::HeaderLimit, StatusCode::BAD_REQUEST), + ( + ResponseBuildError::Serialization, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ] { + let response = response_build_error(error); + assert_eq!(response.status(), status, "{error:?}"); + assert_eq!(body(&response), b"response limit exceeded\n"); + assert!(!response.headers().contains_key("set-cookie")); + } + } + pub(in crate::handlers) async fn wire_response( request: &CookieSyncWire, headers: &HeaderMap, diff --git a/crates/auctioneer-core/src/handlers/setuid.rs b/crates/auctioneer-core/src/handlers/setuid.rs index 9b80a65..ddb68c3 100644 --- a/crates/auctioneer-core/src/handlers/setuid.rs +++ b/crates/auctioneer-core/src/handlers/setuid.rs @@ -14,7 +14,10 @@ use crate::{ host_primitives::clock_handle, usersync::{ catalog::{Candidate, Catalog}, - cookie::{CookieWriteOptions, HostCookie, MAX_SET_COOKIE_FIELD_BYTES, read_cookie}, + cookie::{ + CookieWriteError, CookieWriteOptions, HostCookie, MAX_SET_COOKIE_FIELD_BYTES, + read_cookie, + }, evict::{CookieWriteDecision, FamilyPriorities, prepare_cookie_transaction}, limits::CookieLimits, wire::{ParsedQuery, parse_setuid_query}, @@ -257,7 +260,7 @@ async fn respond_with_registry( .and_then(|value| value.to_str().ok()), hard_cap_bytes: MAX_SET_COOKIE_FIELD_BYTES, }; - let Ok(write) = prepare_cookie_transaction( + let write = match prepare_cookie_transaction( &cookie, candidate.family, &priorities, @@ -266,12 +269,26 @@ async fn respond_with_registry( &limits, host.host_cookie.max_cookie_size_bytes, &keys, - ) else { - return bad("cookie resource limit exceeded"); + ) { + Ok(write) => write, + Err(error) => return cookie_write_error(error), }; write_response(write, format, &candidate) } +fn cookie_write_error(error: CookieWriteError) -> Response { + let status = match error { + CookieWriteError::Allocation => StatusCode::INTERNAL_SERVER_ERROR, + CookieWriteError::HardLimit + | CookieWriteError::InvalidAttributes + | CookieWriteError::ExpiryOverflow + | CookieWriteError::CodecLimit + | CookieWriteError::OptedOut + | CookieWriteError::InvalidPriorities => StatusCode::BAD_REQUEST, + }; + sync_http::setuid_error(status, "cookie resource limit exceeded") +} + fn privacy_refusal( parsed: &ParsedQuery, candidate: &Candidate<'_>, @@ -455,6 +472,27 @@ pub(super) mod tests { response.body().as_bytes().unwrap() } + #[test] + fn cookie_write_failures_distinguish_allocation_from_cookie_limits() { + for (error, status) in [ + ( + CookieWriteError::Allocation, + StatusCode::INTERNAL_SERVER_ERROR, + ), + (CookieWriteError::HardLimit, StatusCode::BAD_REQUEST), + (CookieWriteError::InvalidAttributes, StatusCode::BAD_REQUEST), + (CookieWriteError::ExpiryOverflow, StatusCode::BAD_REQUEST), + (CookieWriteError::CodecLimit, StatusCode::BAD_REQUEST), + (CookieWriteError::OptedOut, StatusCode::BAD_REQUEST), + (CookieWriteError::InvalidPriorities, StatusCode::BAD_REQUEST), + ] { + let response = cookie_write_error(error); + assert_eq!(response.status(), status, "{error:?}"); + assert_eq!(body(&response), b"cookie resource limit exceeded"); + assert!(!response.headers().contains_key("set-cookie")); + } + } + #[test] fn unconfigured_service_refuses_before_registry_or_signing_keys() { let host = HostConfig::default(); diff --git a/crates/auctioneer-core/src/handlers/sync_http.rs b/crates/auctioneer-core/src/handlers/sync_http.rs index 0165ee6..bc88410 100644 --- a/crates/auctioneer-core/src/handlers/sync_http.rs +++ b/crates/auctioneer-core/src/handlers/sync_http.rs @@ -7,6 +7,7 @@ use std::{ str::from_utf8, }; +use auctioneer_bidder::CappedWriter; use auctioneer_config_data::usersync::{SyncResponseFormat, SyncType, UserSyncConfig}; use auctioneer_runtime::aux_work::{ MAX_SYNC_OUTPUT_BYTES, MAX_SYNC_RESPONSE_HEADER_BYTES, MAX_SYNC_RESPONSE_HEADER_COUNT, @@ -14,7 +15,7 @@ use auctioneer_runtime::aux_work::{ use edgezero_core::body::Body; use edgezero_core::error::{EdgeError, RequestBodyOverflowOrigin}; use edgezero_core::http::{ - HeaderMap, HeaderValue, Response, StatusCode, + HeaderMap, HeaderName, HeaderValue, Response, StatusCode, header::{ CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ORIGIN, SET_COOKIE, VARY, }, @@ -57,10 +58,14 @@ pub fn response_header_cap() -> usize { usize::try_from(MAX_SYNC_RESPONSE_HEADER_BYTES).unwrap_or(0) } +/// Why bounded sync response construction could not complete. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ResponseBuildError { + /// Counting or HTML expansion exceeded the declared body ceiling. BodyLimit, + /// Application-owned headers could not be built within their bounds. HeaderLimit, + /// JSON failed, output reservation was refused, or serialization disagreed with counting. Serialization, } @@ -278,7 +283,7 @@ fn bound_headers( if serialized_header_bytes(response.headers())? > max_header_bytes { return Err(ResponseBuildError::HeaderLimit); } - Ok(response) + own_header_storage(response) } fn bound_setuid_headers( @@ -306,6 +311,25 @@ fn bound_setuid_headers( if other_bytes > max_other_header_bytes { return Err(ResponseBuildError::HeaderLimit); } + own_header_storage(response) +} + +fn own_header_storage(mut response: Response) -> Result { + let mut headers = HeaderMap::try_with_capacity(response.headers().len()) + .map_err(|_capacity| ResponseBuildError::HeaderLimit)?; + // Both maps coexist during construction; final values must not retain + // an ingress slice or map capacity left over from removed fields. + for (name, value) in response.headers() { + let owned_name = HeaderName::from_bytes(name.as_str().as_bytes()) + .map_err(|_name| ResponseBuildError::HeaderLimit)?; + let mut owned = HeaderValue::from_bytes(value.as_bytes()) + .map_err(|_value| ResponseBuildError::HeaderLimit)?; + owned.set_sensitive(value.is_sensitive()); + headers + .try_append(owned_name, owned) + .map_err(|_capacity| ResponseBuildError::HeaderLimit)?; + } + *response.headers_mut() = headers; Ok(response) } @@ -470,7 +494,8 @@ struct CookieSyncBody<'a> { /// Serializes a bounded cookie-sync response with a trailing newline. /// /// # Errors -/// Returns `BodyLimit` if the body exceeds the cap, or `Serialization` on JSON failure. +/// Returns `BodyLimit` if counting exceeds the cap. Returns `Serialization` on +/// JSON failure, output-reservation refusal, or count/serialization disagreement. pub fn cookie_sync_success( cookie: &UidCookie, now: OffsetDateTime, @@ -502,7 +527,9 @@ struct GetUidsBody<'a> { /// Serializes the cookie's UIDs with HTML escaping and a trailing newline. /// /// # Errors -/// Returns `BodyLimit` if the body exceeds the cap, or `Serialization` on JSON failure. +/// Returns `BodyLimit` if counting or HTML expansion exceeds the cap. Returns +/// `Serialization` on JSON failure, output-reservation refusal, or +/// count/serialization disagreement. pub fn getuids_success( cookie: &UidCookie, max_body_bytes: usize, @@ -632,30 +659,24 @@ fn response(status: StatusCode, body: Vec) -> Response { response } -struct BoundedBody { - bytes: Vec, +struct BodyCount { + len: usize, max: usize, } -impl BoundedBody { - fn new(max: usize) -> Self { - Self { - bytes: Vec::new(), - max, - } - } -} - #[expect( clippy::missing_trait_methods, - reason = "default Write helpers delegate to the bounded write method; flush has no buffered side effects" + reason = "default Write helpers delegate to the capped counter; flush has no buffered side effects" )] -impl io::Write for BoundedBody { +impl io::Write for BodyCount { fn write(&mut self, buf: &[u8]) -> io::Result { - if buf.len() > self.max.saturating_sub(self.bytes.len()) { - return Err(io::Error::other("sync response body limit")); + if buf.len() > self.max.saturating_sub(self.len) { + return Err(io::ErrorKind::WriteZero.into()); } - self.bytes.extend_from_slice(buf); + self.len = self + .len + .checked_add(buf.len()) + .ok_or(io::ErrorKind::WriteZero)?; Ok(buf.len()) } @@ -664,40 +685,84 @@ impl io::Write for BoundedBody { } } +fn sync_json_error(error: &serde_json::Error) -> ResponseBuildError { + if error.io_error_kind() == Some(io::ErrorKind::WriteZero) { + ResponseBuildError::BodyLimit + } else { + ResponseBuildError::Serialization + } +} + +fn reserve_body(bytes: usize) -> Result, ResponseBuildError> { + let mut body = Vec::new(); + body.try_reserve_exact(bytes) + .map_err(|_allocation| ResponseBuildError::Serialization)?; + Ok(body) +} + +fn html_fragment(ch: char, buffer: &mut [u8; 4]) -> &[u8] { + match ch { + '<' => b"\\u003c", + '>' => b"\\u003e", + '&' => b"\\u0026", + '\u{2028}' => b"\\u2028", + '\u{2029}' => b"\\u2029", + _ => ch.encode_utf8(buffer).as_bytes(), + } +} + fn bounded_json_line( value: &impl Serialize, max: usize, escape_html: bool, ) -> Result, ResponseBuildError> { let payload_cap = max.checked_sub(1).ok_or(ResponseBuildError::BodyLimit)?; - let mut raw = BoundedBody::new(payload_cap); - serde_json::to_writer(&mut raw, value).map_err(|error| { - if error.is_io() { - ResponseBuildError::BodyLimit - } else { - ResponseBuildError::Serialization - } - })?; + // Refuse during counting, before reserving either output buffer. + let mut count = BodyCount { + len: 0, + max: payload_cap, + }; + serde_json::to_writer(&mut count, value).map_err(|error| sync_json_error(&error))?; + let raw_capacity = count + .len + .checked_add(usize::from(!escape_html)) + .ok_or(ResponseBuildError::BodyLimit)?; + let mut raw = reserve_body(raw_capacity)?; + let mut raw_writer = CappedWriter::new(&mut raw, count.len) + .map_err(|_capacity| ResponseBuildError::Serialization)?; + serde_json::to_writer(&mut raw_writer, value) + .map_err(|_error| ResponseBuildError::Serialization)?; + if raw.len() != count.len { + return Err(ResponseBuildError::Serialization); + } let mut body = if escape_html { - let raw_text = from_utf8(&raw.bytes).map_err(|_error| ResponseBuildError::Serialization)?; - let mut escaped = BoundedBody::new(payload_cap); + let raw_text = from_utf8(&raw).map_err(|_error| ResponseBuildError::Serialization)?; + let mut escaped_count = BodyCount { + len: 0, + max: payload_cap, + }; + let mut buffer = [0; 4]; for ch in raw_text.chars() { - match ch { - '<' => escaped.write_all(b"\\u003c"), - '>' => escaped.write_all(b"\\u003e"), - '&' => escaped.write_all(b"\\u0026"), - '\u{2028}' => escaped.write_all(b"\\u2028"), - '\u{2029}' => escaped.write_all(b"\\u2029"), - _ => { - let mut buffer = [0; 4]; - escaped.write_all(ch.encode_utf8(&mut buffer).as_bytes()) - } - } - .map_err(|_error| ResponseBuildError::BodyLimit)?; + escaped_count + .write_all(html_fragment(ch, &mut buffer)) + .map_err(|_limit| ResponseBuildError::BodyLimit)?; + } + // Raw and escaped backing coexist until this transformation completes. + let escaped_capacity = escaped_count + .len + .checked_add(1) + .ok_or(ResponseBuildError::BodyLimit)?; + let mut escaped = reserve_body(escaped_capacity)?; + let mut writer = CappedWriter::new(&mut escaped, escaped_count.len) + .map_err(|_capacity| ResponseBuildError::Serialization)?; + for ch in raw_text.chars() { + writer + .write_all(html_fragment(ch, &mut buffer)) + .map_err(|_limit| ResponseBuildError::BodyLimit)?; } - escaped.bytes + escaped } else { - raw.bytes + raw }; body.push(b'\n'); Ok(body) @@ -1488,6 +1553,159 @@ mod tests { use auctioneer_runtime::aux_work::MAX_SYNC_OUTPUT_BYTES; + #[test] + fn sync_json_line_reserves_only_the_complete_final_body() { + for value in ["", "abc", "x".repeat(127).as_str(), "<>&\u{2028}\u{2029}"] { + for escape_html in [false, true] { + let body = bounded_json_line(&value, 4096, escape_html).unwrap(); + assert_eq!(body.capacity(), body.len(), "{value:?}, {escape_html}"); + for cap in [body.len(), body.len().checked_add(23).unwrap()] { + assert_eq!(bounded_json_line(&value, cap, escape_html).unwrap(), body); + } + assert_eq!( + bounded_json_line(&value, body.len().checked_sub(1).unwrap(), escape_html), + Err(ResponseBuildError::BodyLimit) + ); + } + } + } + + #[test] + fn sync_json_line_keeps_all_html_json_and_newline_escapes() { + let input = "<>&\u{2028}\u{2029}\"\\\n\0\u{e9}"; + let expected = "\"\\u003c\\u003e\\u0026\\u2028\\u2029\\\"\\\\\\n\\u0000\u{e9}\"\n"; + assert_eq!( + bounded_json_line(&input, expected.len(), true).unwrap(), + expected.as_bytes() + ); + assert_eq!( + bounded_json_line(&input, expected.len().checked_sub(1).unwrap(), true), + Err(ResponseBuildError::BodyLimit) + ); + } + + struct ChangingString<'a> { + first: &'a str, + second: &'a str, + calls: Cell, + } + + impl serde::Serialize for ChangingString<'_> { + fn serialize(&self, serializer: S) -> Result { + let call = self.calls.replace(self.calls.get().checked_add(1).unwrap()); + serializer.serialize_str(if call == 0 { self.first } else { self.second }) + } + } + + #[test] + fn sync_json_line_refuses_serialization_growth_and_shrinkage() { + for escape_html in [false, true] { + for (first, second, error) in [ + ("a", "longer", ResponseBuildError::Serialization), + ("longer", "a", ResponseBuildError::Serialization), + ] { + let value = ChangingString { + first, + second, + calls: Cell::new(0), + }; + assert_eq!(bounded_json_line(&value, 128, escape_html), Err(error)); + assert_eq!(value.calls.get(), 2); + } + } + } + + struct VisitedSequence { + entries: usize, + visited: Cell, + } + + impl serde::Serialize for VisitedSequence { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeSeq as _; + let mut sequence = serializer.serialize_seq(Some(self.entries))?; + for _ in 0..self.entries { + self.visited.set(self.visited.get().checked_add(1).unwrap()); + sequence.serialize_element("x")?; + } + sequence.end() + } + } + + #[test] + fn sync_json_counting_stops_at_the_first_overflow_before_output_reservation() { + use auctioneer_runtime::test_support::alloc_track::allocated; + for escape_html in [false, true] { + let value = VisitedSequence { + entries: 1024, + visited: Cell::new(0), + }; + let before = allocated(); + let result = bounded_json_line(&value, 2048, escape_html); + let allocated_bytes = allocated().checked_sub(before).unwrap(); + assert_eq!(result, Err(ResponseBuildError::BodyLimit)); + assert!(value.visited.get() > 0 && value.visited.get() < value.entries); + assert!( + allocated_bytes < 512, + "overflow allocated {allocated_bytes} bytes" + ); + + value.visited.set(0); + let body = bounded_json_line(&value, 8192, escape_html).unwrap(); + assert_eq!(value.visited.get(), value.entries.checked_mul(2).unwrap()); + assert_eq!(body.len(), 4098); + } + } + + #[test] + fn sync_json_line_checks_newline_capacity_before_visiting_input() { + let value = VisitedSequence { + entries: 1, + visited: Cell::new(0), + }; + for escape_html in [false, true] { + assert_eq!( + bounded_json_line(&value, 0, escape_html), + Err(ResponseBuildError::BodyLimit) + ); + assert_eq!(value.visited.get(), 0); + } + } + + #[test] + fn sync_html_overflow_never_reserves_the_escaped_output() { + use auctioneer_runtime::test_support::alloc_track::allocated; + for input_bytes in [1024, 8192] { + let value = "<".repeat(input_bytes); + let before = allocated(); + let result = bounded_json_line(&value, input_bytes.checked_mul(2).unwrap(), true); + let allocated_bytes = allocated().checked_sub(before).unwrap(); + assert_eq!(result, Err(ResponseBuildError::BodyLimit)); + assert!( + allocated_bytes + <= u64::try_from(input_bytes.checked_add(2 + 256).unwrap()).unwrap(), + "only raw JSON plus fixed error scratch is allowed: {allocated_bytes}" + ); + } + } + + #[test] + fn sync_json_line_keeps_genuine_serializer_errors_distinct_from_limits() { + struct Broken; + impl serde::Serialize for Broken { + fn serialize(&self, _serializer: S) -> Result { + use serde::ser::Error as _; + Err(S::Error::custom("fixed serialization failure")) + } + } + for escape_html in [false, true] { + assert_eq!( + bounded_json_line(&Broken, 128, escape_html), + Err(ResponseBuildError::Serialization) + ); + } + } + #[test] fn json_output_cap_includes_newline_and_never_exposes_a_partial_body() { let cap = usize::try_from(MAX_SYNC_OUTPUT_BYTES).unwrap(); @@ -1533,6 +1751,66 @@ mod tests { )); } + #[test] + fn sync_header_bounds_detach_backing_and_discard_removed_map_capacity() { + for backing_bytes in [16 * 1024, 1024 * 1024] { + for value_bytes in [17, 271] { + for setuid in [false, true] { + let backing = Bytes::from(vec![b'x'; backing_bytes]); + let mut shared = HeaderValue::from_maybe_shared( + backing.slice(32..32_usize.checked_add(value_bytes).unwrap()), + ) + .unwrap(); + shared.set_sensitive(true); + let start = backing.as_ptr().addr(); + let addresses = start..start.checked_add(backing.len()).unwrap(); + assert!(addresses.contains(&shared.as_bytes().as_ptr().addr())); + let mut response = Response::new(Body::from_bytes(b"unchanged".to_vec())); + *response.status_mut() = StatusCode::ACCEPTED; + *response.headers_mut() = HeaderMap::with_capacity(1024); + response + .headers_mut() + .insert("x-removed", HeaderValue::from_static("removed")); + assert!(response.headers_mut().remove("x-removed").is_some()); + for _ in 0_usize..3 { + response.headers_mut().append("x-shared", shared.clone()); + } + response + .headers_mut() + .append("vary", HeaderValue::from_static("Origin")); + response + .headers_mut() + .append("vary", HeaderValue::from_static("Accept-Encoding")); + if setuid { + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_static("uids=payload; HttpOnly"), + ); + } + let before = response.headers().clone(); + assert!(before.capacity() >= 1024); + let fresh_capacity = + HeaderMap::::with_capacity(before.len()).capacity(); + let bounded = if setuid { + super::bound_setuid_headers(response, 4096) + } else { + super::bound_headers(response, 4096) + } + .unwrap(); + assert_eq!(bounded.status(), StatusCode::ACCEPTED); + assert_eq!(body_bytes(&bounded), b"unchanged"); + assert_eq!(bounded.headers(), &before); + assert!(bounded.headers().capacity() <= fresh_capacity); + assert_eq!(bounded.headers().get_all("x-shared").iter().count(), 3); + for value in bounded.headers().get_all("x-shared") { + assert!(value.is_sensitive()); + assert!(!addresses.contains(&value.as_bytes().as_ptr().addr())); + } + } + } + } + } + #[test] fn browser_policy_enforces_application_header_count_after_cors_decoration() { let policy = browser_policy(); diff --git a/crates/auctioneer-core/src/usersync/cookie.rs b/crates/auctioneer-core/src/usersync/cookie.rs index 83e05d3..c374542 100644 --- a/crates/auctioneer-core/src/usersync/cookie.rs +++ b/crates/auctioneer-core/src/usersync/cookie.rs @@ -7,7 +7,7 @@ use std::{ str, }; -use auctioneer_bidder::accepts_sync_uid; +use auctioneer_bidder::{CappedWriter, accepts_sync_uid}; use base64::{ Engine as _, alphabet, engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, URL_SAFE}, @@ -438,6 +438,8 @@ pub enum CookieWriteError { ExpiryOverflow, #[error("cookie codec resource limit exceeded")] CodecLimit, + #[error("cookie field allocation failed")] + Allocation, #[error("cookie write forbidden by opt-out")] OptedOut, #[error("invalid cookie priority configuration")] @@ -467,8 +469,8 @@ impl PreparedCookie { } /// # Errors -/// Rejects invalid cookie attributes, an unrepresentable expiry, or a complete -/// serialized field exceeding the hard byte limit. +/// Rejects invalid cookie attributes, an unrepresentable expiry, a complete +/// serialized field exceeding the hard byte limit, or a field allocation failure. pub fn prepare_set_cookie_value( encoded: &str, options: &CookieWriteOptions<'_>, @@ -521,12 +523,23 @@ pub fn prepare_set_cookie_value( if chrome_same_site_none(options.user_agent) { builder = builder.secure(true).same_site(SameSite::None); } - let field = builder.build().to_string(); - if field.len() > hard_cap { - return Err(CookieWriteError::HardLimit); - } + let cookie = builder.build(); + let mut field = Vec::new(); + field + .try_reserve_exact(hard_cap) + .map_err(|_allocation| CookieWriteError::Allocation)?; + let mut writer = CappedWriter::new(&mut field, hard_cap) + .map_err(|_capacity| CookieWriteError::Allocation)?; + // Keep the library's complete cookie grammar, but never grow its field buffer. + write!(&mut writer, "{cookie}").map_err(|error| { + if error.kind() == io::ErrorKind::WriteZero { + CookieWriteError::HardLimit + } else { + CookieWriteError::InvalidAttributes + } + })?; let field_value = - HeaderValue::from_str(&field).map_err(|_error| CookieWriteError::InvalidAttributes)?; + HeaderValue::from_bytes(&field).map_err(|_error| CookieWriteError::InvalidAttributes)?; Ok(PreparedCookie { field_value }) } diff --git a/crates/auctioneer-core/src/usersync/evict.rs b/crates/auctioneer-core/src/usersync/evict.rs index 76af75d..00a3e1f 100644 --- a/crates/auctioneer-core/src/usersync/evict.rs +++ b/crates/auctioneer-core/src/usersync/evict.rs @@ -124,8 +124,9 @@ pub enum CookieWriteDecision { } /// # Errors -/// Returns an error for opt-out, codec limits, invalid attributes, or a field -/// exceeding the hard output limit. Logical eviction refusal is a decision. +/// Returns an error for opt-out, codec limits, invalid attributes, a field +/// exceeding the hard output limit, or field allocation failure. +/// Logical eviction refusal is a decision. #[expect( clippy::too_many_arguments, reason = "transaction keeps independent state, policy, bounds, clock and signing inputs explicit" diff --git a/crates/auctioneer-core/tests/codec_pins.rs b/crates/auctioneer-core/tests/codec_pins.rs index 6579ede..ae657ad 100644 --- a/crates/auctioneer-core/tests/codec_pins.rs +++ b/crates/auctioneer-core/tests/codec_pins.rs @@ -28,7 +28,7 @@ mod tests { /// The `EdgeZero` revision every `edgezero-*` package must resolve /// to. Asserted structurally — over `source =` lines only — so a /// renamed dependency cannot make the check vacuous. - const EDGEZERO_REV: &str = "598f0a2a490eb83dd31c06c024a0cefa8e049628"; + const EDGEZERO_REV: &str = "a7d7fd0f2face027e3810ed67e80f6341fcf68b6"; const EDGEZERO_REPOSITORY: &str = "https://github.com/IABTechLab/edgezero.git"; /// The tuple `Cargo.lock` must resolve. Every exact version here is diff --git a/crates/auctioneer-core/tests/ingress_admission.rs b/crates/auctioneer-core/tests/ingress_admission.rs index ed422be..69fdeb5 100644 --- a/crates/auctioneer-core/tests/ingress_admission.rs +++ b/crates/auctioneer-core/tests/ingress_admission.rs @@ -16,36 +16,62 @@ #[cfg(test)] mod tests { + use async_trait::async_trait; use auctioneer_core::auction::audit::construction_costs as core_construction_costs; use auctioneer_core::configure::configure_app; + use auctioneer_core::host_primitives::gates::BootGates; use auctioneer_core::host_primitives::{ AuctioneerIngressLease, BootEnvelope, DelegateToDevStrategy, DevRng, RouteAdmissionMarker, - StderrBootLog, SystemClock, admission_gate, aux_gate, boot_gates, + StderrBootLog, SystemClock, TestClockHandle, admission_gate, aux_gate, boot_gates, bootstrap_adapter_with_envelope, probe_egress_gate, static_egress_gate, }; use auctioneer_core::ingress::{AUCTION_CLASS, AUX_CLASS, PROBE_CLASS, decide}; + use auctioneer_core::test_support::{ + config_store::runtime_registry, live_catalog, usersync_keys, + }; use auctioneer_runtime::body_caps::DEFAULT_CAP_BYTES; use auctioneer_runtime::budget::ASSEMBLY_FACTOR; use auctioneer_runtime::{ - ImmediateResolveTimer, MAX_GVL_PARSED_BYTES, MIN_INBOUND_PARSE_FACTOR, + FakeClock, ImmediateResolveTimer, MAX_GVL_PARSED_BYTES, MIN_INBOUND_PARSE_FACTOR, MIN_RESPONSE_NODE_PEAK_BYTES, MIN_RESPONSE_PARSE_FACTOR, NoopDecodeCooperator, ResolvedIsolateLimits, TargetPlatformConstants, }; + use bytes::Bytes; use edgezero_core::app::App; use edgezero_core::body::Body; - use edgezero_core::config_store::DEFAULT_CONFIG_JSON_NODES; - use edgezero_core::http::{Method, StatusCode, request_builder}; + use edgezero_core::config_store::{ + BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, ConfigValue, + DEFAULT_CONFIG_JSON_NODES, + }; + use edgezero_core::error::EdgeError; + use edgezero_core::http::{Method, Request, StatusCode, request_builder}; use edgezero_core::ingress::{ AdmissionDecision, IngressDispatchOutcome, IngressFraming, IngressHead, IngressHeadAccounting, }; - use edgezero_core::response_egress::{ResponseEgressEnvelope, ResponseEgressOutcome}; + use edgezero_core::response_egress::{ + ResponseEgressBodyKind, ResponseEgressEnvelope, ResponseEgressFallbackDisposition, + ResponseEgressObserver, ResponseEgressOutcome, ResponseEgressReport, + }; use edgezero_core::response_egress_framing::ResponseEgressFramingMode; use edgezero_core::router::RouteResolution; - use edgezero_core::time::MonotonicInstant; - use futures::executor::block_on; + use edgezero_core::secret_store::{SecretError, SecretHandle, SecretStore}; + use edgezero_core::store_registry::{ + BoundSecretStore, ConfigRegistry, ConfigStoreBinding, SecretRegistry, StoreRegistry, + }; + use edgezero_core::time::{Deadline, MonotonicClock, MonotonicInstant}; + use futures::{ + channel::oneshot, executor::block_on, future::pending, stream, task::noop_waker_ref, + }; + use std::collections::HashSet; + use std::future::Future as _; use std::iter::repeat_with; + use std::mem::take; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; + use std::task::{Context, Poll}; + use time::OffsetDateTime; + use time::format_description::well_known::Rfc3339; const CAP_256_KIB: u64 = 1 << 18; const BYTES_1_MIB: u64 = 1 << 20; @@ -65,6 +91,18 @@ mod tests { const AUX_PERMITS: u32 = 1; const CONCURRENT_AUCTIONS: u32 = 1; const TERMINAL_PERMITS: u32 = 8; + // Every terminal value in the pinned SDK, supplied from the writing state. + const TERMINAL_OUTCOMES: [ResponseEgressOutcome; 9] = [ + ResponseEgressOutcome::Completed, + ResponseEgressOutcome::ClientDisconnected, + ResponseEgressOutcome::ConversionError, + ResponseEgressOutcome::DeadlineExceeded, + ResponseEgressOutcome::HostHandoff, + ResponseEgressOutcome::RequestCancelled, + ResponseEgressOutcome::SourceError, + ResponseEgressOutcome::TransportError, + ResponseEgressOutcome::Unspecified, + ]; /// Serializes every test that reads a gate's in-use counter. /// @@ -80,24 +118,26 @@ mod tests { fn boot() { static BOOTED: OnceLock<()> = OnceLock::new(); - BOOTED.get_or_init(|| { - bootstrap_adapter_with_envelope( - BootEnvelope { - limits: &limits(), - target: &target(), - outbound_meta_bytes: OUTBOUND_META_BYTES, - construction_costs: core_construction_costs(), - log_sink: &StderrBootLog, - catalog_costs: auctioneer_runtime::CatalogResourceCosts::UNCHANGED, - }, - || Arc::new(SystemClock), - || Arc::new(DevRng::new()), - || Arc::new(ImmediateResolveTimer), - || Arc::new(NoopDecodeCooperator), - || Arc::new(DelegateToDevStrategy), - ) - .expect("the fixture envelope validates"); - }); + BOOTED.get_or_init(|| install_envelope(&limits())); + } + + fn install_envelope(limits: &ResolvedIsolateLimits) { + bootstrap_adapter_with_envelope( + BootEnvelope { + limits, + target: &target(), + outbound_meta_bytes: OUTBOUND_META_BYTES, + construction_costs: core_construction_costs(), + log_sink: &StderrBootLog, + catalog_costs: auctioneer_runtime::CatalogResourceCosts::UNCHANGED, + }, + || Arc::new(SystemClock), + || Arc::new(DevRng::new()), + || Arc::new(ImmediateResolveTimer), + || Arc::new(NoopDecodeCooperator), + || Arc::new(DelegateToDevStrategy), + ) + .expect("the fixture envelope validates"); } fn limits() -> ResolvedIsolateLimits { @@ -192,13 +232,72 @@ mod tests { builder = builder.header("origin", origin_value); } let request = builder.body(body).expect("the fixture request builds"); - let outcome = block_on(app.dispatch_ingress( - request, - MonotonicInstant::now(), - IngressHeadAccounting::HostManaged, - IngressFraming::HostManaged, - )) - .expect("admission begins"); + block_on(dispatch_request(app, request)) + } + + fn assert_auxiliary_receipt_held(gates: &BootGates, peak: u64) { + assert_eq!(gates.aux.in_use(), 1); + assert_eq!(gates.aux_work_budget.in_use(), peak); + } + + #[cfg(not(target_family = "wasm"))] + fn ran_isolated_child(control: &str, test: &str) -> bool { + use std::env::{current_exe, var_os}; + use std::process::Command; + if var_os(control).is_some() { + return false; + } + let child = Command::new(current_exe().expect("ingress test executable")) + .args(["--exact", test, "--nocapture"]) + .env(control, "1") + .output() + .expect("isolated ingress test executes"); + let output = format!( + "{}{}", + String::from_utf8_lossy(&child.stdout), + String::from_utf8_lossy(&child.stderr) + ); + assert!(child.status.success(), "{output}"); + assert!( + output.contains("1 passed"), + "the child must execute: {output}" + ); + true + } + + #[cfg(not(target_family = "wasm"))] + fn immediate_probe(app: &App) -> ResponseEgressEnvelope { + use futures::FutureExt as _; + let request = request_builder() + .uri("/status") + .body(Body::empty()) + .unwrap(); + let outcome = app + .dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ) + .now_or_never() + .expect("liveness must not wait for disabled aux capacity") + .unwrap(); + let IngressDispatchOutcome::Response(response) = outcome else { + panic!("the independent probe must be admitted"); + }; + *response + } + + async fn dispatch_request(app: &App, request: Request) -> Option { + let outcome = app + .dispatch_ingress( + request, + MonotonicInstant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ) + .await + .expect("admission begins"); match outcome { IngressDispatchOutcome::Response(envelope) => Some(*envelope), IngressDispatchOutcome::Aborted => None, @@ -210,6 +309,187 @@ mod tests { } } + enum StoreBehavior { + Pending, + Unavailable, + } + + struct AdmissionStore { + behavior: StoreBehavior, + reads: AtomicUsize, + active: AtomicUsize, + } + + struct StoreRead<'a>(&'a AtomicUsize); + + impl Drop for StoreRead<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } + } + + #[async_trait(?Send)] + impl ConfigStore for AdmissionStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + panic!("admitted configuration must use bounded reads"); + } + + async fn get_bounded( + &self, + _key: &str, + _clock: &MonotonicClock, + _deadline: Deadline, + _max_backend_bytes: u64, + _max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + self.reads.fetch_add(1, Ordering::SeqCst); + self.active.fetch_add(1, Ordering::SeqCst); + let _read = StoreRead(&self.active); + match self.behavior { + StoreBehavior::Pending => pending().await, + StoreBehavior::Unavailable => Err(ConfigStoreError::Unavailable { + message: "fixed test outage".to_owned(), + }), + } + } + } + + fn admission_store(behavior: StoreBehavior) -> Arc { + Arc::new(AdmissionStore { + behavior, + reads: AtomicUsize::new(0), + active: AtomicUsize::new(0), + }) + } + + fn store_request(method: Method, path: &str, store: Arc) -> Request { + let mut request = request_builder() + .method(method) + .uri(path) + .body(Body::empty()) + .unwrap(); + let registry: ConfigRegistry = StoreRegistry::single_id( + "auctioneer_runtime".to_owned(), + ConfigStoreBinding { + default_key: "auctioneer_runtime".to_owned(), + handle: ConfigStoreHandle::new(store), + }, + ); + request.extensions_mut().insert(registry); + request + } + + enum SecretBehavior { + Ready, + Pending, + Unavailable, + } + + struct AdmissionSecretStore { + behavior: SecretBehavior, + key: &'static str, + value: Bytes, + reads: AtomicUsize, + active: AtomicUsize, + } + + #[async_trait(?Send)] + impl SecretStore for AdmissionSecretStore { + async fn get_bytes(&self, _store: &str, _key: &str) -> Result, SecretError> { + panic!("admitted handlers must use bounded secret reads"); + } + + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + _clock: &MonotonicClock, + _deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + assert_eq!((store_name, key), ("fake_secrets", self.key)); + assert_eq!((max_backend_bytes, max_value_bytes), (4096, 4096)); + self.reads.fetch_add(1, Ordering::SeqCst); + self.active.fetch_add(1, Ordering::SeqCst); + let _read = StoreRead(&self.active); + match self.behavior { + SecretBehavior::Ready => Ok(BoundedStoreRead { + backend_bytes: u64::try_from(self.value.len()).unwrap(), + value: Some(self.value.clone()), + }), + SecretBehavior::Pending => pending().await, + SecretBehavior::Unavailable => Err(SecretError::Unavailable), + } + } + } + + fn admission_secret_store( + behavior: SecretBehavior, + key: &'static str, + value: &'static [u8], + ) -> Arc { + Arc::new(AdmissionSecretStore { + behavior, + key, + value: Bytes::from_static(value), + reads: AtomicUsize::new(0), + active: AtomicUsize::new(0), + }) + } + + fn health_secret_store(behavior: SecretBehavior) -> Arc { + admission_secret_store(behavior, "fake_health_token", b"fixture-token") + } + + fn attach_secret_store(request: &mut Request, store: Arc) { + let registry: SecretRegistry = StoreRegistry::single_id( + "fake_secret_store".to_owned(), + BoundSecretStore::new(SecretHandle::new(store), "fake_secrets".to_owned()), + ); + request.extensions_mut().insert(registry); + } + + fn authorized_health_request(store: Arc) -> Request { + let mut request = request_builder() + .uri("/_auctioneer/health/gvl") + .header("authorization", "Bearer fixture-token") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(runtime_registry(&serde_json::json!({ + "schema_version": 1_u8, + "host": {"gvl": { + "authenticate_health": true, + "authenticate_health_token_secret": "fake_health_token" + }} + }))); + let generated = + OffsetDateTime::parse(auctioneer_config_data::gvl_archive_generated_at(), &Rfc3339) + .unwrap(); + request + .extensions_mut() + .insert(TestClockHandle(Arc::new(FakeClock::new(generated)))); + attach_secret_store(&mut request, store); + request + } + + fn configured_request( + method: Method, + path: &str, + body: Body, + config: &serde_json::Value, + ) -> Request { + let mut request = request_builder() + .method(method) + .uri(path) + .body(body) + .unwrap(); + request.extensions_mut().insert(runtime_registry(config)); + request + } + /// Builds the admission view the policy sees, without a router: the /// resolution is supplied directly so a single decision arm can be /// tested in isolation. @@ -237,33 +517,15 @@ mod tests { fn an_auction_without_boot_gates_aborts_before_body_reads() { use auctioneer_core::host_primitives::{boot_gates, bootstrap_adapter}; use futures::stream::poll_fn; - use std::env::{current_exe, var_os}; - use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; use std::task::Poll; const CHILD_ENV: &str = "AUCTIONEER__TEST__INGRESS_WITHOUT_BOOT_GATES_CHILD"; // OnceLock gates cannot be cleared; a fresh process excludes sibling boots. - if var_os(CHILD_ENV).is_none() { - let child = Command::new(current_exe().expect("ingress test executable")) - .args([ - "--exact", - "tests::an_auction_without_boot_gates_aborts_before_body_reads", - "--nocapture", - ]) - .env(CHILD_ENV, "1") - .output() - .expect("isolated missing-boot test executes"); - let output = format!( - "{}{}", - String::from_utf8_lossy(&child.stdout), - String::from_utf8_lossy(&child.stderr), - ); - assert!(child.status.success(), "{output}"); - assert!( - output.contains("1 passed"), - "the child must execute: {output}" - ); + if ran_isolated_child( + CHILD_ENV, + "tests::an_auction_without_boot_gates_aborts_before_body_reads", + ) { return; } @@ -540,6 +802,58 @@ mod tests { drop(held); } + #[cfg(not(target_family = "wasm"))] + #[test] + fn zero_auxiliary_capacity_preserves_independent_probe_and_static_owners() { + use auctioneer_core::handlers::probe_config::{ + config_work_capacity, config_work_in_flight, + }; + use auctioneer_runtime::PROBE_CONFIG_MIN_PERMITS; + + const CHILD_ENV: &str = "AUCTIONEER__TEST__ZERO_AUX_TERMINAL_CHILD"; + if ran_isolated_child( + CHILD_ENV, + "tests::zero_auxiliary_capacity_preserves_independent_probe_and_static_owners", + ) { + return; + } + let mut zero_aux = limits(); + zero_aux.aux_route_permits = 0; + install_envelope(&zero_aux); + let gates = boot_gates().unwrap(); + assert_eq!(gates.aux.capacity(), 0); + assert_eq!(gates.aux_work_budget.limit(), 0); + assert!(gates.derived.aux_term > 0, "probe config remains funded"); + let app = configured_app(); + let mut probe = immediate_probe(&app); + assert_eq!(probe.response_mut().status(), StatusCode::OK); + assert_eq!(config_work_capacity(), Some(PROBE_CONFIG_MIN_PERMITS)); + assert_eq!(config_work_in_flight(), 0); + assert_eq!(probe_egress_gate().in_use(), 1); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + assert_eq!(gates.admission.in_use(), 0); + assert_eq!(gates.static_egress.in_use(), 0); + let mut refusal = dispatch(&app, Method::GET, "/info/bidders", Body::empty()).unwrap(); + assert_eq!( + refusal.response_mut().status(), + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(gates.static_egress.in_use(), 1); + assert_eq!(probe_egress_gate().in_use(), 1); + assert_eq!(gates.aux_work_budget.in_use(), 0); + drop(refusal); + assert_eq!(gates.static_egress.in_use(), 0); + assert_eq!(probe_egress_gate().in_use(), 1); + drop(probe); + assert_eq!(probe_egress_gate().in_use(), 0); + drop(immediate_probe(&app)); + assert_eq!(probe_egress_gate().in_use(), 0); + assert_eq!(config_work_in_flight(), 0); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + } + #[test] fn retained_probe_outputs_saturate_and_recover_independently() { boot(); @@ -575,31 +889,965 @@ mod tests { let _serialized = gate_counters_lock(); let app = configured_app(); let gate = probe_egress_gate(); - for outcome in [ - ResponseEgressOutcome::Completed, - ResponseEgressOutcome::ClientDisconnected, - ResponseEgressOutcome::DeadlineExceeded, - ResponseEgressOutcome::RequestCancelled, - ResponseEgressOutcome::TransportError, - ] { + let routes = registered_terminal_requests(&app, PROBE_CLASS); + let expected_routes = routes.len(); + let mut checked = 0_usize; + for (method, path) in routes { + for outcome in TERMINAL_OUTCOMES { + assert_eq!(gate.in_use(), 0); + let envelope = dispatch(&app, method.clone(), &path, Body::empty()).unwrap(); + assert_eq!( + gate.in_use(), + 1, + "handler return must retain probe capacity" + ); + let (prepared, _policy, mut attempt, clock) = envelope + .begin(ResponseEgressFramingMode::ExplicitBufferedLength) + .unwrap_or_else(|_| panic!("probe response egress begins")); + assert!(attempt.begin_writing()); + drop(prepared); + assert!(attempt.terminate(outcome, clock.now())); + assert_eq!(gate.in_use(), 0, "terminal outcome {outcome:?}"); + assert!(!attempt.terminate(outcome, clock.now())); + drop(attempt); + assert_eq!(gate.in_use(), 0, "terminal/drop must not release twice"); + } + drop(dispatch(&app, method, &path, Body::empty()).unwrap()); assert_eq!(gate.in_use(), 0); - let envelope = dispatch(&app, Method::GET, "/status", Body::empty()).unwrap(); - assert_eq!( - gate.in_use(), - 1, - "handler return must retain probe capacity" - ); + checked = checked.checked_add(1).unwrap(); + } + assert_eq!(checked, expected_routes); + assert!(checked > 0); + } + + #[test] + fn aux_output_and_work_stay_funded_until_each_real_sdk_terminal_outcome() { + boot(); + let _serialized = gate_counters_lock(); + let app = configured_app(); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + assert!(peak > 0); + let cases = auxiliary_terminal_requests(&app); + let expected_cases = cases.len(); + let mut checked = 0_usize; + for (method, path) in cases { + for outcome in TERMINAL_OUTCOMES { + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + let envelope = dispatch(&app, method.clone(), &path, Body::empty()).unwrap(); + assert_auxiliary_receipt_held(gates, peak); + let (prepared, _policy, mut attempt, clock) = envelope + .begin(ResponseEgressFramingMode::ExplicitBufferedLength) + .unwrap_or_else(|_| panic!("aux response egress begins")); + assert!(attempt.begin_writing()); + drop(prepared); + assert_auxiliary_receipt_held(gates, peak); + assert!(gates.aux.try_acquire().is_none()); + assert!(attempt.terminate(outcome, clock.now())); + assert_eq!(gates.aux.in_use(), 0, "{path}, {outcome:?}"); + assert_eq!(gates.aux_work_budget.in_use(), 0, "{path}, {outcome:?}"); + assert!(!attempt.terminate(outcome, clock.now())); + drop(attempt); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + } + let envelope = dispatch(&app, method.clone(), &path, Body::empty()).unwrap(); + let (prepared, _policy, attempt, _clock) = envelope + .begin(ResponseEgressFramingMode::ExplicitBufferedLength) + .unwrap_or_else(|_| panic!("aux response egress begins")); + drop(prepared); + assert_eq!(gates.aux.in_use(), 1); + assert_eq!(gates.aux_work_budget.in_use(), peak); + drop(attempt); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + drop(dispatch(&app, method, &path, Body::empty()).unwrap()); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + checked = checked.checked_add(1).unwrap(); + } + assert_eq!(checked, expected_cases); + assert!(checked > 0); + } + + fn registered_terminal_requests(app: &App, class: &str) -> Vec<(Method, String)> { + app.router() + .routes() + .into_iter() + .filter(|route| route.class() == Some(class)) + .map(|route| { + ( + route.method().clone(), + route.pattern().replace("{name}", "fake_fixture_bidder"), + ) + }) + .collect() + } + + fn auxiliary_terminal_requests(app: &App) -> Vec<(Method, String)> { + registered_terminal_requests(app, AUX_CLASS) + .into_iter() + .chain([ + (Method::GET, "/no/such/route".to_owned()), + (Method::POST, "/status".to_owned()), + ]) + .collect() + } + + #[derive(Clone, Default)] + struct EgressReports(Arc>>); + + impl EgressReports { + fn assert_single_application_completion(&self, expected: ResponseEgressOutcome) { + let completed = take(&mut *self.0.lock().unwrap()); + assert_eq!(completed.len(), 1); + assert_eq!(completed[0].body_kind, ResponseEgressBodyKind::Application); + assert_eq!(completed[0].fallback_disposition, None); + assert_eq!(completed[0].outcome, expected); + } + } + + impl ResponseEgressObserver for EgressReports { + fn complete(&self, report: &ResponseEgressReport) { + self.0.lock().unwrap().push(report.clone()); + } + } + + #[test] + fn authorized_health_retains_original_work_through_egress_completion_and_abandonment() { + boot(); + let _serialized = gate_counters_lock(); + let reports = EgressReports::default(); + let mut app = configured_app(); + app.set_response_egress_observer(reports.clone()); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let endings = TERMINAL_OUTCOMES + .into_iter() + .map(|outcome| (Some(outcome), true)) + .chain([(None, false), (None, true)]); + let mut checked = 0_usize; + for (outcome, writing) in endings { + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + let store = health_secret_store(SecretBehavior::Ready); + let request = authorized_health_request(Arc::clone(&store)); + let mut envelope = block_on(dispatch_request(&app, request)).unwrap(); + let response = envelope.response_mut(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "application/json"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("www-authenticate")); + let payload: serde_json::Value = + serde_json::from_slice(response.body().as_bytes().unwrap()).unwrap(); + assert_eq!(payload["status"], "ok"); + assert_eq!(store.reads.load(Ordering::SeqCst), 1); + assert_eq!(store.active.load(Ordering::SeqCst), 0); + assert_auxiliary_receipt_held(gates, peak); let (prepared, _policy, mut attempt, clock) = envelope .begin(ResponseEgressFramingMode::ExplicitBufferedLength) - .unwrap_or_else(|_| panic!("probe response egress begins")); - assert!(attempt.begin_writing()); + .unwrap_or_else(|_| panic!("authorized health egress begins")); + if writing { + assert!(attempt.begin_writing()); + } drop(prepared); - assert!(attempt.terminate(outcome, clock.now())); - assert_eq!(gate.in_use(), 0, "terminal outcome {outcome:?}"); - assert!(!attempt.terminate(outcome, clock.now())); + assert_auxiliary_receipt_held(gates, peak); + assert!(reports.0.lock().unwrap().is_empty()); + assert!(gates.aux.try_acquire().is_none()); + if let Some(terminal) = outcome { + assert!(attempt.terminate(terminal, clock.now())); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + assert!(!attempt.terminate(terminal, clock.now())); + } drop(attempt); - assert_eq!(gate.in_use(), 0, "terminal/drop must not release twice"); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + reports.assert_single_application_completion(outcome.unwrap_or(if writing { + ResponseEgressOutcome::TransportError + } else { + ResponseEgressOutcome::ConversionError + })); + checked = checked.checked_add(1).unwrap(); + } + assert_eq!(checked, 11); + } + + #[test] + fn pending_health_secret_cancellation_releases_original_work_without_borrowing_probe_slots() { + boot(); + let _serialized = gate_counters_lock(); + let app = configured_app(); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let store = health_secret_store(SecretBehavior::Pending); + let request = authorized_health_request(Arc::clone(&store)); + let mut construction = Box::pin(dispatch_request(&app, request)); + assert!( + construction + .as_mut() + .poll(&mut Context::from_waker(noop_waker_ref())) + .is_pending() + ); + assert_eq!(store.reads.load(Ordering::SeqCst), 1); + assert_eq!(store.active.load(Ordering::SeqCst), 1); + assert_auxiliary_receipt_held(gates, peak); + let refusal = dispatch(&app, Method::GET, "/info/bidders", Body::empty()).unwrap(); + assert_eq!(gates.static_egress.in_use(), 1); + let probe = dispatch(&app, Method::GET, "/status", Body::empty()).unwrap(); + assert_eq!(probe_egress_gate().in_use(), 1); + assert_auxiliary_receipt_held(gates, peak); + drop(construction); + assert_eq!(store.active.load(Ordering::SeqCst), 0); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + assert_eq!(gates.static_egress.in_use(), 1); + assert_eq!(probe_egress_gate().in_use(), 1); + drop(refusal); + drop(probe); + assert_eq!(gates.static_egress.in_use(), 0); + assert_eq!(probe_egress_gate().in_use(), 0); + let recovered = health_secret_store(SecretBehavior::Ready); + let mut envelope = + block_on(dispatch_request(&app, authorized_health_request(recovered))).unwrap(); + assert_eq!(envelope.response_mut().status(), StatusCode::OK); + assert_auxiliary_receipt_held(gates, peak); + drop(envelope); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + } + + #[test] + fn unavailable_health_secret_keeps_original_work_through_denial_handoff() { + boot(); + let _serialized = gate_counters_lock(); + let app = configured_app(); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let store = health_secret_store(SecretBehavior::Unavailable); + let request = authorized_health_request(Arc::clone(&store)); + let mut envelope = block_on(dispatch_request(&app, request)).unwrap(); + assert_eq!(store.reads.load(Ordering::SeqCst), 1); + assert_eq!(store.active.load(Ordering::SeqCst), 0); + let response = envelope.response_mut(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.headers()["www-authenticate"], "Bearer"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert_auxiliary_receipt_held(gates, peak); + drop(envelope); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + } + + fn assert_info_payload(payload: &serde_json::Value, index: usize, base: &str, disabled: bool) { + let expected_status = if disabled { "DISABLED" } else { "ACTIVE" }; + match index { + 0 | 1 => { + let names = payload.as_array().unwrap(); + let present = index == 0 || !disabled; + assert_eq!(names.iter().any(|name| name == base), present); + assert_eq!(names.iter().any(|name| name == "fake_info_alias"), present); + } + 2 => assert_eq!(payload["status"], expected_status), + 3 => { + assert_eq!(payload["status"], expected_status); + assert_eq!(payload["aliasOf"], base); + } + 4 => { + assert_eq!(payload[base]["status"], expected_status); + assert_eq!(payload["fake_info_alias"]["status"], expected_status); + assert_eq!(payload["fake_info_alias"]["aliasOf"], base); + } + _ => panic!("every info response shape has an explicit oracle"), + } + } + + #[test] + fn successful_info_list_detail_and_all_keep_runtime_alias_policy_and_terminal_funding() { + boot(); + let _serialized = gate_counters_lock(); + let app = configured_app(); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + // The real App owns a discovered catalog. No provider protocol or + // handwritten bidder-name list is part of this metadata-only control. + let base = auctioneer_config_data::bidders() + .iter() + .find(|info| { + !info.disabled && info.alias_of.is_none() && info.default_enabled != Some(false) + }) + .expect("the published catalog has a default-active base"); + let paths = [ + "/info/bidders".to_owned(), + "/info/bidders?enabledonly=true".to_owned(), + format!("/info/bidders/{}", base.name), + "/info/bidders/fake_info_alias".to_owned(), + "/info/bidders/all".to_owned(), + ]; + let mut checked = 0_usize; + for disabled in [false, true] { + let config = serde_json::json!({ + "schema_version": 1_u8, + "host": {"aliases": {"fake_info_alias": base.name}}, + "bidders": {(base.name.as_str()): {"enabled": !disabled}} + }); + for (index, path) in paths.iter().enumerate() { + for (complete, writing) in [(true, true), (false, false), (false, true)] { + let request = configured_request(Method::GET, path, Body::empty(), &config); + let mut envelope = block_on(dispatch_request(&app, request)).unwrap(); + let response = envelope.response_mut(); + assert_eq!( + response.status(), + StatusCode::OK, + "{path}, disabled={disabled}" + ); + assert_eq!(response.headers()["content-type"], "application/json"); + let payload: serde_json::Value = + serde_json::from_slice(response.body().as_bytes().unwrap()).unwrap(); + assert_info_payload(&payload, index, &base.name, disabled); + assert_auxiliary_receipt_held(gates, peak); + let (prepared, _policy, mut attempt, clock) = envelope + .begin(ResponseEgressFramingMode::ExplicitBufferedLength) + .unwrap_or_else(|_| panic!("successful info egress begins")); + if writing { + assert!(attempt.begin_writing()); + } + drop(prepared); + assert_auxiliary_receipt_held(gates, peak); + assert!(gates.aux.try_acquire().is_none()); + if complete { + assert!(attempt.terminate(ResponseEgressOutcome::Completed, clock.now())); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + assert!(!attempt.terminate(ResponseEgressOutcome::Completed, clock.now())); + } + drop(attempt); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + checked = checked.checked_add(1).unwrap(); + } + } + } + assert_eq!(checked, 30); + } + + fn assert_signing_response_handoff( + app: &App, + request: Request, + store: &AdmissionSecretStore, + expected: StatusCode, + ) { + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let path = request.uri().to_string(); + let mut envelope = block_on(dispatch_request(app, request)).unwrap(); + assert_eq!(store.reads.load(Ordering::SeqCst), 1); + assert_eq!(store.active.load(Ordering::SeqCst), 0); + assert_eq!(envelope.response_mut().status(), expected, "{path}"); + if expected != StatusCode::OK { + assert!(!envelope.response_mut().headers().contains_key("set-cookie")); + } + assert_auxiliary_receipt_held(gates, peak); + drop(envelope); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + } + + #[test] + fn pending_sync_signing_reads_keep_original_receipts_until_cancellation_and_recover() { + boot(); + let _serialized = gate_counters_lock(); + let app = configured_app(); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let role = live_catalog::redirect(); + let config = serde_json::json!({ + "schema_version": 1_u8, + "host": { + "user_sync": {"external_url": "https://sync.example.test"}, + "aliases": {"fake_sync_alias": role.name()} + }, + "bidders": {(role.name()): {"enabled": true}} + }); + let cases = [ + (Method::GET, "/getuids", Vec::new()), + ( + Method::POST, + "/cookie_sync", + serde_json::to_vec(&serde_json::json!({"bidders": [], "gdpr": 0_u8})).unwrap(), + ), + ( + Method::GET, + "/setuid?bidder=fake_sync_alias&uid=partner-user", + Vec::new(), + ), + ]; + let mut checked = 0_usize; + for (method, path, body) in cases { + let request_for = |store| { + let mut request = configured_request( + method.clone(), + path, + Body::from_bytes(body.clone()), + &config, + ); + attach_secret_store(&mut request, store); + request + }; + let store = admission_secret_store( + SecretBehavior::Pending, + "usersync_cookie_keyring", + usersync_keys::SECRET_JSON.as_bytes(), + ); + let mut construction = + Box::pin(dispatch_request(&app, request_for(Arc::clone(&store)))); + assert!( + construction + .as_mut() + .poll(&mut Context::from_waker(noop_waker_ref())) + .is_pending(), + "{path} must suspend in the keyring read" + ); + assert_eq!(store.reads.load(Ordering::SeqCst), 1); + assert_eq!(store.active.load(Ordering::SeqCst), 1); + assert_auxiliary_receipt_held(gates, peak); + let refusal = dispatch(&app, Method::GET, "/info/bidders", Body::empty()).unwrap(); + let probe = dispatch(&app, Method::GET, "/status", Body::empty()).unwrap(); + assert_eq!(gates.static_egress.in_use(), 1); + assert_eq!(probe_egress_gate().in_use(), 1); + assert_auxiliary_receipt_held(gates, peak); + drop(construction); + assert_eq!(store.active.load(Ordering::SeqCst), 0); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + assert_eq!(gates.static_egress.in_use(), 1); + assert_eq!(probe_egress_gate().in_use(), 1); + drop(refusal); + drop(probe); + assert_eq!(gates.static_egress.in_use(), 0); + assert_eq!(probe_egress_gate().in_use(), 0); + let recovered = admission_secret_store( + SecretBehavior::Ready, + "usersync_cookie_keyring", + usersync_keys::SECRET_JSON.as_bytes(), + ); + assert_signing_response_handoff( + &app, + request_for(Arc::clone(&recovered)), + &recovered, + StatusCode::OK, + ); + let unavailable = admission_secret_store( + SecretBehavior::Unavailable, + "usersync_cookie_keyring", + usersync_keys::SECRET_JSON.as_bytes(), + ); + assert_signing_response_handoff( + &app, + request_for(Arc::clone(&unavailable)), + &unavailable, + StatusCode::SERVICE_UNAVAILABLE, + ); + checked = checked.checked_add(1).unwrap(); + } + assert_eq!(checked, 3); + } + + #[test] + fn configured_info_query_and_unknown_name_errors_keep_original_terminal_funding() { + boot(); + let _serialized = gate_counters_lock(); + let app = configured_app(); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let config = serde_json::json!({"schema_version": 1_u8}); + let cases: [(&str, StatusCode, &[u8]); 3] = [ + ( + "/info/bidders?enabledonly=not_bool", + StatusCode::BAD_REQUEST, + b"Invalid value for 'enabledonly' query param, must be of boolean type", + ), + ( + "/info/bidders?baseadaptersonly=not_bool", + StatusCode::BAD_REQUEST, + b"Invalid value for 'baseadaptersonly' query param, must be of boolean type", + ), + ( + "/info/bidders/fake_missing_info_bidder", + StatusCode::NOT_FOUND, + b"", + ), + ]; + let mut checked = 0_usize; + for (path, status, body) in cases { + for (complete, writing) in [(true, true), (false, false), (false, true)] { + let request = configured_request(Method::GET, path, Body::empty(), &config); + let mut envelope = block_on(dispatch_request(&app, request)).unwrap(); + let response = envelope.response_mut(); + assert_eq!(response.status(), status); + assert_eq!(response.body().as_bytes().unwrap(), body); + assert!(!response.headers().contains_key("content-type")); + assert_auxiliary_receipt_held(gates, peak); + let (prepared, _policy, mut attempt, clock) = envelope + .begin(ResponseEgressFramingMode::ExplicitBufferedLength) + .unwrap_or_else(|_| panic!("configured info error egress begins")); + if writing { + assert!(attempt.begin_writing()); + } + drop(prepared); + assert_auxiliary_receipt_held(gates, peak); + if complete { + assert!(attempt.terminate(ResponseEgressOutcome::Completed, clock.now())); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + assert!(!attempt.terminate(ResponseEgressOutcome::Completed, clock.now())); + } + drop(attempt); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + checked = checked.checked_add(1).unwrap(); + } + } + assert_eq!(checked, 9); + } + + #[test] + fn aux_work_is_released_once_when_application_egress_is_abandoned_before_or_during_writing() { + boot(); + let _serialized = gate_counters_lock(); + let reports = EgressReports::default(); + let mut app = configured_app(); + app.set_response_egress_observer(reports.clone()); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let cases = auxiliary_terminal_requests(&app); + assert!(!cases.is_empty()); + for (method, path) in cases { + for writing in [false, true] { + let envelope = dispatch(&app, method.clone(), &path, Body::empty()).unwrap(); + let (prepared, _policy, mut attempt, _clock) = envelope + .begin(ResponseEgressFramingMode::ExplicitBufferedLength) + .unwrap_or_else(|_| panic!("aux response egress begins")); + if writing { + assert!(attempt.begin_writing()); + } + drop(prepared); + assert_auxiliary_receipt_held(gates, peak); + assert!(reports.0.lock().unwrap().is_empty()); + assert!(gates.aux.try_acquire().is_none()); + drop(attempt); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + let completed = take(&mut *reports.0.lock().unwrap()); + assert_eq!(completed.len(), 1, "{path}, writing={writing}"); + assert_eq!(completed[0].body_kind, ResponseEgressBodyKind::Application); + assert_eq!(completed[0].fallback_disposition, None); + assert_eq!( + completed[0].outcome, + if writing { + ResponseEgressOutcome::TransportError + } else { + ResponseEgressOutcome::ConversionError + } + ); + } + } + } + + #[test] + fn aux_work_survives_real_sdk_fallback_preparation_until_completion_or_abandonment() { + boot(); + let _serialized = gate_counters_lock(); + let reports = EgressReports::default(); + let mut app = configured_app(); + app.set_response_egress_observer(reports.clone()); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let cases = auxiliary_terminal_requests(&app); + assert!(!cases.is_empty()); + let endings = [ + (Some(ResponseEgressFallbackDisposition::Completed), true), + (Some(ResponseEgressFallbackDisposition::Aborted), false), + (Some(ResponseEgressFallbackDisposition::Aborted), true), + (None, false), + (None, true), + ]; + for (method, path) in cases { + for cause in [ + ResponseEgressOutcome::ConversionError, + ResponseEgressOutcome::DeadlineExceeded, + ] { + for (disposition, writing) in endings { + let mut envelope = + dispatch(&app, method.clone(), &path, Body::empty()).unwrap(); + assert_auxiliary_receipt_held(gates, peak); + let (mut attempt, clock) = if cause == ResponseEgressOutcome::ConversionError { + // The real normalizer returns the original live attempt, + // rather than a fabricated completion or a second grant. + envelope + .response_mut() + .headers_mut() + .insert("content-length", "invalid".parse().unwrap()); + let Err(failure) = + envelope.begin(ResponseEgressFramingMode::ExplicitBufferedLength) + else { + panic!("invalid framing must return the live attempt"); + }; + let (attempt, clock, actual) = failure.into_parts(); + assert_eq!(actual, cause); + (attempt, clock) + } else { + let (prepared, _policy, attempt, clock) = envelope + .begin(ResponseEgressFramingMode::ExplicitBufferedLength) + .unwrap_or_else(|_| panic!("aux response egress begins")); + drop(prepared); + (attempt, clock) + }; + assert_auxiliary_receipt_held(gates, peak); + assert!(attempt.begin_fallback(cause)); + assert_auxiliary_receipt_held(gates, peak); + assert!(!attempt.begin_fallback(cause)); + let fallback = http::Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from_bytes(b"internal error\n".to_vec())) + .unwrap(); + let prepared = attempt + .prepare_response( + ResponseEgressFramingMode::ExplicitBufferedLength, + &method, + fallback, + ) + .unwrap(); + assert_auxiliary_receipt_held(gates, peak); + if writing { + assert!(attempt.begin_writing()); + } + drop(prepared); + assert_auxiliary_receipt_held(gates, peak); + assert!(reports.0.lock().unwrap().is_empty()); + assert!(gates.aux.try_acquire().is_none()); + if let Some(ending) = disposition { + assert!(attempt.finish_fallback(ending, clock.now())); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + assert!(!attempt.finish_fallback(ending, clock.now())); + } + drop(attempt); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + let completed = take(&mut *reports.0.lock().unwrap()); + assert_eq!( + completed.len(), + 1, + "{path}, {cause:?}, {disposition:?}, writing={writing}" + ); + assert_eq!(completed[0].body_kind, ResponseEgressBodyKind::Fallback); + assert_eq!(completed[0].outcome, cause); + assert_eq!( + completed[0].fallback_disposition, + Some(disposition.unwrap_or(ResponseEgressFallbackDisposition::Aborted)) + ); + } + } + } + } + + #[test] + fn terminal_cases_join_every_registered_auxiliary_and_both_router_fallbacks() { + let app = configured_app(); + let expected: HashSet<_> = app + .router() + .routes() + .into_iter() + .filter(|route| route.class() == Some(AUX_CLASS)) + .map(|route| route.id().clone()) + .collect(); + assert!(!expected.is_empty()); + let mut matched = HashSet::new(); + let mut not_found = 0_usize; + let mut wrong_method = 0_usize; + for (method, path) in auxiliary_terminal_requests(&app) { + assert!( + !path.contains(['{', '}']), + "unexpanded route fixture: {path}" + ); + match app.router().resolve(&method, &path).resolution() { + RouteResolution::Matched(route) => { + assert_eq!(route.class(), Some(AUX_CLASS)); + assert!( + matched.insert(route.id().clone()), + "duplicate route: {method} {path}" + ); + } + RouteResolution::NotFound => not_found = not_found.checked_add(1).unwrap(), + RouteResolution::MethodNotAllowed { .. } => { + wrong_method = wrong_method.checked_add(1).unwrap(); + } + _ => panic!("unrecognized router disposition"), + } + } + assert_eq!(matched, expected); + assert_eq!(not_found, 1); + assert_eq!(wrong_method, 1); + + let expected_probes: HashSet<_> = app + .router() + .routes() + .into_iter() + .filter(|route| route.class() == Some(PROBE_CLASS)) + .map(|route| route.id().clone()) + .collect(); + assert!(!expected_probes.is_empty()); + let mut matched_probes = HashSet::new(); + for (method, path) in registered_terminal_requests(&app, PROBE_CLASS) { + assert!( + !path.contains(['{', '}']), + "unexpanded probe fixture: {path}" + ); + let resolved = app.router().resolve(&method, &path); + let RouteResolution::Matched(route) = resolved.resolution() else { + panic!("probe fixture must reach its registered handler"); + }; + assert_eq!(route.class(), Some(PROBE_CLASS)); + assert!(matched_probes.insert(route.id().clone())); + } + assert_eq!(matched_probes, expected_probes); + } + + #[test] + fn pending_configuration_cancellation_releases_each_admitted_owner() { + boot(); + let _serialized = gate_counters_lock(); + let app = configured_app(); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let mut checked = 0_usize; + for (method, path) in registered_terminal_requests(&app, AUX_CLASS) { + let store = admission_store(StoreBehavior::Pending); + let request = store_request(method.clone(), &path, Arc::clone(&store)); + let mut construction = Box::pin(dispatch_request(&app, request)); + let first = construction + .as_mut() + .poll(&mut Context::from_waker(noop_waker_ref())); + assert_auxiliary_receipt_held(gates, peak); + assert!( + first.is_pending(), + "{method} {path} must suspend at its bounded config read" + ); + assert_eq!(store.reads.load(Ordering::SeqCst), 1); + assert_eq!(store.active.load(Ordering::SeqCst), 1); + let refusal = dispatch(&app, Method::GET, "/info/bidders", Body::empty()).unwrap(); + assert_eq!(gates.static_egress.in_use(), 1); + assert_eq!(gates.aux.in_use(), 1); + assert_eq!(gates.aux_work_budget.in_use(), peak); + drop(refusal); + assert_eq!(gates.static_egress.in_use(), 0); + let probe = dispatch(&app, Method::GET, "/status", Body::empty()).unwrap(); + assert_eq!(probe_egress_gate().in_use(), 1); + assert_eq!(gates.aux.in_use(), 1); + assert_eq!(gates.aux_work_budget.in_use(), peak); + drop(probe); + assert_eq!(probe_egress_gate().in_use(), 0); + assert_eq!(store.active.load(Ordering::SeqCst), 1); + drop(construction); + assert_eq!(store.active.load(Ordering::SeqCst), 0); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + drop(dispatch(&app, method, &path, Body::empty()).unwrap()); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + checked = checked.checked_add(1).unwrap(); + } + assert_eq!( + checked, + app.router() + .routes() + .iter() + .filter(|route| route.class() == Some(AUX_CLASS)) + .count() + ); + assert!(checked > 0); + } + + #[test] + fn failed_configuration_keeps_original_work_receipt_through_error_handoff() { + boot(); + let _serialized = gate_counters_lock(); + let app = configured_app(); + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + let mut checked = 0_usize; + for (method, path) in registered_terminal_requests(&app, AUX_CLASS) { + let store = admission_store(StoreBehavior::Unavailable); + let request = store_request(method, &path, Arc::clone(&store)); + let mut response = block_on(dispatch_request(&app, request)).unwrap(); + assert_eq!( + response.response_mut().status(), + if path == "/_auctioneer/health/gvl" { + StatusCode::UNAUTHORIZED + } else { + StatusCode::SERVICE_UNAVAILABLE + } + ); + assert_eq!(store.reads.load(Ordering::SeqCst), 1); + assert_eq!(store.active.load(Ordering::SeqCst), 0); + assert_eq!(gates.aux.in_use(), 1); + assert_eq!(gates.aux_work_budget.in_use(), peak); + drop(response); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + checked = checked.checked_add(1).unwrap(); + } + assert_eq!( + checked, + app.router() + .routes() + .iter() + .filter(|route| route.class() == Some(AUX_CLASS)) + .count() + ); + assert!(checked > 0); + } + + fn body_read_request( + method: &Method, + path: &str, + config: &serde_json::Value, + ) -> (Request, Option>) { + let sync = matches!(path, "/cookie_sync" | "/getuids" | "/setuid"); + let store = (!sync).then(|| admission_store(StoreBehavior::Pending)); + let mut request = if let Some(pending_store) = &store { + store_request(method.clone(), path, Arc::clone(pending_store)) + } else { + configured_request(method.clone(), path, Body::empty(), config) + }; + if method == Method::OPTIONS { + request + .headers_mut() + .insert("origin", "https://publisher.example.test".parse().unwrap()); + request + .headers_mut() + .insert("access-control-request-method", "POST".parse().unwrap()); + request.headers_mut().insert( + "access-control-request-headers", + "Content-Type".parse().unwrap(), + ); + } + (request, store) + } + + fn assert_body_read_terminal( + app: &App, + method: &Method, + path: &str, + config: &serde_json::Value, + cancel: bool, + ) { + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + assert!(peak > 0, "the pending body must retain nonzero funded work"); + let (mut request, store) = body_read_request(method, path, config); + let (wake, timeout) = oneshot::channel::<()>(); + let body_polls = Arc::new(AtomicUsize::new(0)); + let observed_polls = Arc::clone(&body_polls); + *request.body_mut() = Body::from_stream(stream::once(async move { + observed_polls.fetch_add(1, Ordering::SeqCst); + timeout + .await + .expect("the fixture delivers the adapter timeout"); + Err::(EdgeError::request_timeout("fixed adapter timeout")) + })); + let mut construction = Box::pin(dispatch_request(app, request)); + assert!( + construction + .as_mut() + .poll(&mut Context::from_waker(noop_waker_ref())) + .is_pending() + ); + assert!(!wake.is_canceled(), "the admitted body must still be held"); + assert_eq!(body_polls.load(Ordering::SeqCst), 1, "{method} {path}"); + if let Some(pending_store) = &store { + assert_eq!(pending_store.reads.load(Ordering::SeqCst), 0); + } + assert_eq!(gates.aux.in_use(), 1); + assert_eq!(gates.aux_work_budget.in_use(), peak); + assert_eq!(gates.admission.in_use(), 0); + assert_eq!(gates.static_egress.in_use(), 0); + if cancel { + drop(construction); + assert!( + wake.is_canceled(), + "cancellation must drop the stalled body" + ); + } else { + wake.send(()).unwrap(); + let Poll::Ready(Some(mut response)) = construction + .as_mut() + .poll(&mut Context::from_waker(noop_waker_ref())) + else { + panic!("{method} {path}: the adapter timeout must finish dispatch"); + }; + assert_eq!( + response.response_mut().status(), + StatusCode::REQUEST_TIMEOUT + ); + assert_eq!( + response.response_mut().body().as_bytes(), + Some(b"request timeout\n".as_slice()) + ); + assert_eq!(gates.aux.in_use(), 1); + assert_eq!(gates.aux_work_budget.in_use(), peak); + drop(response); + drop(construction); + } + if let Some(pending_store) = &store { + assert_eq!(pending_store.reads.load(Ordering::SeqCst), 0); + } + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + drop(dispatch(app, method.clone(), path, Body::empty()).unwrap()); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + } + + #[test] + fn body_read_timeout_and_cancellation_preserve_the_original_auxiliary_receipt() { + boot(); + let _serialized = gate_counters_lock(); + let app = configured_app(); + // Sync handlers must resolve configured policy before polling their + // bodies; the collector and fallback drains must not read policy. + let config = serde_json::json!({ + "schema_version": 1_u8, + "host": {"user_sync": { + "external_url": "https://sync.example.test", + "allowed_origins": ["https://publisher.example.test"] + }} + }); + let cases = auxiliary_terminal_requests(&app) + .into_iter() + .filter(|(method, path)| { + matches!( + path.as_str(), + "/info/bidders" | "/cookie_sync" | "/getuids" | "/setuid" + ) || !matches!( + app.router().resolve(method, path).resolution(), + RouteResolution::Matched(_) + ) + }) + .collect::>(); + assert_eq!(cases.len(), 7); + let expected_cases = cases.len().checked_mul(2).unwrap(); + let mut checked = 0_usize; + for (method, path) in cases { + for cancel in [false, true] { + assert_body_read_terminal(&app, &method, &path, &config, cancel); + checked = checked.checked_add(1).unwrap(); + } } + assert_eq!(checked, expected_cases); + assert!(checked > 0); } #[test] diff --git a/crates/auctioneer-core/tests/response_serialization.rs b/crates/auctioneer-core/tests/response_serialization.rs index 245479e..61c699a 100644 --- a/crates/auctioneer-core/tests/response_serialization.rs +++ b/crates/auctioneer-core/tests/response_serialization.rs @@ -1,4 +1,19 @@ //! Allocation and byte-boundary guards for final-response serialization. +//! Real-App refusal controls run in a child of this target so its boot envelope +//! cannot contaminate the existing no-envelope serialization controls. + +#[cfg(all(test, not(target_family = "wasm")))] +pub use auctioneer_core::test_support::bidder_fixture; +#[cfg(all(test, not(target_family = "wasm")))] +mod bounded_store; +#[cfg(all(test, not(target_family = "wasm")))] +mod fanout_limits; +#[cfg(all(test, not(target_family = "wasm")))] +#[expect( + dead_code, + reason = "shared App support exposes helpers used by other integration targets" +)] +mod usersync_app; mod alloc_track { #![expect( @@ -46,6 +61,21 @@ mod alloc_track { (result, measured) } + #[cfg(not(target_family = "wasm"))] + pub fn arm_denial(size: usize) { + assert!( + ACTIVE.with(|cell| cell.get().is_some()), + "an allocation fault requires an active measurement" + ); + DENY_SIZE.with(|cell| { + assert_eq!( + cell.replace(Some(size)), + None, + "arm only one allocation fault" + ); + }); + } + fn record(size: usize) -> bool { ACTIVE .try_with(|cell| { @@ -106,10 +136,18 @@ static MEASURING_ALLOC: alloc_track::MeasuringAllocator = alloc_track::Measuring #[cfg(test)] mod tests { use super::alloc_track::measure; + use auctioneer_core::handlers::sync_http::{ + ResponseBuildError, SyncDebugStatus, cookie_sync_success, getuids_success, + }; + use auctioneer_core::host_primitives::boot_gates; use auctioneer_core::serialize::{ SerializeError, serialize_response, serialize_response_fitting_debug_within, serialize_response_within, }; + use auctioneer_core::usersync::{ + cookie::{CookieWriteError, CookieWriteOptions, UidCookie, prepare_set_cookie_value}, + limits::CookieLimits, + }; use auctioneer_openrtb::types::bid_response::BidResponse; use serde_json::json; use std::io::ErrorKind; @@ -273,8 +311,483 @@ mod tests { ); } + #[test] + fn sync_raw_json_reservation_failure_returns_no_partial_response_or_retry() { + for input_bytes in [2049, 8193] { + let input = "x".repeat(input_bytes); + let debug = [SyncDebugStatus { + bidder: "fake_bidder", + error: &input, + }]; + let expected = json!({ + "status": "no_cookie", "bidder_status": [], + "debug": [{"bidder": "fake_bidder", "error": input}] + }); + let reservation = serde_json::to_vec(&expected) + .unwrap() + .len() + .checked_add(1) + .unwrap(); + let cookie = UidCookie::default(); + let build = || { + cookie_sync_success( + &cookie, + time::OffsetDateTime::UNIX_EPOCH, + &[], + Some(&debug), + reservation, + ) + }; + let (failed, measured) = measure(Some(reservation), build); + assert_eq!(failed.unwrap_err(), ResponseBuildError::Serialization); + assert_eq!(measured.denied_requests, 1, "{measured:?}"); + assert_eq!( + measured.requested_bytes, reservation, + "no retry or fallback buffer" + ); + + let (accepted, success_measurement) = measure(None, build); + let response = accepted.unwrap(); + let body = response.body().as_bytes().unwrap(); + assert_eq!(body.len(), reservation); + assert_eq!(body.last(), Some(&b'\n')); + assert_eq!( + serde_json::from_slice::(body).unwrap(), + expected + ); + assert!(!response.headers().contains_key("set-cookie")); + assert_eq!(success_measurement.denied_requests, 0); + assert_eq!(success_measurement.largest_request, reservation); + } + } + + fn html_json_fixture( + input_bytes: usize, + now: time::OffsetDateTime, + ) -> (UidCookie, Vec, Vec) { + let input = "<".repeat(input_bytes); + let mut cookie = UidCookie::default(); + cookie + .set_uid("fake_bidder", &input, now, &CookieLimits::default()) + .unwrap(); + let raw = serde_json::to_vec(&json!({"buyeruids": {"fake_bidder": input}})).unwrap(); + let mut escaped = String::from_utf8(raw.clone()) + .unwrap() + .replace('<', "\\u003c"); + escaped.push('\n'); + (cookie, raw, escaped.into_bytes()) + } + + #[test] + fn sync_html_json_reservation_failures_return_no_partial_response_or_retry() { + for input_bytes in [1049, 2053] { + let (cookie, raw, expected) = + html_json_fixture(input_bytes, time::OffsetDateTime::UNIX_EPOCH); + assert!(expected.len() > raw.len()); + // Only the owning input projection may allocate before the first + // reservation; do not hide retries inside an aggregate allowance. + let (projection, preparation) = measure(None, || cookie.uids()); + assert!(preparation.requested_bytes > 0); + drop(projection); + for reservation in [raw.len(), expected.len()] { + let (failed, measured) = measure(Some(reservation), || { + getuids_success(&cookie, expected.len()) + }); + assert_eq!(failed.unwrap_err(), ResponseBuildError::Serialization); + assert_eq!(measured.denied_requests, 1, "{measured:?}"); + assert_eq!(measured.largest_request, reservation, "{measured:?}"); + let raw_requested = preparation.requested_bytes.checked_add(raw.len()).unwrap(); + let requested = if reservation == expected.len() { + raw_requested.checked_add(expected.len()).unwrap() + } else { + raw_requested + }; + assert_eq!( + measured.requested_bytes, requested, + "no retry or fallback buffer" + ); + } + let (accepted, measured) = measure(None, || getuids_success(&cookie, expected.len())); + let response = accepted.unwrap(); + assert_eq!(response.body().as_bytes().unwrap(), expected); + assert!(!response.headers().contains_key("set-cookie")); + assert_eq!(measured.denied_requests, 0); + assert_eq!(measured.largest_request, expected.len()); + } + } + + #[test] + fn sync_cookie_field_reservation_failure_returns_no_prepared_header_or_retry() { + for value_bytes in [257, 1049] { + let value = "a".repeat(value_bytes); + for reservation in [3079, 4096] { + let options = CookieWriteOptions { + domain: "sync.example.test", + user_agent: Some("Chrome/130.0"), + hard_cap_bytes: reservation, + ..CookieWriteOptions::default() + }; + let build = + || prepare_set_cookie_value(&value, &options, time::OffsetDateTime::UNIX_EPOCH); + let (name, validation) = measure(None, || { + http::HeaderName::from_bytes(options.name.as_bytes()).unwrap() + }); + drop(name); + let (failed, measured) = measure(Some(reservation), build); + assert_eq!(failed.unwrap_err(), CookieWriteError::Allocation); + assert_eq!(measured.denied_requests, 1, "{measured:?}"); + assert_eq!(measured.largest_request, reservation); + assert_eq!( + measured.requested_bytes, + validation.requested_bytes.checked_add(reservation).unwrap(), + "reservation failure must precede formatting and header conversion" + ); + + let (accepted, success_measurement) = measure(None, build); + let prepared = accepted.unwrap(); + let field = prepared.field_value().to_str().unwrap(); + let parsed = cookie::Cookie::parse(field).unwrap(); + assert_eq!(parsed.name(), "uids"); + assert_eq!(parsed.value(), value); + assert_eq!(parsed.domain(), Some("sync.example.test")); + assert_eq!(parsed.same_site(), Some(cookie::SameSite::None)); + assert_eq!(parsed.secure(), Some(true)); + assert!(field.len() > value_bytes && field.len() <= reservation); + assert_eq!(success_measurement.denied_requests, 0); + assert_eq!(success_measurement.largest_request, reservation); + } + } + } + + #[cfg(not(target_family = "wasm"))] + mod sync_reservation_egress { + use super::{html_json_fixture, measure}; + use crate::{alloc_track, usersync_app}; + use auctioneer_core::{ + host_primitives::boot_gates, + test_support::{live_catalog, usersync_keys}, + usersync::limits::CookieLimits, + }; + use bytes::Bytes; + use edgezero_core::{ + body::Body, + config_store::BoundedStoreRead, + http::{Method, StatusCode}, + response_egress::{ResponseEgressEnvelope, ResponseEgressOutcome}, + response_egress_framing::ResponseEgressFramingMode, + secret_store::{SecretError, SecretHandle, SecretStore}, + store_registry::{BoundSecretStore, SecretRegistry, StoreRegistry}, + time::{Deadline, MonotonicClock}, + }; + use futures::executor::block_on; + use serde_json::{Value, json}; + use std::{ + env::{current_exe, var_os}, + process::Command, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; + + struct ReservationSecrets { + deny_size: Option, + reads: AtomicUsize, + } + + fn assert_auxiliary_receipts(held: bool) { + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + assert!(peak > 0); + assert_eq!(gates.aux.in_use(), u32::from(held)); + assert_eq!(gates.aux_work_budget.in_use(), if held { peak } else { 0 }); + assert_eq!(gates.static_egress.in_use(), 0); + } + + #[async_trait::async_trait(?Send)] + impl SecretStore for ReservationSecrets { + async fn get_bytes(&self, _: &str, _: &str) -> Result, SecretError> { + panic!("the App must use the bounded keyring read"); + } + + async fn get_bytes_bounded( + &self, + store_name: &str, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, SecretError> { + assert_eq!((store_name, key), ("default", "usersync_cookie_keyring")); + assert_eq!((max_backend_bytes, max_value_bytes), (4096, 4096)); + assert!(!deadline.is_expired_at(clock.now())); + let value = Bytes::from_static(usersync_keys::SECRET_JSON.as_bytes()); + let backend_bytes = u64::try_from(value.len()).unwrap(); + assert!(backend_bytes <= max_backend_bytes && backend_bytes <= max_value_bytes); + self.reads.fetch_add(1, Ordering::SeqCst); + assert_auxiliary_receipts(true); + // Configuration and the bounded provider read precede the TLS + // fault; key parsing and the real handler still run normally. + if let Some(size) = self.deny_size { + alloc_track::arm_denial(size); + } + Ok(BoundedStoreRead { + backend_bytes, + value: Some(value), + }) + } + } + + struct ReservationCase { + method: Method, + uri: String, + headers: Vec<(&'static str, String)>, + body: Vec, + expected_json: Option, + deny_size: usize, + failure_status: StatusCode, + failure_body: &'static [u8], + } + + fn reservation_cases() -> Vec { + let bidders: Vec<_> = (0_usize..9) + .map(|index| format!("fake_reservation_{index}_{}", "x".repeat(83))) + .collect(); + let debug: Vec<_> = bidders + .iter() + .map(|bidder| json!({"bidder": bidder, "error": "unknown bidder"})) + .collect(); + let expected = json!({"status": "no_cookie", "bidder_status": [], "debug": debug}); + let raw_size = serde_json::to_vec(&expected) + .unwrap() + .len() + .checked_add(1) + .unwrap(); + assert!(raw_size > 1024 && !raw_size.is_power_of_two()); + let mut cases = vec![ReservationCase { + method: Method::POST, + uri: "/cookie_sync".to_owned(), + headers: vec![("content-type", "application/json".to_owned())], + body: serde_json::to_vec(&json!({ + "bidders": bidders, "debug": true, "gdpr": 0_u8, "coopSync": false + })) + .unwrap(), + expected_json: Some(expected), + deny_size: raw_size, + failure_status: StatusCode::INTERNAL_SERVER_ERROR, + failure_body: b"response limit exceeded\n", + }]; + let denied = json!({"status": "no_cookie", "bidder_status": []}); + cases.push(ReservationCase { + method: Method::POST, + uri: "/cookie_sync".to_owned(), + headers: vec![("content-type", "application/json".to_owned())], + body: serde_json::to_vec(&json!({ + "bidders": ["fake_privacy_denied"], "debug": true, + "gdpr": 1_u8, "gdpr_consent": "malformed-tcf" + })) + .unwrap(), + deny_size: serde_json::to_vec(&denied) + .unwrap() + .len() + .checked_add(1) + .unwrap(), + expected_json: Some(denied), + failure_status: StatusCode::INTERNAL_SERVER_ERROR, + failure_body: b"response limit exceeded\n", + }); + let (cookie, raw, escaped) = html_json_fixture(1049, usersync_app::TEST_NOW); + let header = format!( + "uids={}", + usersync_keys::encode_cookie(&cookie, &CookieLimits::default()).unwrap() + ); + for deny_size in [raw.len(), escaped.len()] { + cases.push(ReservationCase { + method: Method::GET, + uri: "/getuids".to_owned(), + headers: vec![("cookie", header.clone())], + body: Vec::new(), + expected_json: Some(serde_json::from_slice(&raw).unwrap()), + deny_size, + failure_status: StatusCode::INTERNAL_SERVER_ERROR, + failure_body: b"response limit exceeded", + }); + } + cases.push(ReservationCase { + method: Method::GET, + uri: live_catalog::redirect().setuid("uid=reservation-uid&f=b&gdpr=0"), + headers: Vec::new(), + body: Vec::new(), + expected_json: None, + deny_size: 4096, + failure_status: StatusCode::INTERNAL_SERVER_ERROR, + failure_body: b"cookie resource limit exceeded", + }); + cases + } + + fn dispatch( + app: &usersync_app::TestApp, + case: &ReservationCase, + deny_size: Option, + ) -> ResponseEgressEnvelope { + assert_auxiliary_receipts(false); + let provider = Arc::new(ReservationSecrets { + deny_size, + reads: AtomicUsize::new(0), + }); + let concrete = Arc::clone(&provider); + let secrets: Arc = concrete; + let registry: SecretRegistry = StoreRegistry::single_id( + "default".to_owned(), + BoundSecretStore::new(SecretHandle::new(secrets), "default".to_owned()), + ); + let headers: Vec<_> = case + .headers + .iter() + .map(|(name, value)| (*name, value.as_str())) + .collect(); + let body = Body::from_bytes(case.body.clone()); + let config_reads = app.store().reads(); + let (envelope, measured) = measure(None, || { + block_on(app.dispatch_envelope_with( + case.method.clone(), + &case.uri, + &headers, + body, + |request| { + request.extensions_mut().insert(registry); + }, + )) + }); + assert_eq!(app.store().reads(), config_reads.checked_add(1).unwrap()); + assert_eq!(provider.reads.load(Ordering::SeqCst), 1, "no keyring retry"); + assert_eq!( + app.secrets().reads(), + 0, + "the request-bound provider must be used" + ); + assert_eq!( + measured.denied_requests, + usize::from(deny_size.is_some()), + "{measured:?}" + ); + assert!(measured.requested_bytes > 0); + assert_auxiliary_receipts(true); + envelope + } + + fn assert_success(mut envelope: ResponseEgressEnvelope, case: &ReservationCase) { + let response = envelope.response_mut(); + assert_eq!(response.status(), StatusCode::OK); + let body = usersync_app::body(response); + if let Some(expected) = &case.expected_json { + let mut actual: Value = serde_json::from_slice(body).unwrap(); + // The chooser shuffles diagnostics. Compare every independent + // expected row by identity, without depending on RNG state. + if let Some(debug) = actual.get_mut("debug").and_then(Value::as_array_mut) { + debug.sort_unstable_by(|left, right| { + left["bidder"] + .as_str() + .unwrap() + .cmp(right["bidder"].as_str().unwrap()) + }); + } + assert_eq!(actual, *expected); + assert_eq!(body.last(), Some(&b'\n')); + assert!(!response.headers().contains_key("set-cookie")); + } else { + assert!(body.is_empty()); + let field = response.headers()["set-cookie"].to_str().unwrap(); + assert!(field.len() <= 4096); + let cookie = cookie::Cookie::parse(field).unwrap(); + let decoded = + usersync_keys::decode_cookie(cookie.value(), &CookieLimits::default()).unwrap(); + assert!(!decoded.corrupt); + assert_eq!( + decoded.cookie.uid(live_catalog::redirect().family()), + Some("reservation-uid") + ); + } + assert_auxiliary_receipts(true); + drop(envelope); + assert_auxiliary_receipts(false); + } + + #[test] + fn allocation_refusals_keep_original_auxiliary_receipts_through_egress() { + const CHILD: &str = "AUCTIONEER__TEST__SYNC_RESERVATION_CHILD"; + if var_os(CHILD).is_none() { + let child = Command::new(current_exe().unwrap()) + .args([ + "--exact", + "tests::sync_reservation_egress::allocation_refusals_keep_original_auxiliary_receipts_through_egress", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + let output = format!( + "{}{}", + String::from_utf8_lossy(&child.stdout), + String::from_utf8_lossy(&child.stderr) + ); + assert!(child.status.success(), "{output}"); + assert!( + output.contains("1 passed"), + "the child must execute: {output}" + ); + return; + } + let app = usersync_app::TestApp::new( + "schema_version = 1\n[host.user_sync]\nexternal_url = 'https://sync.example.test'\n[host.gdpr]\nenabled = true\nhost_vendor_id = 32\n[host.gdpr.tcf2]\nenabled = true\n", + ); + let mut failures = 0_usize; + for case in reservation_cases() { + // Warm lazy shared state and prove the identical request reaches + // successful output before targeting its one reservation. + assert_success(dispatch(&app, &case, None), &case); + for outcome in [ + Some(ResponseEgressOutcome::Completed), + Some(ResponseEgressOutcome::RequestCancelled), + None, + ] { + let mut envelope = dispatch(&app, &case, Some(case.deny_size)); + let response = envelope.response_mut(); + assert_eq!(response.status(), case.failure_status); + assert_eq!(usersync_app::body(response), case.failure_body); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("set-cookie")); + let (prepared, _policy, mut attempt, clock) = envelope + .begin(ResponseEgressFramingMode::ExplicitBufferedLength) + .unwrap_or_else(|_| { + panic!("the complete refusal response must begin egress"); + }); + assert!(attempt.begin_writing()); + drop(prepared); + assert_auxiliary_receipts(true); + if let Some(terminal) = outcome { + assert!(attempt.terminate(terminal, clock.now())); + assert_auxiliary_receipts(false); + assert!(!attempt.terminate(terminal, clock.now())); + } + drop(attempt); + assert_auxiliary_receipts(false); + failures = failures.checked_add(1).unwrap(); + } + assert_success(dispatch(&app, &case, None), &case); + } + assert_eq!( + failures, 15, + "all five response paths and three egress endings must execute" + ); + } + } + #[test] fn no_envelope_preserves_unbounded_serialization() { + assert!(boot_gates().is_none()); let response = escaped_response(); assert_eq!( serialize_response(&response).expect("no envelope installed in this test binary"), diff --git a/crates/auctioneer-core/tests/serialize_allocation_bound.rs b/crates/auctioneer-core/tests/serialize_allocation_bound.rs index 12aa0f2..967de04 100644 --- a/crates/auctioneer-core/tests/serialize_allocation_bound.rs +++ b/crates/auctioneer-core/tests/serialize_allocation_bound.rs @@ -13,8 +13,9 @@ //! the call, and the allocator is the only place it is observable. A //! counting global allocator can only exist in a test binary, which is //! why this measurement lives here rather than beside the function. -//! Caller-owned response fixtures are built before measurement; no fixture -//! subtraction or host-exact allocation baseline contributes to the bound. +//! Serialization fixtures are built before measurement. Header-history controls +//! build and drop input owners inside their window before sampling retention. +//! Neither uses fixture subtraction or claims physical allocator/guest bounds. /// Test-scoped counting allocator (per-thread, peak bytes) backing the /// serialization allocation bound. Delegates every operation to @@ -55,6 +56,15 @@ mod alloc_track { PEAK.try_with(Cell::get).unwrap_or(0) } + pub fn live_on_this_thread() -> usize { + LIVE.try_with(Cell::get).unwrap_or(0) + } + + pub fn reset_peak_on_this_thread() { + let live = live_on_this_thread(); + let _peak = PEAK.try_with(|cell| cell.set(live)); + } + pub fn track_reallocations_at_least_on_this_thread(size: usize) { let _minimum = REALLOC_MIN_SIZE.try_with(|cell| cell.set(size)); let _count = REALLOCATIONS.try_with(|cell| cell.set(0)); @@ -148,22 +158,26 @@ mod fanout_limits; #[cfg(test)] mod tests { use crate::config_funding_support as config_funding; - use std::{hint::black_box, thread}; + use std::{hint::black_box, str, thread}; use super::alloc_track::{ - peak_on_this_thread, reallocations_on_this_thread, reset_on_this_thread, + live_on_this_thread, peak_on_this_thread, reallocations_on_this_thread, + reset_on_this_thread, reset_peak_on_this_thread, track_reallocations_at_least_on_this_thread, }; + use auctioneer_config_data::usersync::UserSyncConfig; use auctioneer_core::handlers::health_gvl::health_gvl; + use auctioneer_core::handlers::sync_http::{SyncBrowserPolicy, response_header_cap}; use auctioneer_core::host_primitives::{TestClockHandle, install_health_counter_scope}; use auctioneer_core::serialize::{SerializeError, serialize_response_within}; use auctioneer_core::test_support::config_store::{context, runtime_registry}; use auctioneer_openrtb::types::bid_response::BidResponse; use auctioneer_runtime::FakeClock; + use bytes::Bytes; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use futures::executor::block_on; - use http::{Request, StatusCode}; + use http::{HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode}; use std::sync::Arc; use time::OffsetDateTime; @@ -385,4 +399,483 @@ mod tests { .join() .unwrap(); } + + #[test] + fn sync_json_outputs_do_not_reallocate_at_two_independent_sizes() { + use auctioneer_core::handlers::sync_http::{ + SyncDebugStatus, cookie_sync_success, getuids_success, + }; + use auctioneer_core::usersync::{cookie::UidCookie, limits::CookieLimits}; + + thread::spawn(|| { + for input_bytes in [2049, 8193] { + let input = "<".repeat(input_bytes); + let debug = [SyncDebugStatus { + bidder: "fake_bidder", + error: &input, + }]; + let mut cookie = UidCookie::default(); + cookie + .set_uid( + "fake_bidder", + &input, + OffsetDateTime::UNIX_EPOCH, + &CookieLimits::default(), + ) + .unwrap(); + for escape_html in [false, true] { + let build = |cap| { + if escape_html { + getuids_success(&cookie, cap) + } else { + cookie_sync_success( + &UidCookie::default(), + OffsetDateTime::UNIX_EPOCH, + &[], + Some(&debug), + cap, + ) + } + }; + let expected = build(128 * 1024).unwrap().into_body().into_bytes().unwrap(); + track_reallocations_at_least_on_this_thread(expected.len().div_euclid(2)); + let mut growable = Vec::new(); + for chunk in expected.chunks(17) { + growable.extend_from_slice(chunk); + } + let control = reallocations_on_this_thread(); + assert_eq!(growable, expected); + assert!( + control > 0, + "the growable control must register body-sized reallocations" + ); + + track_reallocations_at_least_on_this_thread(expected.len().div_euclid(2)); + let actual = build(expected.len()).unwrap(); + let reallocations = reallocations_on_this_thread(); + assert_eq!(actual.into_body().into_bytes().unwrap(), expected); + assert_eq!( + reallocations, 0, + "{input_bytes} input bytes, escaped={escape_html}" + ); + } + } + }) + .join() + .unwrap(); + } + + #[test] + fn complete_cookie_formatting_never_grows_its_reserved_field() { + use auctioneer_core::usersync::cookie::{CookieWriteOptions, prepare_set_cookie_value}; + + thread::spawn(|| { + for value_bytes in [2049, 3553] { + let value = "a".repeat(value_bytes); + let options = CookieWriteOptions { + domain: "sync.example.test", + user_agent: Some("Chrome/130.0"), + ..CookieWriteOptions::default() + }; + let expected = + prepare_set_cookie_value(&value, &options, OffsetDateTime::UNIX_EPOCH).unwrap(); + let field = expected.field_value(); + assert!(field.as_bytes().len() > value_bytes); + track_reallocations_at_least_on_this_thread(value_bytes.div_euclid(2)); + let mut control = String::new(); + for chunk in field.to_str().unwrap().as_bytes().chunks(17) { + control.push_str(str::from_utf8(chunk).unwrap()); + } + assert!(reallocations_on_this_thread() > 0); + assert_eq!(control.as_bytes(), field.as_bytes()); + + track_reallocations_at_least_on_this_thread(value_bytes.div_euclid(2)); + let prepared = + prepare_set_cookie_value(&value, &options, OffsetDateTime::UNIX_EPOCH).unwrap(); + let growth = reallocations_on_this_thread(); + assert_eq!(prepared.field_value(), field); + assert_eq!(growth, 0, "complete {value_bytes}-byte cookie value"); + } + }) + .join() + .unwrap(); + } + + #[derive(Clone, Copy, Debug)] + struct HeaderShape { + unique_fields: usize, + duplicate_values: usize, + name_bytes: usize, + value_bytes: usize, + } + + const SMALL_HEADER_BACKING_BYTES: usize = 16 * 1024; + const LARGE_HEADER_BACKING_BYTES: usize = 1024 * 1024; + + fn header_history_response( + shape: HeaderShape, + backing_bytes: usize, + removed_fields: usize, + ) -> (Response, Bytes) { + let origin = b"https://buyer.example"; + let origin_end = 32_usize.checked_add(origin.len()).unwrap(); + let values = shape + .unique_fields + .checked_add(shape.duplicate_values) + .unwrap(); + let mut storage = vec![b'x'; backing_bytes]; + storage[32..origin_end].copy_from_slice(origin); + for index in 0..values { + let start = origin_end + .checked_add(index.checked_mul(shape.value_bytes).unwrap()) + .unwrap(); + let end = start.checked_add(shape.value_bytes).unwrap(); + let marker = b'a'.checked_add(u8::try_from(index).unwrap()).unwrap(); + storage[start..end].fill(marker); + } + let backing = Bytes::from(storage); + let mut headers = HeaderMap::new(); + for index in 0..removed_fields { + let name = HeaderName::from_bytes(format!("x-removed-{index}").as_bytes()).unwrap(); + headers.append(name.clone(), HeaderValue::from_static("removed-first")); + headers.append(name, HeaderValue::from_static("removed-second")); + } + assert_eq!(headers.len(), removed_fields.checked_mul(2).unwrap()); + let capacity = headers.capacity(); + assert!(capacity >= removed_fields); + for index in 0..removed_fields { + let name = format!("x-removed-{index}"); + assert_eq!(headers.get_all(&name).iter().count(), 2); + assert_eq!(headers.remove(&name).unwrap(), "removed-first"); + assert!(!headers.contains_key(&name)); + } + assert!(headers.is_empty()); + assert_eq!(headers.capacity(), capacity); + for index in 0..values { + let field = if index < shape.unique_fields { + index + } else { + 0 + }; + let mut name = "x".repeat(shape.name_bytes.checked_sub(1).unwrap()); + name.push(char::from( + b'a'.checked_add(u8::try_from(field).unwrap()).unwrap(), + )); + let start = origin_end + .checked_add(index.checked_mul(shape.value_bytes).unwrap()) + .unwrap(); + let end = start.checked_add(shape.value_bytes).unwrap(); + let mut value = HeaderValue::from_maybe_shared(backing.slice(start..end)).unwrap(); + assert_eq!( + value.as_bytes().as_ptr().addr(), + backing.as_ptr().addr().checked_add(start).unwrap(), + ); + assert_eq!(value.as_bytes().len(), shape.value_bytes); + value.set_sensitive(index.is_multiple_of(2)); + headers.append(HeaderName::from_bytes(name.as_bytes()).unwrap(), value); + } + headers.append("vary", HeaderValue::from_static("Accept-Encoding")); + headers.append("vary", HeaderValue::from_static("Origin")); + headers.insert("cache-control", HeaderValue::from_static("no-store")); + headers.insert( + "access-control-allow-origin", + HeaderValue::from_maybe_shared(backing.slice(32..origin_end)).unwrap(), + ); + headers.insert( + "access-control-allow-credentials", + HeaderValue::from_static("true"), + ); + let mut response = Response::new(Body::from_bytes(b"unchanged".to_vec())); + *response.status_mut() = StatusCode::ACCEPTED; + *response.headers_mut() = headers; + (response, backing) + } + + fn assert_header_history_output(response: &Response, before: &[(String, Vec, bool)]) { + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!(response.body().as_bytes().unwrap(), b"unchanged"); + assert_eq!(response.headers().len(), before.len()); + for ((name, value), (old_name, old_value, sensitive)) in + response.headers().iter().zip(before) + { + assert_eq!(name.as_str(), old_name); + assert_eq!(value.as_bytes(), old_value); + assert_eq!(value.is_sensitive(), *sensitive); + } + assert_eq!(response.headers()["cache-control"], "no-store"); + assert_eq!( + response.headers()["access-control-allow-origin"], + "https://buyer.example", + ); + assert_eq!( + response.headers()["access-control-allow-credentials"], + "true" + ); + assert!( + response + .headers() + .get_all("vary") + .iter() + .map(HeaderValue::as_bytes) + .eq([b"Accept-Encoding".as_slice(), b"Origin".as_slice()]), + ); + } + + // An independent compact reference, not the production decorator. Collection + // reserves for the full value count and preserves duplicate values. + fn compact_header_heap(shape: HeaderShape) -> usize { + thread::spawn(move || { + let (source, backing) = header_history_response(shape, SMALL_HEADER_BACKING_BYTES, 0); + let before: Vec<_> = source + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value.as_bytes().to_vec(), + value.is_sensitive(), + ) + }) + .collect(); + drop(source); + drop(backing); + // Borrow the reference inputs without counting their storage. + // This thread measures only the independent compact map. + reset_on_this_thread(); + let headers: HeaderMap = before + .iter() + .map(|(name, bytes, sensitive)| { + let mut value = HeaderValue::from_bytes(bytes).unwrap(); + value.set_sensitive(*sensitive); + (HeaderName::from_bytes(name.as_bytes()).unwrap(), value) + }) + .collect(); + assert_eq!(headers.len(), before.len()); + let retained = live_on_this_thread(); + drop(headers); + assert_eq!(live_on_this_thread(), 0); + retained + }) + .join() + .expect("compact-header reference thread completed") + } + + struct HeaderAllocation { + retained: usize, + input_live: usize, + construction_peak: usize, + construction_allowance: usize, + } + + impl HeaderAllocation { + fn construction_within_bound(&self) -> bool { + self.construction_peak + <= self + .input_live + .checked_add(self.construction_allowance) + .unwrap() + } + } + + // Requested native heap only: retain the input in the construction window; + // all fixture owners die before the final live sample. This is a regression + // control, not an allocator/guest certificate or a new production reserve. + fn measured_header_history( + shape: HeaderShape, + backing_bytes: usize, + removed_fields: usize, + decorate: bool, + transient_control: bool, + ) -> HeaderAllocation { + // Complete the reference before starting the measured thread. Spawning + // a nested thread inside it would mix cross-thread ownership into TLS + // allocation counters, even after that nested thread has been joined. + let construction_allowance = compact_header_heap(shape).checked_mul(2).unwrap(); + thread::spawn(move || { + reset_on_this_thread(); + let input_live; + let construction_peak; + let response = { + let (source_response, backing) = + header_history_response(shape, backing_bytes, removed_fields); + let source_capacity = source_response.headers().capacity(); + let before: Vec<_> = source_response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value.as_bytes().to_vec(), + value.is_sensitive(), + ) + }) + .collect(); + let config = UserSyncConfig { + allowed_origins: vec!["https://buyer.example".to_owned()], + ..UserSyncConfig::default() + }; + let mut request = HeaderMap::new(); + request.insert( + "origin", + source_response.headers()["access-control-allow-origin"].clone(), + ); + assert_eq!( + request["origin"].as_bytes().as_ptr().addr(), + backing.as_ptr().addr().checked_add(32).unwrap(), + ); + // One compact final set plus at most one complete set of CORS + // replacement scratch. The reference uses the locked HTTP + // allocator behavior, without guessing its private layouts. + input_live = live_on_this_thread(); + assert!(input_live >= backing_bytes); + reset_peak_on_this_thread(); + if transient_control { + let scratch = vec![0_u8; construction_allowance.checked_add(1).unwrap()]; + black_box(&scratch); + drop(scratch); + } + let response = if decorate { + SyncBrowserPolicy::new(&config) + .decorate(source_response, &request, response_header_cap()) + .expect("the complete fixture fits the existing header contract") + } else { + source_response + }; + construction_peak = peak_on_this_thread(); + assert_header_history_output(&response, &before); + let start = backing.as_ptr().addr(); + let addresses = start..start.checked_add(backing.len()).unwrap(); + let shared = response + .headers() + .values() + .filter(|value| addresses.contains(&value.as_bytes().as_ptr().addr())) + .count(); + if decorate { + assert_eq!(shared, 0); + if removed_fields > 0 { + assert!(response.headers().capacity() < source_capacity); + } + } else { + let expected_shared = shape + .unique_fields + .checked_add(shape.duplicate_values) + .unwrap() + .checked_add(1) + .unwrap(); + assert_eq!(shared, expected_shared); + assert_eq!(response.headers().capacity(), source_capacity); + } + response + }; + let retained = live_on_this_thread(); + assert!(retained > 0 && retained <= construction_peak); + drop(response); + assert_eq!( + live_on_this_thread(), + 0, + "the response was the last measured owner", + ); + HeaderAllocation { + retained, + input_live, + construction_peak, + construction_allowance, + } + }) + .join() + .expect("header-history measurement thread completed") + } + + #[test] + fn sync_header_retention_is_independent_of_source_backing_and_map_history() { + for unique_fields in [1_usize, 5] { + for duplicate_values in [0_usize, 3] { + for name_bytes in [17_usize, 129] { + for value_bytes in [19_usize, 271] { + let shape = HeaderShape { + unique_fields, + duplicate_values, + name_bytes, + value_bytes, + }; + let reference = measured_header_history( + shape, + SMALL_HEADER_BACKING_BYTES, + 0, + true, + false, + ); + assert!(reference.construction_within_bound()); + for backing_bytes in + [SMALL_HEADER_BACKING_BYTES, LARGE_HEADER_BACKING_BYTES] + { + for removed_fields in [0_usize, 256] { + let measured = measured_header_history( + shape, + backing_bytes, + removed_fields, + true, + false, + ); + assert!( + measured.construction_within_bound(), + "construction peak {}, input {}, allowance {}: {shape:?}, backing={backing_bytes}, removed={removed_fields}", + measured.construction_peak, + measured.input_live, + measured.construction_allowance + ); + assert_eq!( + measured.retained, reference.retained, + "{shape:?}, backing={backing_bytes}, removed={removed_fields}", + ); + } + } + } + } + } + } + } + + #[test] + fn header_history_control_detects_retained_shared_backing_and_map_storage() { + let shape = HeaderShape { + unique_fields: 2, + duplicate_values: 3, + name_bytes: 17, + value_bytes: 19, + }; + let small = measured_header_history(shape, SMALL_HEADER_BACKING_BYTES, 0, false, false); + let large = measured_header_history(shape, LARGE_HEADER_BACKING_BYTES, 0, false, false); + assert_eq!( + large.retained.checked_sub(small.retained).unwrap(), + LARGE_HEADER_BACKING_BYTES + .checked_sub(SMALL_HEADER_BACKING_BYTES) + .unwrap(), + ); + let with_history = + measured_header_history(shape, SMALL_HEADER_BACKING_BYTES, 256, false, false); + assert!( + with_history.retained > small.retained, + "removed fields must leave detectable map storage", + ); + } + + #[test] + fn header_construction_control_detects_temporary_storage_that_is_not_retained() { + let shape = HeaderShape { + unique_fields: 2, + duplicate_values: 3, + name_bytes: 17, + value_bytes: 19, + }; + let reference = measured_header_history(shape, SMALL_HEADER_BACKING_BYTES, 0, true, false); + assert!(reference.construction_within_bound()); + for backing in [SMALL_HEADER_BACKING_BYTES, LARGE_HEADER_BACKING_BYTES] { + for removed in [0, 256] { + let transient = measured_header_history(shape, backing, removed, true, true); + assert_eq!(transient.retained, reference.retained); + assert!(!transient.construction_within_bound()); + } + } + } } diff --git a/crates/auctioneer-core/tests/usersync_routes.rs b/crates/auctioneer-core/tests/usersync_routes.rs index e97e0d7..7a8f778 100644 --- a/crates/auctioneer-core/tests/usersync_routes.rs +++ b/crates/auctioneer-core/tests/usersync_routes.rs @@ -385,7 +385,21 @@ mod tests { MonotonicInstant::now(), config, ); - mem::replace(envelope.response_mut(), Response::new(Body::empty())) + // These wire fixtures include URL/body/cookie construction refusals. + // Handler return must preserve the original admission receipt on all + // of them; extracting bytes here is not the production egress path. + let gates = boot_gates().unwrap(); + let peak = gates.aux_work_derived.peak_bytes(); + assert!(peak > 0); + assert_eq!(gates.aux.in_use(), 1); + assert_eq!(gates.aux_work_budget.in_use(), peak); + assert_eq!(gates.static_egress.in_use(), 0); + let response = mem::replace(envelope.response_mut(), Response::new(Body::empty())); + assert_eq!(gates.aux_work_budget.in_use(), peak); + drop(envelope); + assert_eq!(gates.aux.in_use(), 0); + assert_eq!(gates.aux_work_budget.in_use(), 0); + response } fn assert_browser_wire( diff --git a/docs/bidders/schema-qualification.md b/docs/bidders/schema-qualification.md index 3f9542a..e516ac0 100644 --- a/docs/bidders/schema-qualification.md +++ b/docs/bidders/schema-qualification.md @@ -1,5 +1,7 @@ # Schema Compiler Qualification +For the current source-bound closure, implemented components and remaining proof owners, see [resource proof ownership](../testing/resource-proof-ownership.md). Historical checkpoint prose below retains its original scope; it is not current target certification. + **2026-11-10 publication amendment:** Mocktioneer is now compiled in both feature lanes, disabled by default, with an explicit endpoint required to enable it. Current shipped source checks require 18 native bindings (four guest diff --git a/docs/testing/development.md b/docs/testing/development.md index 0155a46..ca40294 100644 --- a/docs/testing/development.md +++ b/docs/testing/development.md @@ -27,7 +27,7 @@ their diagnostics include standard-library source excerpts. CI installs the same component; do not regenerate snapshots to compensate for missing Rust sources. The current EdgeZero dependency is the immutable PR #275 integration revision -`598f0a2a490eb83dd31c06c024a0cefa8e049628`, not a moving branch selector. +`a7d7fd0f2face027e3810ed67e80f6341fcf68b6`, not a moving branch selector. Acquisition requires that upstream Git object to remain available. A missing object is a source-availability failure, not a failed runtime test; do not silently select another revision. Resolve the actual fetched checkout for @@ -35,7 +35,7 @@ SDK-dependent tests rather than guessing Cargo's cache directory: ```sh set -euo pipefail -pin=598f0a2a490eb83dd31c06c024a0cefa8e049628 +pin=a7d7fd0f2face027e3810ed67e80f6341fcf68b6 metadata=$(mktemp) cargo metadata --locked --offline --format-version 1 > "$metadata" manifest=$(jq -er --arg source "git+https://github.com/IABTechLab/edgezero.git?rev=$pin#$pin" ' @@ -66,7 +66,7 @@ git --no-replace-objects -C "$AUCTIONEER__BIDDER__SOURCE_DIR" fetch --no-tags -- Historical evidence retains the revision and original bytes it exercised. Its older SDK pin is not a current-source reproduction instruction; use the -[current SDK update record](evidence/edgezero-pr275-update-20261102.md) and +[current source/SDK ownership index](resource-proof-ownership.md) and the commands above for the current tree. Fetching dependencies alone does not requalify historical results for another executable. diff --git a/docs/testing/provider-handlers.md b/docs/testing/provider-handlers.md index 4a17349..d0612ee 100644 --- a/docs/testing/provider-handlers.md +++ b/docs/testing/provider-handlers.md @@ -40,7 +40,7 @@ The SDK archive tests are ordinary Rust unit tests, including in workspace explicit `EDGEZERO_SDK_ROOT`, or discover the dependency checkout through locked, offline Cargo metadata. A missing or invalid root fails rather than skips. The checkout must come from [IABTechLab/edgezero PR #275](https://github.com/IABTechLab/edgezero/pull/275) -at exactly `598f0a2a490eb83dd31c06c024a0cefa8e049628`. Follow the +at exactly `a7d7fd0f2face027e3810ed67e80f6341fcf68b6`. Follow the [fresh-checkout bootstrap](development.md#fresh-checkout) before offline runs; an empty cache cannot fetch a Git revision in offline mode. Historical evidence retains the SDK revision it actually exercised; this diff --git a/docs/testing/resource-proof-ownership.md b/docs/testing/resource-proof-ownership.md new file mode 100644 index 0000000..f56bc4d --- /dev/null +++ b/docs/testing/resource-proof-ownership.md @@ -0,0 +1,154 @@ +# Current Resource Proof Ownership + +Date: 2026-11-11. This is a source-linked index for [#33](https://github.com/IABTechLab/auctioneer/issues/33), [#50](https://github.com/IABTechLab/auctioneer/issues/50) and [#48](https://github.com/IABTechLab/auctioneer/issues/48), not another issue tracker or a target certificate. + +## Identity And Status + +The source baseline is merged `main` at `bc2193096920752cf3b149a1496f9deee1432f8f`, including #160's runtime health policy and publication changes. The approved specification and plan landed in #161 at `ec9d7905002f7f4b760144b7cf0fedc0773a9bd2`. The storage and terminal controls are supplied by [#162](https://github.com/IABTechLab/auctioneer/pull/162); its handoff and current captures bind executed checks to the exact implementation commit. Earlier checkpoints are not recertified by updating this index. + +- SDK: `a7d7fd0f2face027e3810ed67e80f6341fcf68b6` (PR #275 head captured on 2026-11-11). +- `Cargo.lock` SHA-256: `e7f92633b6b27c6d97bdffcbd51958de64970b67d0da3e8f6a16197b1827f61f`. +- Toolchain: Rust 1.98.1; native observations use `aarch64-apple-darwin`. +- Locked HTTP/storage dependencies: `http 1.4.2`, `bytes 1.12.1`, `cookie 0.18.2`. +- Reproduction owners below identify executing controls, not a substitute for the complete feature/compiler/producer/guest acceptance matrix. + +**Acceptance boundary:** #33's closure reconciliation and #50's SDK correspondence are supplied for review. #48 is partial: bounded application storage and terminal controls do not establish complete container/allocator/host-copy bounds. This PR changes no isolate charge, workload limit, headroom, schema selection, provider fact or historical evidence. It preserves the independently measured execution-frame change already merged in #160. No target is newly declared ready. + +The tables describe source-bound proof state and code/storage ownership. Responsible human roles, concrete next actions, priority, task status and native blockers live only on the linked GitHub issues, not in this index. + +The 2026-11-11 SDK refresh advances nine PR #275 commits from `598f0a2a` to the pinned head above. It preserves the retained outbound scope/cancellation contract, decoder dependency tuple, published platform facts and extraction limits; Rust remains 1.98.1. It adopts Fastly same-turn buffered readiness, absolute native timer targets, explicit late-batch cutoff handling, framing normalization fixes and pinned CLI manifest selection. Fastly's additional batch wake signal is included in the bookkeeping correspondence below, not treated as physically certified or charged through an invented constant. Earlier execution evidence retains its original SDK and does not certify this refresh. + +## Actual Executable Closure + +The sources of membership are [factory discovery](../../crates/auctioneer-config/src/bidder_discovery.rs), [publication generation](../../crates/auctioneer-config/src/codegen/mod.rs), the generated `discovery.rs`/`registry.rs` and [schema staging](../../crates/auctioneer-config/src/schema_staging_build.rs). The table records observations at the stated baseline, not a new hardcoded membership gate. + +| Population | Native Secure / Development | Guest Secure / Development | +| --- | --- | --- | +| Compiled candidate factories | 17 / 17 | 17 / 17 | +| Published executable bindings, including public aliases | 18 / 18 | 4 / 4 | +| Effective parameter schemas behind those bindings | 16 / 16 | 3 / 3 | +| Inspection-selected schemas | 16 / 16 | 16 / 16 | +| Controlled-native serving reservation | Installed when funded | Not available | + +`native_only` constrains publication, not candidate factory discovery. Runtime-disabled definitions remain in their compiled lane. A public alias shares its resolved implementation and effective schema; schema-free bindings do not acquire an invented schema. Inspection selection is not the guest-serving population. + +The selected schema closure has four generated regex helpers and one shared hostname program, plus hostname and UUID format callbacks. Equal patterns in distinct generated owners are not shared allocations. Native admission installs tested reservations of 16 MiB resident, 8 MiB initialization and 512 KiB validation scratch per auction through [the existing schema owner](../../crates/auctioneer-bidder/src/schema/native.rs); these are not certified worst-case bounds. + +Imported inventory is a different population: 358 identities, 261 bases, 97 aliases, 259 implementation references, 261 main schema artifacts and two separately witnessed schema artifacts. There are 262 bidder manifests, 245 without an executable factory. Full-source controls remain required, but an unused imported schema is not a serving prerequisite. Some staging/source controls still select pinned priority/supplemental projections; replacing that selection with complete discovery is remaining [#46](https://github.com/IABTechLab/auctioneer/issues/46)/[#47](https://github.com/IABTechLab/auctioneer/issues/47) work, not delivered by this index. + +## Application Proof Owners + +In this table `K` is admitted concurrent auctions, `N` is the per-auction call bound, `S` is active body streams, `A` is auxiliary route permits and `P = max(A, PROBE_CONFIG_MIN_PERMITS)` is independent probe-configuration work. Units and populations are separate: one retained result per completed call is not bounded just by currently active streams. + +| Surface | Code Owner, Units And Lifetime | Implemented Components / Remaining Evidence | Owning Issues | +| --- | --- | --- | --- | +| Schema **C** | Compiler/library program owners; retained heap and linked code/data bytes; initialization through instance/execution teardown. Independently compiled owners coexist. | Library-owned layouts and component models exist; complete selected-program composition/allocator certificates do not. [Qualification owner](../bidders/schema-qualification.md), [source conformance](../../crates/auctioneer-bidder/tests/schema_source_conformance.rs). | [#46](https://github.com/IABTechLab/auctioneer/issues/46) | +| Schema **I** | Constructor temporary bytes and source work; begins before construction, ends after success/failure cleanup, overlaps previously retained C. | Constructor/cache/failure models exist; aggregate target heap, work and stack remain incomplete. [Construction controls](../../crates/auctioneer-bidder/tests/schema_nfa_construction/mod.rs). | [#46](https://github.com/IABTechLab/auctioneer/issues/46) | +| Schema **V** | Mutable cache/scratch bytes, evaluation work and stack; one bundle per active validation, charged per auction; hostname's per-call cache can overlap the outer bundle. | Explicit cache ownership and conditional component certificates exist, not whole-target worst-case qualification. [Generated dispatch](../../crates/auctioneer-config/src/schema_staging_build.rs), [conformance owner](../../crates/auctioneer-bidder/tests/schema_source_conformance.rs). | [#47](https://github.com/IABTechLab/auctioneer/issues/47) | +| Configuration | Raw/parsed/converted/merged/retained bytes and JSON values; fund before extraction; auction, A and P work can coexist. | Pre-extraction funding and captured-cap guards exist. [Runtime extraction](../../crates/auctioneer-core/src/config/runtime.rs), [funding controls](../../crates/auctioneer-core/tests/config_funding.rs); [#130](https://github.com/IABTechLab/auctioneer/issues/130) is delivered implementation history, not an outstanding proof owner. | [#147](https://github.com/IABTechLab/auctioneer/issues/147) | +| Sealed response DTO | Delivered bytes/nodes and retained candidate storage; admission through decode/publication/drop; completed candidates remain after streams close. | One-shot claims, rollback, candidate credits and decode caps exist. [Decode session](../../crates/auctioneer-bidder/src/response_decode/session.rs), [node controls](../../crates/auctioneer-bidder/tests/response_node_budget.rs). | [#150](https://github.com/IABTechLab/auctioneer/issues/150) | +| Ordinary Serde audit | Lexical nodes versus visitor allocations, work and stack during request/config/sync decoding. | Structural limits and raw-capture refusal exist; they are not a complete audit of every visitor. [Bounded parser](../../crates/auctioneer-runtime/src/bounded_json.rs). | [#148](https://github.com/IABTechLab/auctioneer/issues/148) | +| Auxiliary storage | Raw/escaped body, headers, complete cookie and construction scratch bytes; admission through terminal egress, A live slots. | This checkpoint bounds sync output reservation, detaches final sync headers and caps the existing cookie formatter. The signed-cookie encode stage still grows raw/HTML-escaped JSON buffers and constructs base64 payload/envelope strings before the final field preflight; their allocation, simultaneous lifetimes and repeated eviction passes remain #48 evidence. Complete physical container/guest/provider-copy certificates remain absent. [Storage owners](../../crates/auctioneer-core/src/handlers/sync_http.rs), [cookie owner](../../crates/auctioneer-core/src/usersync/cookie.rs), [allocation owner](../../crates/auctioneer-core/tests/usersync_handler_alloc.rs). | [#48](https://github.com/IABTechLab/auctioneer/issues/48) | +| Auxiliary/probe lifetime | A route slots/work receipts end at completion or abandonment; P configuration slots end at resolution/render/publication; probe output/cache, static output and auction populations stay independent. | Existing [admission](../../crates/auctioneer-core/src/ingress.rs), [real SDK controls](../../crates/auctioneer-core/tests/ingress_admission.rs), [probe pending-read controls](../../crates/auctioneer-core/tests/probe_pending_reads.rs) and [registered-route join](../../crates/auctioneer-core/tests/aux_routes_wrapped.rs) implement these owners. | [#48](https://github.com/IABTechLab/auctioneer/issues/48) | +| GVL backing | Distinct parsed lists, not Arc handles; cache residency plus K/A requests pinning versions evicted from cache. | Separate shared backing and retained-version population are already charged in [the runtime](../../crates/auctioneer-runtime/src/budget.rs). | [#9](https://github.com/IABTechLab/auctioneer/issues/9) | +| Combined host artifact | Original funding, host bridge/engine/egress bytes, stack and actual linked artifact; settings-first preparation through retained result and terminal handoff. | The portable API/owned receipts exist; standalone Auctioneer acceptance is not Trusted Server acceptance. [Services](../../crates/auctioneer-core/src/auction/services.rs), [preparation](../../crates/auctioneer-core/src/auction/prepare.rs). | [#76](https://github.com/IABTechLab/auctioneer/issues/76), [Trusted Server #1510](https://github.com/IABTechLab/trusted-server/issues/1510) | + +## SDK Correspondence + +All SDK references in this section point into [the exact pinned tree](https://github.com/IABTechLab/edgezero/tree/a7d7fd0f2face027e3810ed67e80f6341fcf68b6/crates). Existing payload or Auctioneer metadata charges do not automatically prove container, channel, task or SDK bookkeeping coverage. + +| SDK Owner | Funding Correspondence And Lifetime | Missing Source-Bound Evidence / Owning Issues | +| --- | --- | --- | +| Prepared contexts, preflight outcomes, batch slots | `edgezero-core/src/outbound/{dispatch,batch}.rs`; per-wave context vectors coexist with requests. Request backing maps to K times N call-backed auction work. | Complete vector/context/layout byte bounds and their funded populations are unproved. [#50](https://github.com/IABTechLab/auctioneer/issues/50). | +| Callback/body scopes and registrations | `edgezero-core/src/outbound/scope.rs`; base scopes exist outside auction-only admission. Callback revocation and accepted-body handoff have different terminal points; empty handles can outlive payloads. | Scope/registration populations across ingress, A, P, streams and terminal egress are not fully mapped. [#50](https://github.com/IABTechLab/auctioneer/issues/50), [#72](https://github.com/IABTechLab/auctioneer/issues/72)–[#75](https://github.com/IABTechLab/auctioneer/issues/75). | +| Tasks, channels and cooperative queues | Adapter outbound scheduling: Axum/Cloudflare batch tasks request `slots + 1` channel capacity; Spin's unbounded-channel API has a finite event producer; Fastly uses cooperative drivers/queues. | Complete task/future/channel allocation and simultaneous-queue bounds are absent; finite event count is not a physical capacity certificate. [#50](https://github.com/IABTechLab/auctioneer/issues/50). | +| Fastly batch readiness signals | `edgezero-adapter-fastly/src/outbound/{scope,progress}.rs`; each batch polling closure retains an Arc-backed wake-request flag, and each observation creates a source waker that may remain registered until replaced or dropped. Ready buffered work is finalized in the same observation turn, before later provider calls can advance its completion timestamp. | The flag, source-waker wrappers and overlapping registrations belong to SDK bookkeeping; retained outbound scope/cancellation ownership is preserved, but physical storage/population correspondence remains unproved. [#50](https://github.com/IABTechLab/auctioneer/issues/50), [#73](https://github.com/IABTechLab/auctioneer/issues/73). | +| Completed queues, state vectors, boxed receipts | `edgezero-core/src/outbound/batch.rs`; Auctioneer [consumes each `next()` item](../../crates/auctioneer-core/src/outbound/wave.rs) immediately, but retains completed results across waves until publication/drop. Payload demand therefore includes K times N, not only S. | Queue/vector/box overhead and overlap remain unproved. Unused SDK accumulator/merge/collect APIs are not serving prerequisites. [#50](https://github.com/IABTechLab/auctioneer/issues/50). | +| Scope teardown | `edgezero-core/src/outbound/{scope,task}.rs`; root gathering, cleanup vectors and detached boxed payloads can coexist with live callbacks/body owners. | Cleanup peak and surviving wrapper/waker storage lack complete population correspondence. [#50](https://github.com/IABTechLab/auctioneer/issues/50). | +| Axum shared transport and idle connection pool | `edgezero-adapter-axum/src/{service,outbound}.rs`; one service-owned client is cloned into requests, sharing its native transport and pool. Idle connections can outlive completed calls until eviction or client teardown. Locked `reqwest 0.13.4` defaults retain idle connections for 90 seconds with `usize::MAX` idle connections per host; the SDK builder does not override either setting. | Active call/stream permits do not by themselves bound the retained idle-host/connection population. Complete client, connector, pool, background-maintenance and host/socket storage correspondence remains unproved; a nonzero baseline is not a certificate for it. This is not a demonstrated undercharge or a new pool policy. [#50](https://github.com/IABTechLab/auctioneer/issues/50), [#72](https://github.com/IABTechLab/auctioneer/issues/72). | +| Fastly recorder/configuration-key set | `edgezero-adapter-fastly/src/context.rs`; callback owns recorder and distinct configuration keys; numeric observation handles can survive callback exit. | Set capacity, insertion overlap and the separately surviving observation lifetime lack complete storage bounds. [#50](https://github.com/IABTechLab/auctioneer/issues/50), [#73](https://github.com/IABTechLab/auctioneer/issues/73). | +| Fastly backend cache | `edgezero-adapter-fastly/src/outbound.rs`; static `BACKENDS` retains names, identities and backend handles for execution/instance lifetime. Successful registration precedes a later deadline check. | Persistent identity count and host storage are not certified; callback teardown does not establish cache eviction. [#50](https://github.com/IABTechLab/auctioneer/issues/50), [#73](https://github.com/IABTechLab/auctioneer/issues/73). | +| Codec/framing/egress | SDK `edgezero-core/src/compression.rs`; per-layer decoder/windows and wrappers scale with S. Auction egress, A/P work, terminal output and probe output/cache retain separate owners in [the runtime](../../crates/auctioneer-runtime/src/budget.rs). | Source/ABI/allocator/backend certificates remain distinct from application header caps and provider-added framing. [#49](https://github.com/IABTechLab/auctioneer/issues/49), [#72](https://github.com/IABTechLab/auctioneer/issues/72)–[#75](https://github.com/IABTechLab/auctioneer/issues/75). | + +### Adapter Facts And Proof Boundaries + +These are source declarations, not live deployment observations. `K/N/S/A` names the configured workload, not a provider-enforced inbound population. + +| Adapter | Published Facts And Existing Enforcement | Missing Evidence / Owning Issues | +| --- | --- | --- | +| Axum | Ceiling, inbound population and host-ingress scope are operator-configured unknowns. The SDK has a finite pre-parser connection gate; default 256, while Auctioneer boot requires no more than 384 connections. K/N/S/A = 2/16/2/1. | Actual operator quota/scope, runner settings and allocation correspondence remain unbound. SDK parser allocation APIs exist, but their Rust 1.95 audit is not a Rust 1.98.1 certificate. [#72](https://github.com/IABTechLab/auctioneer/issues/72). | +| Cloudflare | 128,000,000 bytes per instance; inbound population and host-ingress accounting are unknown. Auctioneer charges population 6; K/N/S/A = 6/32/32/4. | Inbound enforcement and JS/host/Wasm scope correspondence remain unpublished/unproved. Six charged slots do not prove six actual inbound requests. [#74](https://github.com/IABTechLab/auctioneer/issues/74). | +| Fastly | 128,000,000 primary bytes, separate 1,000,000-byte stack, one inbound request per execution; ingress accounting unknown. K/N/S/A = 1/32/32/1. | Ingress contracts and the application's required stack against its linked artifact remain unproved, including the recorder/cache owners above. [#73](https://github.com/IABTechLab/auctioneer/issues/73). | +| Akamai Functions hosted Spin | 134,217,728-byte hosted default, one inbound request per execution; ingress accounting unknown. Hosted metadata reaches the actual entrypoint/dispatch. K/N/S/A = 1/32/32/1. | Authorized deployment provenance and physical/host correspondence remain unbound. No current generic-versus-hosted dispatch mismatch is established. [#75](https://github.com/IABTechLab/auctioneer/issues/75). | + +[The existing evaluator](../../crates/auctioneer-cli/src/bidders/resources_platform.rs) has nine controls: scope mismatch, unexpected separate stack, nonunit execution population, once-scaled demand, stack boundary/proof shape, configured overflow independent of external unknowns, unknown facts/population coverage, minimum of independent ceilings/headroom, and numeric excess preserving external blockers. A configured-ceiling overflow is Auctioneer demand failure even if the provider ceiling is unknown. Missing facts remain nonpassing. A nonempty stack-proof string is only a shape check, not authentication of an artifact certificate. [Dispatch/layout checks](../../crates/auctioneer-cli/src/bidders/resources/mod.rs) remain separate. + +## Storage Checkpoint And Accounting Decision + +The sync writer now counts through a nonallocating cap-aware sink, reserves once, writes through the existing `CappedWriter`, refuses second-pass growth and shrinkage, and includes its final newline. HTML escaping counts all five substitutions before reserving escaped output. Raw and escaped buffers coexist during transformation. It changes neither successful bytes nor the existing cap. Info/health retain their existing distinct `serialize_json_exact` contract. + +Sync final headers are rebuilt only after their current limits pass. Final names/values own bounded bytes, duplicates and sensitivity are retained, and removed fields cannot leave a large final map behind. The old map, new map, response body and cookie can overlap during copying; no claim is made that this also bounds arbitrary caller-owned input maps before the builder is called. `allowed_origin` already copied the matching configured origin and is not replaced with an ingress slice. Provider-added headers remain outside these application limits. + +Complete cookie formatting still uses `cookie::Cookie`'s formatter, now writing to a fallibly reserved capped field instead of a growable string. The original preflight, complete 4-KiB field limit, dates, attributes, signed payload and byte-reference tests remain. Its phases differ: date-format scratch overlaps the reserved field during formatting, then dies before conversion; field backing overlaps the copied `HeaderValue` during conversion. Later response decoration can overlap that value with its detached replacement. A physical proof must account for each reachable phase, not invent a simultaneous three-buffer peak or omit the later copy. + +Cookie field storage failures have the public `CookieWriteError::Allocation` variant, separate from codec and hard limits. Setuid returns HTTP 500 for that server-side failure and retains HTTP 400 for existing cookie transaction errors, with the same fixed error body and no cookie. The real-App allocator control verifies the status and original receipt lifetime; the exhaustive handler mapping separately pins the unchanged limit responses. This adds a public enum variant; downstream exhaustive matches must handle it. It changes no signed-cookie format, cap or resource charge. + +The signed-cookie encoder is a separate input-producing stage, not covered by the formatter's final-field reservation proof. `prepare_cookie_transaction` calls `encode_cookie` on each eviction pass before the 4-KiB field preflight. Its two `BoundedWriter` buffers start empty and grow while retaining raw and HTML-escaped JSON simultaneously (up to the existing decoded-byte limit, 24 KiB by default); base64 encoding and envelope/MAC formatting then construct additional strings. #48 still needs source/allocator/guest bounds for those capacities, copies, overlap and repeated passes. This checkpoint changes neither that encoder nor its limits, and a bounded final cookie is not a proof of bounded physical input construction. + +### Actual Application Producers + +These are current application-owned fields, before SDK/provider framing. The limits of the sync decorator do not apply automatically to info, health, probe or canonical errors. + +| Producer And Source | Final Shape And Relevant Overlap | +| --- | --- | +| [Sync responses](../../crates/auctioneer-core/src/handlers/sync_http.rs) | Cookie-sync/getuids use cache-control and content-type; setuid adds an optional complete set-cookie field and, for its image form, content-type/content-length. Cookie-sync errors add content-type/nosniff. CORS can add allow-origin, allow-credentials and Vary. Final detachment overlaps the old/new maps, name/value backing and existing body; duplicate values have separate storage. | +| Sync preflight, same owner | Successful preflight has six unique names but eight values, including three distinct Vary values, and an empty 204 body. Denial has cache-control and a 403. Map sizing cannot use unique-name count as if it counted all values. | +| [Info](../../crates/auctioneer-core/src/handlers/info_bidders.rs) | Successful output has one JSON content-type field; invalid boolean and unknown detail responses have no application headers. Serialization failure may retain the content-type with an empty body. The map is built fresh, not passed through sync detachment. | +| [GVL health](../../crates/auctioneer-core/src/handlers/health_gvl.rs) | Denial retains WWW-Authenticate and cache-control: no-store; authorized output retains JSON content-type and cache-control: no-store. Every admitted health request resolves bounded runtime policy, including omitted policy or a missing bearer. The full resolved policy is dropped before the authenticated secret read. The handler accepts at most 4096 backend/value bytes, but a provider may materialize more before refusing; this is not a provider-allocation bound. The small selected policy/reference and original auxiliary receipt survive that read. Secret comparison scratch is distinct from response-retained storage. Archive inspection follows successful authorization; the handler is no longer archive-only. | +| [Configuration errors](../../crates/auctioneer-core/src/config/runtime.rs), [canonical renderer](../../crates/auctioneer-core/src/host_primitives/error_renderer.rs) and SDK router fallbacks | Content-type/cache-control can coexist with config-status or Retry-After. Required-header maps and the rendered map can overlap; framework 405 can add Allow. Buffered drain/refusal responses also set content-length; admission prebuilds both bounded drain alternatives. A text body constructed from `&str` is not necessarily allocation-free. | +| [Probe](../../crates/auctioneer-core/src/handlers/probe_config.rs) | Config-status accompanies an empty 204 or bounded 200 banner/diagnostic. Fresh verdict, diagnostic and rendered body can overlap; publishing a fresh verdict can overlap an old cached verdict. The cache retains verdict text, not the complete response. Probe-config work, output permits and cache residency have distinct lifetimes. | + +For locked `http 1.4.2`, map storage includes private indices, first-value buckets and a separate duplicate-value vector. Fresh map capacities at total-value reservations 1/2/3/4-6/7-12/13-16 are 1/2/3/6/12/24. Thus the eight-value preflight reserves capacity 12, not six or eight. These are source observations, not a public byte-layout contract: `capacity()` does not report backing bytes. Custom names, values and `Bytes` sharing metadata remain separate allocations. `HeaderValue::from_bytes` copies; `from_maybe_shared(Bytes)` can retain a large original allocation. `Bytes::from(Vec)` can retain spare capacity and sharing metadata can be allocated when cloned. Fallible map-capacity checks do not make every internal backing allocation recoverably fallible. + +[Native allocation controls](../../crates/auctioneer-core/tests/serialize_allocation_bound.rs) vary unique fields, duplicate values, custom-name length and value length independently. Source backing is independently 16 KiB or 1 MiB, with zero or 256 removed fields. Before-state pointer/capacity checks prove those owners exist. All fixture owners are created and dropped inside the measurement window before final retention is sampled. Identical final headers retain identical requested bytes across those histories; bypassing decoration provides separate positive controls for retained backing and removed-field map storage. These measurements do not infer an allocator layout, RSS ceiling or guest bound. + +The same matrix separately bounds native construction peaks against the live input plus two independently collected compact header sets: one final set and a complete-set allowance for CORS replacement scratch. The compact reference completes in its own thread before measurement begins; only the peak counter is reset before decoration, leaving the live input counted. A temporary-allocation positive control must exceed this guard while retaining exactly the same final bytes. This is an input-relative native regression control, not a physical certificate or a new production reservation. + +### Terminal Controls + +[Ingress controls](../../crates/auctioneer-core/tests/ingress_admission.rs) derive terminal cases from registered auxiliary/probe routes and join route identities back to the SDK router, with explicit framework 404/405 cases. Base responses exercise each SDK completion outcome and abandonment with held auxiliary permits and original work receipts. Separate injected configuration-store failures verify held receipts through error-envelope handoff and drop; they do not repeat the entire outcome cross-product. Pending configuration cancellation proves one bounded read has started before dropping it for every registered auxiliary route, including health. Failed health policy resolution retains its 401 denial and no-store/Bearer headers, while other configuration-dependent routes retain their 503 error contract. Matched non-body collection and both fallback drains exercise body timeout and cancellation before configuration work. Existing [sync route controls](../../crates/auctioneer-core/tests/usersync_routes.rs) also assert held receipts on URL/body/cookie construction refusals before releasing the envelope. Their wire assertions are unchanged. + +Additional admitted-App controls reach authorized health JSON and keep its original auxiliary/work receipt through every pinned terminal outcome and abandonment before/after writing. They suspend at a bounded health-token read before cancellation, assert active provider-future destruction and independent probe/static occupancy, then recover. Secret-store failure reaches health's no-store/Bearer denial with the same held receipt. The separate health authorization owners remain required for access-policy semantics; these receipt controls do not certify provider allocation. + +The stalled-body control also drives configured cookie-sync, its accepted preflight, getuids and setuid beyond policy resolution into their own body reads. A poll counter proves that the stream actually started before the pending assertions; the body remains held with one auxiliary slot and the full original work receipt. Cancellation destroys the body and releases both counters. An injected body-read timeout returns exactly 408 with `request timeout\n`, retaining both counters until the response is dropped. The matched collector and framework fallback rows still prove they do not read configuration before draining. These controls do not replace sync byte/privacy tests or certify body/provider allocation. + +Successful info list, filtered list, base/alias detail and `all` responses now exercise original receipt retention through completion and abandonment with independently varied runtime enable/disable policy. Configured invalid-query and unknown-name errors retain the same original receipt and their exact wire contracts. The metadata-only control discovers its base from the published catalog and uses a synthetic operator alias; no provider protocol or handwritten bidder roster is involved. Sync cancellation controls separately suspend cookie-sync, getuids and setuid at their bounded signing-keyring read, beyond configuration extraction; cancellation, provider failure, and successful recovery retain the disjoint auxiliary, probe and static owners. + +The existing [serialization failure owner](../../crates/auctioneer-core/tests/response_serialization.rs) injects a recoverable allocator refusal at each raw JSON, HTML-escaped JSON and complete-cookie reservation. Pure controls independently vary input and reservation sizes, assert the fault actually fired, reject partial output and retries, and retain byte/content positive controls. Its real-App child process reaches all four reservation sites, checks complete handler-specific refusal responses with no cookie, and keeps the original route/work receipts through completion, cancellation and abandonment. The child preserves this target's existing no-envelope controls; no extra integration target or production failure hook is added. + +Distinct lifecycle controls abandon the application attempt both before and after writing starts, and assert the observer's corresponding conversion-error or transport-error report. Real SDK fallback selection and preparation retain the original auxiliary permit and work receipt through completion, explicit abort and abandonment before/during writing. The conversion case begins with a genuine framing rejection returning the live attempt; the deadline case selects fallback from a prepared initial attempt, rather than claiming to test clock expiry. Each case asserts exactly one report with the original cause and fallback disposition, plus complete counter recovery. + +A fresh-process zero-auxiliary case proves the independent probe-configuration/output and static-refusal owners remain available when auxiliary capacity is zero. Probe output exhaustion/completion controls remain separate; no route acquires a second auxiliary receipt to finish rendering. These are lifetime controls, not certification of the retained containers' physical bytes. + +Existing output funding includes two body buffers plus header/cookie storage; the transform and header-rebuild phases differ. That receipt is not a proof of the locked header map's physical layout or of an allocator/host copy multiplier. Native requested-allocation measurements are controls, not guest physical maxima. + +**No accounting cutover:** retain `(A + P) * (H * D + T)`, with D the unchanged declared auxiliary work peak, H the existing handoff factor and T transmit state. The candidate `A * (D + (H - 1) * R + T) + P * D` is not implemented. A complete R is unavailable: header-map indices/entries/duplicate storage and allocator slack, cookie scratch/conversion overlap, fallback/info/health/probe shapes, guest layouts and provider handoff/copy contracts are not jointly certified. Do not fill R from a wire length or maximum observed allocation, set it to zero, or reuse a native measurement as guest proof. No savings or new fit is claimed. + +#48 therefore retains the unmet complete route/ABI/container/allocator/lifetime proof and applicable SDK/provider correspondences in #50/#72-#75. The route and native allocation controls above do not establish private header-map/`Bytes` representation, allocator size classes/metadata/fragmentation, guest layouts or provider handoff/copy storage. Real bootstrap/CLI parity remains separately required. A missing proof assigned to someone else is still a missing proof, not #48 completion. + +## Reproduction Owners + +Use [development setup and test ownership](development.md), the pinned source checkout and fresh processes. These commands are owner entrypoints, not claims that every matrix job has run at this checkpoint: + +```sh +cargo test --locked -p auctioneer-core --lib +cargo test --locked -p auctioneer-core --test serialize_allocation_bound --test usersync_handler_alloc --test response_serialization +cargo test --locked -p auctioneer-core --test health_gvl --test health_gvl_auth --test health_gvl_failclosed --test health_gvl_observer --test health_gvl_stale_observer --test health_gvl_docs +cargo test --locked -p auctioneer-core --test ingress_admission --test egress_binding --test aux_routes_wrapped --test aux_route_write_deadline --test aux_request_body_bounds +cargo test --locked -p auctioneer-core --test probe_output_before_boot --test probe_pending_reads --test probe_before_boot +cargo test --locked -p auctioneer-cli --bin auctioneer-cli bidders::resources_platform:: +cargo test --locked -p auctioneer-cli --test bidders_resources --test resource_boot_parity +cargo run --locked -p auctioneer-cli -- bidders resources --check --all-adapters --capture-audit +``` + +The real bootstrap/strict parity owner is the subprocess target above, not two calls to a shared helper. Historical [adapter envelopes](adapter-resource-envelopes.md), schema checkpoints and qualification captures retain their original identities and bytes; they are not current target certificates. GitHub retains priorities, blockers and final acceptance. Refresh this index when source/SDK, selection or ownership changes rather than silently recertifying old artifacts. diff --git a/docs/testing/resource-qualification.md b/docs/testing/resource-qualification.md index efaa203..4c3fb1d 100644 --- a/docs/testing/resource-qualification.md +++ b/docs/testing/resource-qualification.md @@ -1,5 +1,7 @@ # Native Resource Qualification +See [current resource proof ownership](resource-proof-ownership.md) for the source/SDK-bound closure, existing ownership controls and remaining target facts. This document's retained captures keep their original source, profile and acceptance scope. + The Rust harness in the existing `auctioneer-cli` measures the actual manifest-generated Axum application through bounded external processes. The harness itself is not acceptance evidence; each measured run needs its own