diff --git a/Cargo.lock b/Cargo.lock index 70316712ac..fe78f03fd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,9 +206,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -528,9 +528,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.3" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -576,9 +576,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -830,7 +830,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1530,9 +1530,10 @@ dependencies = [ [[package]] name = "edgezero-adapter" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" dependencies = [ "base16ct 1.0.0", + "edgezero-core", "serde", "serde_json", "sha2 0.11.1", @@ -1543,21 +1544,32 @@ dependencies = [ [[package]] name = "edgezero-adapter-axum" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" dependencies = [ "anyhow", + "async-stream", "async-trait", "axum", "bytes", "ctor", "edgezero-adapter", "edgezero-core", + "edgezero-store-aws", + "edgezero-store-celld", "futures", "futures-util", + "getrandom 0.4.2", "http", + "http-body", + "hyper", + "hyper-util", + "ipnet", + "libc", "log", + "mime", "redb", "reqwest 0.13.4", + "serde", "serde_json", "simple_logger", "thiserror 2.0.18", @@ -1571,9 +1583,10 @@ dependencies = [ [[package]] name = "edgezero-adapter-cloudflare" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" dependencies = [ "anyhow", + "async-stream", "async-trait", "brotli", "bytes", @@ -1588,13 +1601,15 @@ dependencies = [ "tempfile", "toml_edit 0.25.12+spec-1.1.0", "walkdir", + "wasm-streams", + "web-sys", "worker", ] [[package]] name = "edgezero-adapter-fastly" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" dependencies = [ "anyhow", "async-stream", @@ -1607,6 +1622,8 @@ dependencies = [ "edgezero-adapter", "edgezero-core", "fastly", + "fastly-shared", + "fastly-sys", "fern", "flate2", "futures", @@ -1624,16 +1641,15 @@ dependencies = [ [[package]] name = "edgezero-adapter-spin" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" dependencies = [ "anyhow", + "async-stream", "async-trait", - "brotli", "bytes", "ctor", "edgezero-adapter", "edgezero-core", - "flate2", "futures", "futures-util", "log", @@ -1651,7 +1667,7 @@ dependencies = [ [[package]] name = "edgezero-cli" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" dependencies = [ "chrono", "clap", @@ -1676,31 +1692,43 @@ dependencies = [ [[package]] name = "edgezero-core" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" dependencies = [ + "adler2", + "alloc-no-stdlib", + "alloc-stdlib", "anyhow", "async-compression", "async-stream", "async-trait", "base16ct 1.0.0", + "brotli", + "brotli-decompressor", "bytes", + "compression-codecs", + "compression-core", + "crc32fast", "edgezero-macros", + "flate2", "futures", "futures-util", "http", "http-body", "log", "matchit 0.9.2", + "miniz_oxide", "ryu", "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha2 0.11.1", + "simd-adler32", "thiserror 2.0.18", "toml", "tower-service", "tracing", + "url", "validator", "web-time", ] @@ -1708,18 +1736,39 @@ dependencies = [ [[package]] name = "edgezero-macros" version = "0.1.0" -source = "git+https://github.com/IABTechLab/edgezero?tag=v0.0.11#87456c708ffe9edf86b3a0f8b06dfd15d60b07b5" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" dependencies = [ "log", "proc-macro2", "quote", "serde", "serde_json", - "syn 3.0.4", + "syn 3.0.10", "toml", "validator", ] +[[package]] +name = "edgezero-store-aws" +version = "0.1.0" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "edgezero-store-celld" +version = "0.1.0" +source = "git+https://github.com/IABTechLab/edgezero?rev=456313675b6a85e11acea870240f6a6d869f1137#456313675b6a85e11acea870240f6a6d869f1137" +dependencies = [ + "serde", + "thiserror 2.0.18", + "url", +] + [[package]] name = "ego-tree" version = "0.10.0" @@ -1816,7 +1865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2021,6 +2070,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2204,14 +2259,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasip2", + "wasip3 0.4.0+wasi-0.3.0-rc-2026-01-06", ] [[package]] @@ -3010,7 +3067,7 @@ checksum = "0da1c46491b49458a446cc76f0085860f8164c2290742e0aa8c653ce67240a97" dependencies = [ "bstr", "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "unicode-normalization", ] @@ -3161,6 +3218,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -3169,7 +3235,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3180,7 +3246,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3311,9 +3377,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -3425,7 +3491,7 @@ dependencies = [ "num-derive", "num-iter", "num-traits", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", "strum_macros", @@ -3640,7 +3706,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3954,7 +4020,7 @@ dependencies = [ "cfg-if", "cssparser 0.36.0", "encoding_rs", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "memchr", "mime", @@ -4714,6 +4780,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.10", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -4795,7 +4871,7 @@ dependencies = [ "multimap", "once_cell", "petgraph", - "prettyplease", + "prettyplease 0.2.37", "prost 0.13.5", "prost-types 0.13.5", "regex", @@ -4968,7 +5044,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.3", + "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -5315,7 +5391,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5383,7 +5459,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5897,8 +5973,7 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "spin-macro" version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac8a641da857d4e7cada31a853c40180eeb10485a0f2f0282d0a7e5a2a4105d4" +source = "git+https://github.com/stackpop/spin-rust-sdk.git?rev=6c59ed5b4fcb377072763aee0b27a3abdd8edb72#6c59ed5b4fcb377072763aee0b27a3abdd8edb72" dependencies = [ "proc-macro2", "quote", @@ -5908,8 +5983,7 @@ dependencies = [ [[package]] name = "spin-sdk" version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b7a3d9a6805ccdb4d404e91ccf7198ac5e486b6199dc9387aeebaa368272112" +source = "git+https://github.com/stackpop/spin-rust-sdk.git?rev=6c59ed5b4fcb377072763aee0b27a3abdd8edb72#6c59ed5b4fcb377072763aee0b27a3abdd8edb72" dependencies = [ "anyhow", "bytes", @@ -5919,7 +5993,7 @@ dependencies = [ "http-body-util", "spin-macro", "thiserror 2.0.18", - "wasip3", + "wasip3 0.10.0+wasi-0.3.2", ] [[package]] @@ -6049,9 +6123,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" dependencies = [ "proc-macro2", "quote", @@ -6094,10 +6168,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6550,7 +6624,6 @@ dependencies = [ "error-stack", "futures", "log", - "reqwest 0.12.28", "simple_logger", "temp-env", "tokio", @@ -6571,7 +6644,6 @@ dependencies = [ "edgezero-core", "error-stack", "futures", - "js-sys", "log", "serde_json", "tokio", @@ -6616,14 +6688,11 @@ dependencies = [ "anyhow", "async-trait", "base64", - "brotli", "bytes", "edgezero-adapter-spin", "edgezero-core", "error-stack", - "flate2", "futures", - "http-body-util", "log", "spin-sdk", "tokio", @@ -6702,6 +6771,7 @@ dependencies = [ "cssparser 0.36.0", "derive_more", "ed25519-dalek", + "edgezero-adapter-axum", "edgezero-core", "error-stack", "flate2", @@ -6988,7 +7058,7 @@ version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.3", + "getrandom 0.4.2", "js-sys", "wasm-bindgen", ] @@ -7055,9 +7125,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -7079,15 +7149,24 @@ dependencies = [ [[package]] name = "wasip3" -version = "0.7.1+wasi-0.3.0" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasip3" +version = "0.10.0+wasi-0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e9ed91ee5a995b6fe16c89d4511a8c12eb76967be20b57557618e02e781bcc" +checksum = "d1657e9f48c63d6c3f143f70d5c6e2be7d463371a7af1ed939a5b83d3ca080d0" dependencies = [ "bytes", "http", "http-body", "thiserror 2.0.18", - "wit-bindgen 0.57.1", + "wit-bindgen 0.63.0", ] [[package]] @@ -7147,24 +7226,46 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.247.0" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +dependencies = [ + "leb128fmt", + "wasmparser 0.262.0", ] [[package]] name = "wasm-metadata" -version = "0.247.0" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder 0.262.0", + "wasmparser 0.262.0", ] [[package]] @@ -7182,9 +7283,21 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.247.0" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" dependencies = [ "bitflags 2.13.0", "hashbrown 0.17.1", @@ -7273,7 +7386,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -7534,6 +7647,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" dependencies = [ "bitflags 2.13.0", + "wit-bindgen-rust-macro 0.51.0", ] [[package]] @@ -7541,59 +7655,129 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06" dependencies = [ "bitflags 2.13.0", "futures", - "wit-bindgen-rust-macro", + "wit-bindgen-rust-macro 0.63.0", ] [[package]] name = "wit-bindgen-core" -version = "0.57.1" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.262.0", ] [[package]] name = "wit-bindgen-rust" -version = "0.57.1" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", "indexmap 2.14.0", - "prettyplease", + "prettyplease 0.2.37", "syn 2.0.118", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", + "wasm-metadata 0.244.0", + "wit-bindgen-core 0.51.0", + "wit-component 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease 0.3.0", + "syn 3.0.10", + "wasm-metadata 0.262.0", + "wit-bindgen-core 0.63.0", + "wit-component 0.262.0", ] [[package]] name = "wit-bindgen-rust-macro" -version = "0.57.1" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" dependencies = [ "anyhow", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", "syn 2.0.118", - "wit-bindgen-core", - "wit-bindgen-rust", + "wit-bindgen-core 0.51.0", + "wit-bindgen-rust 0.51.0", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6" +dependencies = [ + "anyhow", + "prettyplease 0.3.0", + "proc-macro2", + "quote", + "syn 3.0.10", + "wit-bindgen-core 0.63.0", + "wit-bindgen-rust 0.63.0", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata 0.244.0", + "wasmparser 0.244.0", + "wit-parser 0.244.0", ] [[package]] name = "wit-component" -version = "0.247.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -7602,20 +7786,19 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", + "wasm-encoder 0.262.0", + "wasm-metadata 0.262.0", + "wasmparser 0.262.0", + "wit-parser 0.262.0", ] [[package]] name = "wit-parser" -version = "0.247.0" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", - "hashbrown 0.17.1", "id-arena", "indexmap 2.14.0", "log", @@ -7624,7 +7807,26 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser", + "wasmparser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.262.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser 0.262.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 139cf4f610..841e1dfeaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,12 +56,12 @@ cssparser = "0.36" derive_more = { version = "2.0", features = ["display", "error"] } directories = "5" ed25519-dalek = { version = "2.2", features = ["rand_core"] } -edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } -edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11" } -edgezero-core = { git = "https://github.com/IABTechLab/edgezero", tag = "v0.0.11", default-features = false } +edgezero-adapter-axum = { git = "https://github.com/IABTechLab/edgezero", rev = "456313675b6a85e11acea870240f6a6d869f1137", default-features = false } +edgezero-adapter-cloudflare = { git = "https://github.com/IABTechLab/edgezero", rev = "456313675b6a85e11acea870240f6a6d869f1137", default-features = false } +edgezero-adapter-fastly = { git = "https://github.com/IABTechLab/edgezero", rev = "456313675b6a85e11acea870240f6a6d869f1137", default-features = false } +edgezero-adapter-spin = { git = "https://github.com/IABTechLab/edgezero", rev = "456313675b6a85e11acea870240f6a6d869f1137", default-features = false } +edgezero-cli = { git = "https://github.com/IABTechLab/edgezero", rev = "456313675b6a85e11acea870240f6a6d869f1137" } +edgezero-core = { git = "https://github.com/IABTechLab/edgezero", rev = "456313675b6a85e11acea870240f6a6d869f1137", default-features = false } env_logger = "0.11" error-stack = "0.6" esi = "0.7.2" @@ -108,7 +108,7 @@ serde_yaml_ng = "0.10" sha2 = "0.11" similar = "2.7" simple_logger = "5" -spin-sdk = { version = "~7.0", default-features = false, features = ["http", "key-value", "variables"] } +spin-sdk = { version = "=7.0.0", git = "https://github.com/stackpop/spin-rust-sdk.git", rev = "6c59ed5b4fcb377072763aee0b27a3abdd8edb72", default-features = false, features = ["http", "key-value", "variables"] } subtle = "2.6" temp-env = "0.3.6" tempfile = "3.24" diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index af707aa24e..4611b23307 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -25,7 +25,6 @@ edgezero-core = { workspace = true } error-stack = { workspace = true } futures = { workspace = true } log = { workspace = true } -reqwest = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } trusted-server-core = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 0b1858e208..88b0934f60 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -155,6 +155,7 @@ fn legacy_admin_alias_denied() -> Response { async fn execute_handler( state: Arc, ctx: RequestContext, + body_limit: Option, handler: F, ) -> Result where @@ -162,7 +163,12 @@ where Fut: Future>>, { let services = state.services_for_request(&ctx); - let mut req = ctx.into_request(); + if ctx.method() == Method::POST + && let Some(limit) = body_limit + { + ctx.body_bytes(limit).await?; + } + let mut req = ctx.into_request()?; if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, &mut req, @@ -284,6 +290,7 @@ fn fallback_handler( Box::pin(execute_handler( state, ctx, + None, |state, services, req| async move { dispatch_fallback(&state, &services, req).await }, )) } @@ -453,6 +460,21 @@ fn named_route_handler( Box::pin(execute_handler( state, ctx, + match handler { + NamedRouteHandler::VerifySignature => { + Some(trusted_server_core::request_signing::endpoints::VERIFY_MAX_BODY_BYTES) + } + NamedRouteHandler::Auction => { + Some(trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE) + } + NamedRouteHandler::FirstPartySign => { + Some(trusted_server_core::proxy::SIGN_MAX_BODY_BYTES) + } + NamedRouteHandler::FirstPartyProxyRebuild => { + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES) + } + _ => None, + }, move |state, services, req| async move { match handler { NamedRouteHandler::TrustedServerDiscovery => { @@ -619,16 +641,16 @@ impl Hooks for TrustedServerApp { "TrustedServer" } - fn routes() -> RouterService { + fn routes() -> Result { let state = match build_state() { Ok(s) => s, Err(ref e) => { log::error!("failed to build application state: {:?}", e); - return startup_error_router(e); + return Ok(startup_error_router(e)); } }; - build_router(&state) + Ok(build_router(&state)) } } @@ -725,7 +747,7 @@ fn build_router(state: &Arc) -> RouterService { router.build() } -fn trace_metadata(state: &AppState, request: &Request) -> TraceMetadata { +fn trace_metadata(state: &AppState, request: &edgezero_core::http::RequestParts) -> TraceMetadata { if let Some(services) = &state.services { let client_info = services.client_info().clone(); let geo = client_info.client_ip.and_then(|client_ip| { @@ -738,7 +760,9 @@ fn trace_metadata(state: &AppState, request: &Request) -> TraceMetadata { } TraceMetadata { client_info: ClientInfo { - client_ip: AxumRequestContext::get(request) + client_ip: request + .extensions + .get::() .and_then(|context| context.remote_addr) .map(|peer| peer.ip()), ..ClientInfo::default() diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index 2f15e566d2..569e2e502f 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -8,5 +8,5 @@ pub mod app; /// Request middleware (auth, response finalisation). pub mod middleware; -/// Platform-trait implementations backed by env vars and `reqwest`. +/// Platform services backed by environment values and the request's `EdgeZero` client. pub mod platform; diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index 66d592e0b7..e439753365 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,5 +1,5 @@ use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumDevServerConfig}; -use edgezero_core::app::Hooks as _; +use edgezero_core::app::{App, Hooks as _}; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; @@ -32,14 +32,21 @@ fn main() { Some(port) => AxumDevServerConfig { addr: std::net::SocketAddr::from(([127, 0, 0, 1], port)), enable_ctrl_c: true, + ..AxumDevServerConfig::default() }, // Normal development path: read bind address from axum.toml. None => AxumDevServerConfig::default(), }; log::info!("Listening on http://{}", config.addr); - let router = TrustedServerApp::routes(); - if let Err(err) = AxumDevServer::with_config(router, config).run() { + let router = match TrustedServerApp::routes() { + Ok(router) => router, + Err(error) => { + log::error!("trusted-server-adapter-axum initialization fails: {error}"); + std::process::exit(1); + } + }; + if let Err(err) = AxumDevServer::with_config(App::new(router), config).run() { log::error!("trusted-server-adapter-axum failed: {err}"); std::process::exit(1); } diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 893cdc12db..9c7c93dd21 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -5,9 +5,9 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::auth::enforce_basic_auth_parts; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; -use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; +use trusted_server_core::http_util::sanitize_trusted_client_ip_header_map; use trusted_server_core::settings::Settings; // --------------------------------------------------------------------------- @@ -38,8 +38,8 @@ impl SanitizeRequestMiddleware { #[async_trait(?Send)] impl Middleware for SanitizeRequestMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - sanitize_trusted_client_ip_headers( - ctx.request_mut(), + sanitize_trusted_client_ip_header_map( + ctx.headers_mut(), self.settings.trusted_client_ip.as_ref(), ); next.run(ctx).await @@ -106,7 +106,7 @@ impl AuthMiddleware { #[async_trait(?Send)] impl Middleware for AuthMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { + match enforce_basic_auth_parts(&self.settings, ctx.parts_mut()) { Ok(Some(response)) => return Ok(response), Ok(None) => {} Err(report) => { @@ -275,8 +275,8 @@ mod tests { let handler_observed = Arc::clone(&handler_observed); async move { *handler_observed.lock().expect("should lock observation") = Some(( - ctx.request().headers().contains_key("x-reader-ip"), - ctx.request().headers().contains_key("x-reader-ip-auth"), + ctx.headers().contains_key("x-reader-ip"), + ctx.headers().contains_key("x-reader-ip-auth"), )); Ok::(empty_response()) } diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index 229bb902f7..d7aa859076 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -1,18 +1,11 @@ -use std::future::Future; use std::net::IpAddr; -use std::pin::Pin; use std::sync::Arc; -use std::task::{Context, Poll}; -use std::time::Duration; -use async_trait::async_trait; -use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue, header}; use error_stack::{Report, ResultExt as _}; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, - RuntimeServices, StoreId, StoreName, + AuctionTargetId, BackendNamingPolicy, ClientInfo, EdgeZeroHttpClient, GeoInfo, PlatformBackend, + PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformKvStore, + PlatformSecretStore, RuntimeServices, StoreId, StoreName, }; // --------------------------------------------------------------------------- @@ -186,385 +179,6 @@ impl PlatformGeo for AxumPlatformGeo { } } -// --------------------------------------------------------------------------- -// PlatformHttpClient -// --------------------------------------------------------------------------- - -type SpawnedRequestResult = Result<(u16, Vec<(String, Vec)>, Vec), Report>; - -fn sanitized_response_headers(headers: &HeaderMap) -> Vec<(String, Vec)> { - let connection_tokens = connection_header_tokens(headers); - - headers - .iter() - .filter(|(name, _)| !is_hop_by_hop_response_header(name, &connection_tokens)) - .map(|(name, value)| (name.to_string(), value.as_bytes().to_vec())) - .collect() -} - -fn connection_header_tokens(headers: &HeaderMap) -> Vec { - headers - .get_all(header::CONNECTION) - .iter() - .filter_map(header_value_to_str) - .flat_map(|value| value.split(',')) - .map(str::trim) - .filter(|token| !token.is_empty()) - .filter_map(|token| HeaderName::from_bytes(token.as_bytes()).ok()) - .collect() -} - -fn header_value_to_str(value: &HeaderValue) -> Option<&str> { - value.to_str().ok() -} - -fn is_hop_by_hop_response_header(name: &HeaderName, connection_tokens: &[HeaderName]) -> bool { - name == header::CONNECTION - || name == header::PROXY_AUTHENTICATE - || name == header::PROXY_AUTHORIZATION - || name == header::TE - || name == header::TRAILER - || name == header::TRANSFER_ENCODING - || name == header::UPGRADE - || name.as_str().eq_ignore_ascii_case("keep-alive") - || connection_tokens.iter().any(|token| token == name) -} - -/// Buffered response parts from a spawned outbound request. -/// -/// Stored inside [`PlatformPendingRequest`] so that [`AxumPlatformHttpClient::select`] -/// can poll multiple in-flight handles concurrently via -/// [`futures::future::select_all`]. -struct AxumPendingHandle { - backend_name: String, - handle: tokio::task::JoinHandle, -} - -impl Drop for AxumPendingHandle { - fn drop(&mut self) { - // Abort instead of detaching: when the orchestrator hits the auction - // deadline and drops the remaining pending requests, the abandoned - // bidder tasks would otherwise keep running for up to the 30s - // transport timeout. - self.handle.abort(); - } -} - -/// Resolves to the backend name together with the task result so that -/// [`futures::future::select_all`] callers never have to reconstruct which -/// backend a completion belongs to by position. `select_all` removes the -/// ready future with `swap_remove` and makes no ordering guarantee for the -/// remaining futures, so positional bookkeeping would mislabel them. -impl Future for AxumPendingHandle { - type Output = (String, Result); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match Pin::new(&mut self.handle).poll(cx) { - Poll::Ready(result) => { - let backend_name = std::mem::take(&mut self.backend_name); - Poll::Ready((backend_name, result)) - } - Poll::Pending => Poll::Pending, - } - } -} - -/// reqwest-backed HTTP client for the Axum dev server. -/// -/// `send_async` buffers any `Body::Stream` in the calling context, then spawns -/// a `tokio` task for each outbound request so that multiple `send_async` calls -/// run concurrently. `select` uses [`futures::future::select_all`] to wait for -/// the first completing handle, preserving fan-out semantics. -pub struct AxumPlatformHttpClient { - client: reqwest::Client, -} - -impl AxumPlatformHttpClient { - /// Create a new client with sensible dev-server timeouts. - /// - /// # Panics - /// - /// Panics if the underlying `reqwest::Client` cannot be built (should not - /// happen with the default TLS configuration on any supported platform). - #[must_use] - pub fn new() -> Self { - Self { - client: reqwest::Client::builder() - .connect_timeout(Duration::from_secs(5)) - .timeout(Duration::from_secs(30)) - // Disable automatic redirects: core proxy code enforces redirect - // limits and allowed_domains checks itself. Without this, reqwest - // would follow Location headers internally and bypass those checks. - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("should build reqwest client"), - } - } - - /// Drain `body` to a `Vec`. - /// - /// For `Body::Stream` this awaits every chunk in the current async context - /// (where `LocalBoxStream` is valid) before the bytes are moved into a - /// `tokio::spawn` task that requires `Send`. - async fn buffer_body( - body: edgezero_core::body::Body, - ) -> Result, Report> { - match body { - edgezero_core::body::Body::Once(bytes) => Ok(bytes.to_vec()), - edgezero_core::body::Body::Stream(mut stream) => { - log::debug!("buffering Body::Stream into Vec for outbound request"); - use futures::StreamExt as _; - let mut buf = Vec::new(); - while let Some(chunk) = stream.next().await { - let bytes = chunk.map_err(|e| { - Report::new(PlatformError::HttpClient) - .attach(format!("failed to buffer outbound streaming body: {e}")) - })?; - buf.extend_from_slice(&bytes); - } - Ok(buf) - } - } - } - - async fn execute( - &self, - request: PlatformHttpRequest, - ) -> Result> { - let uri = request.request.uri().to_string(); - let method = reqwest::Method::from_bytes(request.request.method().as_str().as_bytes()) - .change_context(PlatformError::HttpClient)?; - - let mut builder = self.client.request(method, &uri); - for (name, value) in request.request.headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - - let (_, body) = request.request.into_parts(); - let body_bytes = Self::buffer_body(body).await?; - if !body_bytes.is_empty() { - builder = builder.body(body_bytes); - } - - let resp = builder - .send() - .await - .change_context(PlatformError::HttpClient) - .attach(format!("outbound request to {uri} failed"))?; - - let status = resp.status().as_u16(); - let mut edge_builder = edgezero_core::http::response_builder().status(status); - for (name, value) in sanitized_response_headers(resp.headers()) { - edge_builder = edge_builder.header(name.as_str(), value.as_slice()); - } - let resp_bytes = resp - .bytes() - .await - .change_context(PlatformError::HttpClient)?; - // Upstream responses are buffered whole with no cap — acceptable for - // the dev server, but log the size so a large or hostile upstream is - // visible instead of silently growing the heap. - log::debug!( - "buffered {} upstream response bytes from {uri}", - resp_bytes.len() - ); - let edge_resp = edge_builder - .body(edgezero_core::body::Body::from(resp_bytes.to_vec())) - .change_context(PlatformError::HttpClient)?; - - Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) - } -} - -impl Default for AxumPlatformHttpClient { - fn default() -> Self { - Self::new() - } -} - -#[async_trait(?Send)] -impl PlatformHttpClient for AxumPlatformHttpClient { - async fn send( - &self, - request: PlatformHttpRequest, - ) -> Result> { - self.execute(request).await - } - - async fn send_async( - &self, - request: PlatformHttpRequest, - ) -> Result> { - let backend_name = request.backend_name.clone(); - - // Extract all Send-compatible parts before spawning. - let uri = request.request.uri().to_string(); - let method_bytes = request.request.method().as_str().as_bytes().to_vec(); - let headers: Vec<(String, Vec)> = request - .request - .headers() - .iter() - .map(|(n, v)| (n.to_string(), v.as_bytes().to_vec())) - .collect(); - - // Buffer any LocalBoxStream body here in the ?Send context before spawn. - let (_, body) = request.request.into_parts(); - let body_bytes = Self::buffer_body(body).await?; - - let client = self.client.clone(); - let handle = tokio::spawn(async move { - let method = reqwest::Method::from_bytes(&method_bytes) - .map_err(|e| Report::new(PlatformError::HttpClient).attach(e.to_string()))?; - let mut builder = client.request(method, &uri); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_slice()); - } - if !body_bytes.is_empty() { - builder = builder.body(body_bytes); - } - let resp = builder.send().await.map_err(|e| { - Report::new(PlatformError::HttpClient) - .attach(format!("outbound request to {uri} failed: {e}")) - })?; - let status = resp.status().as_u16(); - let resp_headers = sanitized_response_headers(resp.headers()); - let body = resp - .bytes() - .await - .map_err(|e| Report::new(PlatformError::HttpClient).attach(e.to_string()))? - .to_vec(); - // Same unbounded-buffering note as the synchronous path: log the - // size so large upstream responses are visible in dev. - log::debug!("buffered {} upstream response bytes from {uri}", body.len()); - Ok::<_, Report>((status, resp_headers, body)) - }); - - let pending = AxumPendingHandle { - backend_name: backend_name.clone(), - handle, - }; - Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name)) - } - - async fn select_until( - &self, - pending_requests: Vec, - deadline: web_time::Instant, - ) -> Result, Report> { - let mut handles = recover_pending_handles(pending_requests)?; - if handles.is_empty() { - return Err(Report::new(PlatformError::HttpClient) - .attach("select_until called with no pending requests")); - } - let remaining = deadline.saturating_duration_since(web_time::Instant::now()); - if !remaining.is_zero() { - // Borrow the handles so timer expiry cannot drop completed siblings - // before the final nonblocking snapshot inspects them. - let selected = - tokio::time::timeout(remaining, futures::future::select_all(handles.iter_mut())) - .await; - if let Ok(((backend_name, result), index, _)) = selected { - drop(handles.swap_remove(index)); - return Ok(Some(completed_selection(backend_name, result, handles))); - } - } - // Snapshot once: only completed tasks survive into subsequent calls. - // An unfinished task is aborted on drop and cannot become a late bid. - handles.retain(|pending| pending.handle.is_finished()); - if handles.is_empty() { - return Ok(None); - } - let ((backend_name, result), _, remaining_handles) = - futures::future::select_all(handles).await; - Ok(Some(completed_selection( - backend_name, - result, - remaining_handles, - ))) - } - - async fn select( - &self, - pending_requests: Vec, - ) -> Result> { - if pending_requests.is_empty() { - return Err(Report::new(PlatformError::HttpClient) - .attach("select called with an empty pending_requests list")); - } - - let handles = recover_pending_handles(pending_requests)?; - - // Each AxumPendingHandle resolves to (backend_name, result), so the - // remaining handles keep their own backend names — no positional - // reconstruction (select_all does not preserve the order of the - // remaining futures). - let ((backend_name, result), _ready_idx, remaining_handles) = - futures::future::select_all(handles).await; - - Ok(completed_selection(backend_name, result, remaining_handles)) - } -} - -fn completed_selection( - backend_name: String, - result: Result, - remaining_handles: Vec, -) -> PlatformSelectResult { - let remaining: Vec = remaining_handles - .into_iter() - .map(|handle| { - let backend_name = handle.backend_name.clone(); - PlatformPendingRequest::new(handle).with_backend_name(backend_name) - }) - .collect(); - - // Map join panics and per-request errors into ready: Err(...) so that the - // auction orchestrator can log the failure and continue with remaining providers - // rather than treating one bad provider as a fatal select() failure. - let ready = result - .map_err(|e| { - Report::new(PlatformError::HttpClient) - .attach(format!("auction request task panicked: {e}")) - }) - .and_then(|inner| inner) - .and_then(|(status, headers, body)| { - let mut builder = edgezero_core::http::response_builder().status(status); - for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_slice()); - } - builder - .body(edgezero_core::body::Body::from(body)) - .change_context(PlatformError::HttpClient) - }) - .map(|edge_resp| PlatformResponse::new(edge_resp).with_backend_name(backend_name.clone())); - - // Attribute the failure to its backend so the orchestrator can remove - // the provider and record a BidStatus::Error, matching the Fastly - // adapter. Without this, a failed provider silently vanishes through - // the orchestrator's "backend not identified" branch. - let failed_backend_name = ready.as_ref().err().map(|_| backend_name); - - PlatformSelectResult { - ready, - remaining, - failed_backend_name, - } -} - -fn recover_pending_handles( - pending_requests: Vec, -) -> Result, Report> { - pending_requests - .into_iter() - .map(|pending| { - pending.downcast::().map_err(|_| { - Report::new(PlatformError::HttpClient) - .attach("unexpected pending type in Axum selection") - }) - }) - .collect() -} - // --------------------------------------------------------------------------- // build_runtime_services // --------------------------------------------------------------------------- @@ -582,14 +196,12 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R log::warn!("Axum dev server: generic runtime KV is unavailable (UnavailableKvStore)."); }); - let client_ip = edgezero_adapter_axum::context::AxumRequestContext::get(ctx.request()) + let client_ip = ctx + .extensions() + .get::() .and_then(|c| c.remote_addr) .map(|addr| addr.ip()); - use trusted_server_core::platform::{ - PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformKvStore, PlatformSecretStore, - }; - // Stateless shims are promoted to process-wide statics so callers clone // an existing Arc instead of allocating a new one per request. static CONFIG_STORE: std::sync::OnceLock> = @@ -617,11 +229,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .backend(Arc::clone(BACKEND.get_or_init(|| { Arc::new(AxumPlatformBackend) as Arc }))) - // Keep the HTTP client request-scoped in the dev adapter. Sharing a pooled - // client across requests previously regressed the Next.js server-action → - // API-route integration flow by reusing a poisoned connection after a - // truncated POST. Revisit pooling if profiling shows allocation cost. - .http_client(Arc::new(AxumPlatformHttpClient::new())) + .http_client(EdgeZeroHttpClient::for_request(ctx, AuctionTargetId::Axum)) .geo(Arc::clone(GEO.get_or_init(|| { Arc::new(AxumPlatformGeo) as Arc }))) @@ -651,9 +259,7 @@ impl trusted_server_core::platform::PlatformTimer for AxumTimer { #[cfg(test)] mod tests { use super::*; - use edgezero_core::body::Body as EdgeBody; use std::time::Duration; - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; #[test] fn html_finalization_timer_is_shared_without_an_executor() { @@ -678,11 +284,9 @@ mod tests { #[test] fn auction_http_capabilities_are_explicit() { - let client = AxumPlatformHttpClient::new(); let capabilities = trusted_server_core::platform::AuctionTargetId::Axum .descriptor() .capabilities(); - assert!(client.supports_concurrent_fanout()); assert!(capabilities.supports_concurrent_provider_fanout()); } @@ -786,426 +390,4 @@ mod tests { .expect("should not error"); assert!(with_ip.is_none(), "should return None for any IP"); } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn http_client_surfaces_redirect_without_following() { - let url = serve_raw_response( - b"HTTP/1.1 302 Found\r\nLocation: https://redirect.example/next\r\nContent-Length: 0\r\n\r\n", - ) - .await; - let request = edgezero_core::http::request_builder() - .uri(url) - .body(EdgeBody::empty()) - .expect("should build outbound request"); - - let response = AxumPlatformHttpClient::new() - .send(PlatformHttpRequest::new(request, "test_backend")) - .await - .expect("should surface redirect") - .response; - - assert_eq!(response.status().as_u16(), 302); - assert_eq!( - response - .headers() - .get(edgezero_core::http::header::LOCATION) - .and_then(|value| value.to_str().ok()), - Some("https://redirect.example/next") - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn http_client_strips_hop_by_hop_response_headers() { - let url = serve_raw_response( - b"HTTP/1.1 200 OK\r\n\ - Transfer-Encoding: chunked\r\n\ - Connection: keep-alive, x-remove-me\r\n\ - Keep-Alive: timeout=5\r\n\ - X-Remove-Me: listed-by-connection\r\n\ - X-Preserve-Me: application-header\r\n\ - \r\n\ - 2\r\n\ - ok\r\n\ - 0\r\n\ - \r\n", - ) - .await; - - let request = edgezero_core::http::request_builder() - .uri(url) - .body(EdgeBody::empty()) - .expect("should build outbound request"); - - let response = AxumPlatformHttpClient::new() - .send(PlatformHttpRequest::new(request, "test_backend")) - .await - .expect("should proxy raw response") - .response; - - assert!( - response.headers().get(header::TRANSFER_ENCODING).is_none(), - "should strip transfer-encoding" - ); - assert!( - response.headers().get(header::CONNECTION).is_none(), - "should strip connection" - ); - assert!( - response.headers().get("keep-alive").is_none(), - "should strip keep-alive" - ); - assert!( - response.headers().get("x-remove-me").is_none(), - "should strip headers named by connection" - ); - assert_eq!( - response - .headers() - .get("x-preserve-me") - .and_then(|value| value.to_str().ok()), - Some("application-header"), - "should preserve end-to-end headers" - ); - assert_eq!( - response - .into_body() - .into_bytes() - .unwrap_or_default() - .as_ref(), - b"ok", - "should preserve decoded response body" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn select_attributes_failed_backend_name() { - // Bind and immediately drop a listener so the port is closed — the - // request fails with connection refused. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("should bind probe listener"); - let addr = listener.local_addr().expect("should read local address"); - drop(listener); - - let request = edgezero_core::http::request_builder() - .uri(format!("http://{addr}/")) - .body(EdgeBody::empty()) - .expect("should build outbound request"); - - let client = AxumPlatformHttpClient::new(); - let pending = client - .send_async(PlatformHttpRequest::new(request, "failing_backend")) - .await - .expect("should spawn async request"); - - let result = client - .select(vec![pending]) - .await - .expect("select should surface the failure via ready, not a fatal error"); - - assert!( - result.ready.is_err(), - "request to a closed port should fail" - ); - assert_eq!( - result.failed_backend_name.as_deref(), - Some("failing_backend"), - "failed provider must be attributed to its backend so the orchestrator can record BidStatus::Error" - ); - assert!( - result.remaining.is_empty(), - "no remaining requests expected" - ); - } - - async fn deadline_pending( - client: &AxumPlatformHttpClient, - url: String, - backend: &str, - ) -> PlatformPendingRequest { - let request = edgezero_core::http::request_builder() - .uri(url) - .body(EdgeBody::empty()) - .expect("should build deadline request"); - client - .send_async(PlatformHttpRequest::new(request, backend)) - .await - .expect("should dispatch deadline request") - } - - async fn serve_slow_response(trickle_body: bool) -> (String, tokio::task::JoinHandle<()>) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("should bind deadline server"); - let address = listener - .local_addr() - .expect("should read deadline server address"); - let task = tokio::spawn(async move { - let (mut stream, _) = listener - .accept() - .await - .expect("should accept deadline request"); - let mut request = [0; 1024]; - let bytes_read = stream - .read(&mut request) - .await - .expect("should read deadline request"); - assert!(bytes_read > 0, "should receive a deadline request"); - if trickle_body { - if stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n") - .await - .is_err() - { - return; - } - for _ in 0..100 { - if stream.write_all(b"x").await.is_err() { - return; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - } else { - tokio::time::sleep(Duration::from_secs(5)).await; - let _ = stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") - .await; - } - }); - (format!("http://{address}/"), task) - } - - #[tokio::test] - async fn select_until_bounds_delayed_headers_and_trickling_bodies() { - let client = AxumPlatformHttpClient::new(); - for trickle_body in [false, true] { - let (url, server) = serve_slow_response(trickle_body).await; - let pending = deadline_pending(&client, url, "slow").await; - let start = web_time::Instant::now(); - let result = tokio::time::timeout( - Duration::from_secs(2), - client.select_until(vec![pending], start + Duration::from_millis(200)), - ) - .await - .expect("should finish before the slow response") - .expect("should enforce the deadline"); - server.abort(); - assert!( - result.is_none(), - "should time out before the entire response completes" - ); - assert!( - start.elapsed() < Duration::from_secs(2), - "should bound the complete response wait" - ); - } - } - - #[tokio::test] - async fn select_until_preserves_fast_success_before_slow_body_expires() { - let client = AxumPlatformHttpClient::new(); - let (slow_url, server) = serve_slow_response(true).await; - let fast_url = serve_raw_response(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok").await; - let slow = deadline_pending(&client, slow_url, "slow").await; - let fast = deadline_pending(&client, fast_url, "fast").await; - let deadline = web_time::Instant::now() + Duration::from_millis(500); - let result = client - .select_until(vec![slow, fast], deadline) - .await - .expect("should collect fast response") - .expect("should complete before the deadline"); - assert_eq!( - result - .ready - .expect("should succeed") - .backend_name - .as_deref(), - Some("fast"), - "should collect the fast complete body first" - ); - assert_eq!( - result.remaining.len(), - 1, - "should preserve the slow body handle" - ); - let result = client - .select_until(result.remaining, deadline) - .await - .expect("should bound slow response"); - server.abort(); - assert!(result.is_none(), "should expire the remaining slow body"); - } - - #[tokio::test] - async fn select_until_expired_deadline_preserves_buffered_response() { - let client = AxumPlatformHttpClient::new(); - let handle = tokio::spawn(async { Ok((200, Vec::new(), b"ok".to_vec())) }); - while !handle.is_finished() { - tokio::task::yield_now().await; - } - let pending = PlatformPendingRequest::new(AxumPendingHandle { - backend_name: "ready".to_owned(), - handle, - }); - let result = client - .select_until(vec![pending], web_time::Instant::now()) - .await - .expect("should recognize expired deadline"); - assert!( - result.is_some(), - "should preserve a complete buffered response without waiting after cutoff" - ); - } - - #[tokio::test] - async fn expired_snapshot_preserves_finished_siblings_and_aborts_unfinished_task() { - let mut tasks = Vec::new(); - for name in ["ready-a", "ready-b"] { - tasks.push(AxumPendingHandle { - backend_name: name.to_owned(), - handle: tokio::spawn(async { Ok((200, Vec::new(), b"ok".to_vec())) }), - }); - } - while tasks.iter().any(|pending| !pending.handle.is_finished()) { - tokio::task::yield_now().await; - } - let (started_sender, started_receiver) = tokio::sync::oneshot::channel(); - let (drop_sender, drop_receiver) = tokio::sync::oneshot::channel::<()>(); - tasks.push(AxumPendingHandle { - backend_name: "unfinished".to_owned(), - handle: tokio::spawn(async move { - let _drop_signal = drop_sender; - started_sender - .send(()) - .expect("should signal unfinished task start"); - futures::future::pending::().await - }), - }); - started_receiver - .await - .expect("should start unfinished task"); - let pending = tasks - .into_iter() - .map(|task| { - let name = task.backend_name.clone(); - PlatformPendingRequest::new(task).with_backend_name(name) - }) - .collect(); - let client = AxumPlatformHttpClient::new(); - let deadline = web_time::Instant::now(); - let first = client - .select_until(pending, deadline) - .await - .expect("should snapshot ready responses") - .expect("should retain first response"); - assert!( - first.ready.is_ok(), - "should preserve first completed result" - ); - assert_eq!( - first.remaining.len(), - 1, - "should retain only the other completed response" - ); - let second = client - .select_until(first.remaining, deadline) - .await - .expect("should drain the completed snapshot") - .expect("should retain second response"); - assert!( - second.ready.is_ok(), - "should preserve the second completed result" - ); - assert!( - second.remaining.is_empty(), - "should leave no unfinished handles" - ); - assert!( - tokio::time::timeout(Duration::from_secs(1), drop_receiver) - .await - .expect("should promptly abort unfinished work") - .is_err(), - "should release unfinished task resources" - ); - } - - #[tokio::test] - async fn select_until_aborts_task_on_expiration() { - let (started_sender, started_receiver) = tokio::sync::oneshot::channel(); - let (dropped_sender, dropped_receiver) = tokio::sync::oneshot::channel::<()>(); - let handle = tokio::spawn(async move { - let _drop_signal = dropped_sender; - started_sender.send(()).expect("should signal task start"); - futures::future::pending::().await - }); - started_receiver - .await - .expect("should start task before selection"); - let pending = PlatformPendingRequest::new(AxumPendingHandle { - backend_name: "slow".to_owned(), - handle, - }); - let result = AxumPlatformHttpClient::new() - .select_until( - vec![pending], - web_time::Instant::now() + Duration::from_millis(50), - ) - .await - .expect("should expire task"); - assert!(result.is_none(), "should report deadline expiration"); - assert!( - tokio::time::timeout(Duration::from_secs(2), dropped_receiver) - .await - .expect("should promptly drop aborted task state") - .is_err(), - "should drop task resources on expiration" - ); - } - - #[tokio::test] - async fn select_until_preserves_failed_backend_attribution() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("should bind probe"); - let address = listener.local_addr().expect("should read probe address"); - drop(listener); - let client = AxumPlatformHttpClient::new(); - let pending = deadline_pending(&client, format!("http://{address}/"), "failed").await; - let result = client - .select_until( - vec![pending], - web_time::Instant::now() + Duration::from_secs(2), - ) - .await - .expect("should collect transport failure") - .expect("should fail before deadline"); - assert!(result.ready.is_err(), "should preserve transport failure"); - assert_eq!( - result.failed_backend_name.as_deref(), - Some("failed"), - "should attribute failure to its backend" - ); - } - - async fn serve_raw_response(response: &'static [u8]) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("should bind raw HTTP test server"); - let addr = listener.local_addr().expect("should read local address"); - - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("should accept request"); - let mut request = [0; 1024]; - let _ = stream - .read(&mut request) - .await - .expect("should read request"); - stream - .write_all(response) - .await - .expect("should write response"); - }); - - format!("http://{addr}/") - } } diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 5bdc36e496..c74655f80a 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -1,12 +1,10 @@ //! Integration tests for the Axum dev server. //! -//! Uses `EdgeZeroAxumService` directly (no live TCP server) so tests remain fast -//! and self-contained. Each test builds the full `TrustedServerApp` router and -//! drives it through the Tower `Service` interface. +//! These route tests drive the full `TrustedServerApp` router through Tower. +//! They do not qualify native HTTP ingress, transport, or egress behavior. -use axum::body::Body as AxumBody; use axum::http::Request; -use edgezero_adapter_axum::service::EdgeZeroAxumService; +use edgezero_core::body::Body as AxumBody; use std::sync::Arc; use tower::{Service as _, ServiceExt as _}; use trusted_server_adapter_axum::app::TrustedServerApp; @@ -15,6 +13,16 @@ use trusted_server_core::test_support::nextjs_auction; const LEGACY_ADMIN_DENY_METHODS: &[&str] = &["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"]; +#[test] +fn named_sign_route_buffers_guarded_streamed_json_under_its_existing_cap() { + let settings = trusted_server_core::test_support::named_route_inputs::sign_settings(); + let router = TrustedServerApp::routes_with_settings(settings) + .expect("should build streamed-input routes"); + futures::executor::block_on( + trusted_server_core::test_support::named_route_inputs::assert_sign_inputs(&router), + ); +} + /// Build the full application router from explicit test settings. /// /// The settings baked into the binary contain placeholder secrets that @@ -46,8 +54,8 @@ fn test_router() -> edgezero_core::router::RouterService { .expect("should build router from test settings") } -fn make_service() -> EdgeZeroAxumService { - EdgeZeroAxumService::new(test_router()) +fn make_service() -> edgezero_core::router::RouterService { + test_router() } fn registered_routes() -> Vec<(String, String)> { @@ -86,7 +94,7 @@ async fn aps_profile_serves_renderer_through_adapter_fallback() { ); let router = TrustedServerApp::routes_with_settings(settings) .expect("should build router with APS profile"); - let mut service = EdgeZeroAxumService::new(router); + let mut service = router; let request = Request::builder() .method("GET") .uri("/integrations/aps/renderer") @@ -953,7 +961,7 @@ async fn nextjs_gtm_rejects_malformed_request_hosts() { nextjs_auction::services(origin), ) .expect("should build script composition router"); - let mut service = EdgeZeroAxumService::new(router); + let mut service = router; let hostile_host = "example.com"; let value = if header == "forwarded" { format!("host=\"{hostile_host}\"") @@ -982,8 +990,9 @@ async fn nextjs_gtm_rejects_malformed_request_hosts() { expected_status, "should reject malformed Host and ignore unauthenticated {header}" ); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await + let body = response + .into_body() + .into_bytes() .expect("should consume hostile host response"); assert!( !String::from_utf8_lossy(&body).contains(hostile_host), @@ -1011,7 +1020,7 @@ async fn nextjs_gtm_accepts_local_request_hosts_and_ports() { nextjs_auction::services(origin), ) .expect("should build script composition router"); - let mut service = EdgeZeroAxumService::new(router); + let mut service = router; let request = Request::builder() .uri("/script-composition") .header("host", host) @@ -1029,8 +1038,9 @@ async fn nextjs_gtm_accepts_local_request_hosts_and_ports() { 200, "should accept host: {host}" ); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await + let body = response + .into_body() + .into_bytes() .expect("should consume local host response"); let html = String::from_utf8(body.to_vec()).expect("should emit UTF-8 HTML"); assert!( @@ -1061,7 +1071,7 @@ async fn nextjs_gtm_html_response_is_complete() { nextjs_auction::services(origin), ) .expect("should build mixed integration router"); - let mut service = EdgeZeroAxumService::new(router); + let mut service = router; let request = Request::builder() .method("GET") .uri("/article") @@ -1089,8 +1099,9 @@ async fn nextjs_gtm_html_response_is_complete() { gzip.then_some("gzip"), "should retain the requested response compression" ); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await + let body = response + .into_body() + .into_bytes() .expect("should consume entire adapter body"); nextjs_auction::assert_script_composition_response(&body, gzip); } diff --git a/crates/trusted-server-adapter-cloudflare/Cargo.toml b/crates/trusted-server-adapter-cloudflare/Cargo.toml index 36a9041641..c5fb0e66cb 100644 --- a/crates/trusted-server-adapter-cloudflare/Cargo.toml +++ b/crates/trusted-server-adapter-cloudflare/Cargo.toml @@ -38,7 +38,6 @@ worker = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] edgezero-adapter-cloudflare = { workspace = true, features = ["cloudflare"] } -js-sys = { workspace = true } worker = { workspace = true } [dev-dependencies] diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index c569050e16..99a5076b37 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -266,6 +266,7 @@ type BoxedHandlerFuture = Pin( state: Arc, + body_limit: Option, f: F, ) -> impl Fn(RequestContext) -> BoxedHandlerFuture + Clone + 'static where @@ -277,7 +278,12 @@ where let f = f.clone(); Box::pin(async move { let services = s.services_for_request(&ctx); - let mut req = ctx.into_request(); + if ctx.method() == Method::POST + && let Some(limit) = body_limit + { + ctx.body_bytes(limit).await?; + } + let mut req = ctx.into_request()?; if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, &mut req, @@ -447,16 +453,16 @@ impl Hooks for TrustedServerApp { "TrustedServer" } - fn routes() -> RouterService { + fn routes() -> Result { let state = match build_state() { Ok(s) => s, Err(ref e) => { log::error!("failed to build application state: {:?}", e); - return startup_error_router(e); + return Ok(startup_error_router(e)); } }; - build_router(&state) + Ok(build_router(&state)) } } @@ -513,7 +519,7 @@ fn build_router(state: &Arc) -> RouterService { ctx: RequestContext, ) -> Result { let services = state.services_for_request(&ctx); - let mut req = ctx.into_request(); + let mut req = ctx.into_request()?; if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); } @@ -613,15 +619,19 @@ fn build_router(state: &Arc) -> RouterService { .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) .get( "/.well-known/trusted-server.json", - make_handler(Arc::clone(&state), |s, services, req| async move { + make_handler(Arc::clone(&state), None, |s, services, req| async move { handle_trusted_server_discovery(&s.settings, &services, req) }), ) .post( "/verify-signature", - make_handler(Arc::clone(&state), |s, services, req| async move { - handle_verify_signature(&s.settings, &services, req) - }), + make_handler( + Arc::clone(&state), + Some(trusted_server_core::request_signing::endpoints::VERIFY_MAX_BODY_BYTES), + |s, services, req| async move { + handle_verify_signature(&s.settings, &services, req) + }, + ), ) // Canonical admin key routes. These match `Settings::ADMIN_ENDPOINTS` // and the production basic-auth handler regex (`^/_ts/admin`), so they @@ -654,53 +664,61 @@ fn build_router(state: &Arc) -> RouterService { // adapter serves the real handler. .get( "/_ts/admin/eids", - make_handler(Arc::clone(&state), |s, _services, req| async move { + make_handler(Arc::clone(&state), None, |s, _services, req| async move { let partner_registry = PartnerRegistry::from_config(&s.settings.ec.partners)?; handle_admin_eids_lookup(&partner_registry, &req) }), ) .post( "/auction", - make_handler(Arc::clone(&state), |s, services, req| async move { - // Build the geo-aware EC context so the auction consent gate - // sees the caller's jurisdiction — `EcContext::default()` - // fails it closed for consented users. - let mut ec_context = build_ec_context(&s.settings, &services, &req); - handle_auction( - &s.settings, - &s.orchestrator, - None, - None, - &mut ec_context, - &services, - req, - ) - .await - }), + make_handler( + Arc::clone(&state), + Some(trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE), + |s, services, req| async move { + // Build the geo-aware EC context so the auction consent gate + // sees the caller's jurisdiction — `EcContext::default()` + // fails it closed for consented users. + let mut ec_context = build_ec_context(&s.settings, &services, &req); + handle_auction( + &s.settings, + &s.orchestrator, + None, + None, + &mut ec_context, + &services, + req, + ) + .await + }, + ), ) .get( "/first-party/proxy", - make_handler(Arc::clone(&state), |s, services, req| async move { + make_handler(Arc::clone(&state), None, |s, services, req| async move { handle_first_party_proxy(&s.settings, &services, req).await }), ) .get( "/first-party/click", - make_handler(Arc::clone(&state), |s, services, req| async move { + make_handler(Arc::clone(&state), None, |s, services, req| async move { handle_first_party_click(&s.settings, &services, req).await }), ) .get( "/first-party/sign", - make_handler(Arc::clone(&state), |s, services, req| async move { + make_handler(Arc::clone(&state), None, |s, services, req| async move { handle_first_party_proxy_sign(&s.settings, &services, req).await }), ) .post( "/first-party/sign", - make_handler(Arc::clone(&state), |s, services, req| async move { - handle_first_party_proxy_sign(&s.settings, &services, req).await - }), + make_handler( + Arc::clone(&state), + Some(trusted_server_core::proxy::SIGN_MAX_BODY_BYTES), + |s, services, req| async move { + handle_first_party_proxy_sign(&s.settings, &services, req).await + }, + ), ) // GET serves the click guard's navigation fallback: the creative // iframe is an opaque origin (sandbox without `allow-same-origin`), @@ -708,15 +726,19 @@ fn build_router(state: &Arc) -> RouterService { // for a 302 instead. .get( "/first-party/proxy-rebuild", - make_handler(Arc::clone(&state), |s, services, req| async move { + make_handler(Arc::clone(&state), None, |s, services, req| async move { handle_first_party_proxy_rebuild(&s.settings, &services, req).await }), ) .post( "/first-party/proxy-rebuild", - make_handler(Arc::clone(&state), |s, services, req| async move { - handle_first_party_proxy_rebuild(&s.settings, &services, req).await - }), + make_handler( + Arc::clone(&state), + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES), + |s, services, req| async move { + handle_first_party_proxy_rebuild(&s.settings, &services, req).await + }, + ), ); // SPA re-auction endpoint, registered on the canonical path and on the @@ -728,29 +750,35 @@ fn build_router(state: &Arc) -> RouterService { // `X-TSJS-Page-Bids` gate stays trustworthy — an alias that let the // preflight fall through to a permissive origin would reopen exactly // the cross-site hole the canonical path closes. - let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { - let mut ec_context = build_ec_context(&s.settings, &services, &req); - let auction = AuctionDispatch { - integration_registry: &s.registry, - orchestrator: &s.orchestrator, - slots: s.settings.creative_opportunity_slots(), - registry: None, - }; - handle_page_bids(&s.settings, &services, None, auction, &mut ec_context, req).await - }); - let page_bids_preflight = - make_handler(Arc::clone(&state), |_s, _services, _req| async move { - Ok(page_bids_preflight_denied()) - }); + let page_bids = make_handler( + Arc::clone(&state), + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES), + |s, services, req| async move { + let mut ec_context = build_ec_context(&s.settings, &services, &req); + let auction = AuctionDispatch { + integration_registry: &s.registry, + orchestrator: &s.orchestrator, + slots: s.settings.creative_opportunity_slots(), + registry: None, + }; + handle_page_bids(&s.settings, &services, None, auction, &mut ec_context, req).await + }, + ); + let page_bids_preflight = make_handler( + Arc::clone(&state), + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES), + |_s, _services, _req| async move { Ok(page_bids_preflight_denied()) }, + ); for path in [PAGE_BIDS_PATH, PAGE_BIDS_LEGACY_PATH] { router = router.route(path, Method::GET, page_bids.clone()); router = router.route(path, Method::OPTIONS, page_bids_preflight.clone()); } - let cache_purge_unsupported = - make_handler(Arc::clone(&state), |_s, _services, _req| async move { - Ok(cache_purge_not_supported()) - }); + let cache_purge_unsupported = make_handler( + Arc::clone(&state), + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES), + |_s, _services, _req| async move { Ok(cache_purge_not_supported()) }, + ); for method in publisher_fallback_methods() { router = router.route( "/_ts/admin/cache/purge", @@ -759,10 +787,11 @@ fn build_router(state: &Arc) -> RouterService { ); } - let legacy_admin_deny = - make_handler(Arc::clone(&state), |_s, _services, _req| async move { - Ok(legacy_admin_alias_denied()) - }); + let legacy_admin_deny = make_handler( + Arc::clone(&state), + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES), + |_s, _services, _req| async move { Ok(legacy_admin_alias_denied()) }, + ); for method in publisher_fallback_methods() { router = router.route( "/admin/keys/rotate", @@ -781,7 +810,7 @@ fn build_router(state: &Arc) -> RouterService { } } -fn trace_metadata(state: &AppState, request: &Request) -> TraceMetadata { +fn trace_metadata(state: &AppState, request: &edgezero_core::http::RequestParts) -> TraceMetadata { if let Some(services) = &state.services { let client_info = services.client_info().clone(); let geo = client_info.client_ip.and_then(|client_ip| { @@ -793,12 +822,12 @@ fn trace_metadata(state: &AppState, request: &Request) -> TraceMetadata { return TraceMetadata { client_info, geo }; } let client_ip = request - .headers() + .headers .get("cf-connecting-ip") .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse().ok()); let geo = request - .headers() + .headers .get("cf-ipcountry") .and_then(|value| value.to_str().ok()) .filter(|country| *country != "XX") diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index af19241163..cb5b1c3fe8 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -5,9 +5,9 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::auth::enforce_basic_auth_parts; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; -use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; +use trusted_server_core::http_util::sanitize_trusted_client_ip_header_map; use trusted_server_core::settings::Settings; // --------------------------------------------------------------------------- @@ -38,8 +38,8 @@ impl SanitizeRequestMiddleware { #[async_trait(?Send)] impl Middleware for SanitizeRequestMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - sanitize_trusted_client_ip_headers( - ctx.request_mut(), + sanitize_trusted_client_ip_header_map( + ctx.headers_mut(), self.settings.trusted_client_ip.as_ref(), ); next.run(ctx).await @@ -75,7 +75,6 @@ impl FinalizeResponseMiddleware { impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let geo_available = ctx - .request() .headers() .get("cf-ipcountry") .and_then(|v| v.to_str().ok()) @@ -113,7 +112,7 @@ impl AuthMiddleware { #[async_trait(?Send)] impl Middleware for AuthMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { + match enforce_basic_auth_parts(&self.settings, ctx.parts_mut()) { Ok(Some(response)) => return Ok(response), Ok(None) => {} Err(report) => { @@ -306,8 +305,8 @@ mod tests { let handler_observed = Arc::clone(&handler_observed); async move { *handler_observed.lock().expect("should lock observation") = Some(( - ctx.request().headers().contains_key("x-reader-ip"), - ctx.request().headers().contains_key("x-reader-ip-auth"), + ctx.headers().contains_key("x-reader-ip"), + ctx.headers().contains_key("x-reader-ip-auth"), )); Ok::(empty_response()) } diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 40cfc7b3a1..a5f6c68220 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -9,18 +9,9 @@ use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; use error_stack::{Report, ResultExt as _}; use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, - PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, -}; - -#[cfg(not(target_arch = "wasm32"))] -use trusted_server_core::platform::UnavailableHttpClient; - -#[cfg(target_arch = "wasm32")] -use trusted_server_core::platform::{ - PlatformHttpRequest, PlatformPendingRequest, PlatformPendingResponse, PlatformResponse, - PlatformSelectResult, + AuctionTargetId, BackendNamingPolicy, ClientInfo, EdgeZeroHttpClient, GeoInfo, KvError, + PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, + PlatformKvStore, PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, }; // --------------------------------------------------------------------------- @@ -104,6 +95,7 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { .ok_or_else(|| { Report::new(PlatformError::ConfigStore).attach(format!("key not found: {key}")) }) + .map(|value| value.as_str().to_owned()) } fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { @@ -161,406 +153,6 @@ impl KvStore for KvHandleAdapter { } } -// --------------------------------------------------------------------------- -// CloudflareHttpClient — WASM target only -// --------------------------------------------------------------------------- - -/// [`worker::Fetch`]-backed HTTP client for the Cloudflare Workers runtime. -/// -/// # Multi-provider auction limitation -/// -/// Auction configuration supports one provider at a time, so -/// [`PlatformHttpClient::supports_concurrent_fanout`] reports `false`. -/// Requests start in a runtime task at dispatch, overlapping publisher-origin work. -/// Deadline selection bounds waiting for fetch and body collection and aborts -/// the Workers fetch when its pending operation is dropped. -#[cfg(target_arch = "wasm32")] -pub struct CloudflareHttpClient; - -/// Maximum buffered upstream response body, mirroring the Fastly adapter's cap. -/// -/// Cloudflare Workers isolates have a bounded memory budget (~128 MB), so a -/// large or hostile upstream could OOM the isolate. `execute` buffers the whole -/// body via `resp.bytes()`, so it caps both the declared Content-Length and the -/// materialized byte count at this limit. -#[cfg(target_arch = "wasm32")] -const MAX_PLATFORM_RESPONSE_BODY_BYTES: usize = 10 * 1024 * 1024; // 10 MiB - -/// Collect the lowercased tokens listed in the upstream `Connection:` header(s). -/// -/// Every header named here is hop-by-hop (RFC 7230 §6.1) and must not be -/// forwarded downstream. -#[cfg(target_arch = "wasm32")] -fn response_connection_tokens(resp: &worker::Response) -> Vec { - resp.headers() - .entries() - .filter(|(name, _)| name.eq_ignore_ascii_case("connection")) - .flat_map(|(_, value)| { - value - .split(',') - .map(|token| token.trim().to_ascii_lowercase()) - .filter(|token| !token.is_empty()) - .collect::>() - }) - .collect() -} - -/// Returns `true` for hop-by-hop response headers that must not be forwarded -/// downstream (RFC 7230 §6.1), including any header named in the upstream -/// `Connection:` token list. Mirrors the Axum adapter's -/// `is_hop_by_hop_response_header`. `transfer-encoding` is stripped separately -/// at the call site alongside the other auto-decoded framing headers. -#[cfg(target_arch = "wasm32")] -fn is_hop_by_hop_response_header(name: &str, connection_tokens: &[String]) -> bool { - const HOP_BY_HOP: [&str; 6] = [ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "upgrade", - ]; - let lower = name.to_ascii_lowercase(); - HOP_BY_HOP.iter().any(|header| *header == lower) || connection_tokens.contains(&lower) -} - -/// Cache policy for the outbound Workers `fetch` derived from -/// [`PlatformHttpRequest::cache_intent`]. -/// -/// Workers subrequests are eligible for Cloudflare's cache by default, so an -/// ad-stack navigation could otherwise be satisfied from cache (or revalidated -/// into a bodyless 304) instead of receiving a complete origin body. Mapping -/// the bypass flag to the runtime's `no-store` mode keeps the core contract's -/// cache-bypass guarantee intact on this adapter. -/// -/// Extracted as a free function over a target-independent enum so the mapping -/// is testable on native targets, where the `#[cfg(target_arch = "wasm32")]` -/// `execute` impl and its `worker` dependency are excluded from the test -/// binary. -#[cfg(any(target_arch = "wasm32", test))] -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum OutboundCacheMode { - /// Leave the Workers runtime's default cache behavior in place. - RuntimeDefault, - /// Maps to `worker::CacheMode::NoStore`. - NoStore, -} - -#[cfg(any(target_arch = "wasm32", test))] -fn outbound_cache_mode( - intent: &trusted_server_core::platform::PlatformCacheIntent, -) -> OutboundCacheMode { - // Workers has no surrogate-key concept, so `Shared` falls in with `Default`: let the - // runtime apply its own behavior rather than pretending to honor a key it cannot use. - if intent.is_bypass() { - OutboundCacheMode::NoStore - } else { - OutboundCacheMode::RuntimeDefault - } -} - -#[cfg(target_arch = "wasm32")] -fn outbound_request_init(method: worker::Method, headers: worker::Headers) -> worker::RequestInit { - let mut init = worker::RequestInit::new(); - init.with_method(method) - .with_headers(headers) - .with_redirect(worker::RequestRedirect::Manual); - init -} - -#[cfg(target_arch = "wasm32")] -struct AbortOnDrop(Option); - -#[cfg(target_arch = "wasm32")] -impl Drop for AbortOnDrop { - fn drop(&mut self) { - if let Some(controller) = self.0.take() { - controller.abort(); - } - } -} - -#[cfg(target_arch = "wasm32")] -impl CloudflareHttpClient { - async fn execute( - request: PlatformHttpRequest, - ) -> Result> { - use worker::{CacheMode, Fetch, Headers, Method, Request}; - - // The Cloudflare fetch path cannot honor Fastly-style Image Optimizer - // metadata, and it always buffers the response body (see below). The - // `PlatformHttpRequest` contract requires adapters that cannot honor - // these to reject rather than silently drop the behavior, so surface a - // typed error instead of returning an untransformed or fully-buffered - // response. These fields are only set by asset routes, which are not - // routed to the Cloudflare adapter today; the guard keeps the contract - // honest if that changes. - if request.image_optimizer.is_some() { - return Err(Report::new(PlatformError::HttpClient) - .attach("Image Optimizer is not supported on the Cloudflare Workers runtime")); - } - if request.stream_response { - return Err(Report::new(PlatformError::HttpClient).attach( - "streaming response bodies are not supported on the Cloudflare Workers runtime", - )); - } - - let cache_mode = outbound_cache_mode(&request.cache_intent); - - let uri = request.request.uri().to_string(); - // http::Method always stores uppercase; worker 0.7 implements From only. - let method = Method::from(request.request.method().to_string()); - - let headers = Headers::new(); - for (name, value) in request.request.headers() { - let value_str = std::str::from_utf8(value.as_bytes()) - .change_context(PlatformError::HttpClient) - .attach_with(|| { - format!("non-UTF-8 bytes in outbound header `{name}` — value dropped") - })?; - // `append` rather than `set`: a request carrying the same header - // name more than once must forward every value, matching the - // response path which appends via `edge_builder.header(...)`. - headers - .append(name.as_str(), value_str) - .change_context(PlatformError::HttpClient)?; - } - - let (_, body) = request.request.into_parts(); - let body_bytes = match body { - edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), - edgezero_core::body::Body::Stream(_) => { - return Err(Report::new(PlatformError::HttpClient) - .attach("streaming request bodies are not supported on Cloudflare Workers")); - } - }; - - // Force manual redirect handling: the Workers runtime otherwise defaults - // to `RequestRedirect::Follow` and transparently chases 3xx responses to - // any host inside `Fetch::send()`. Core's `proxy_with_redirects` does its - // own per-hop redirect handling and validates each next hop against - // `allowed_domains`; auto-following here would bypass that allowlist - // (SSRF). `Manual` surfaces the 3xx + Location back to core unfollowed, - // matching the Axum adapter's `redirect::Policy::none()`. - let mut init = outbound_request_init(method, headers); - // Setting the `cache` field requires the `cache_option_enabled` - // compatibility flag, which is only on by default from compatibility - // date 2024-11-11. `wrangler.toml`/`wrangler.ci.toml` pin an earlier - // date and set the flag explicitly; without it the Workers runtime - // throws here rather than failing at deploy time. - match cache_mode { - OutboundCacheMode::NoStore => { - init.with_cache(CacheMode::NoStore); - } - OutboundCacheMode::RuntimeDefault => {} - } - if !body_bytes.is_empty() { - let uint8 = js_sys::Uint8Array::from(body_bytes.as_slice()); - init.with_body(Some(uint8.into())); - } - - let worker_req = - Request::new_with_init(&uri, &init).change_context(PlatformError::HttpClient)?; - - let abort = AbortOnDrop(Some(worker::AbortController::default())); - let signal = abort - .0 - .as_ref() - .expect("should retain abort controller") - .signal(); - let mut resp = Fetch::Request(worker_req) - .send_with_signal(&signal) - .await - .change_context(PlatformError::HttpClient) - .attach_with(|| format!("outbound request to {uri} failed"))?; - - let status = resp.status_code(); - - // Pre-flight: reject oversized responses before copying bytes into the - // isolate heap. Content-Length is advisory (and absent on chunked - // responses), so the post-buffer check below is the real guard; this - // just rejects honestly-declared large bodies cheaply. Mirrors the - // Fastly adapter's two-stage cap. - if let Some(claimed_len) = resp - .headers() - .get("content-length") - .ok() - .flatten() - .and_then(|v| v.trim().parse::().ok()) - && claimed_len > MAX_PLATFORM_RESPONSE_BODY_BYTES - { - return Err(Report::new(PlatformError::HttpClient).attach(format!( - "origin Content-Length {claimed_len} exceeds \ - {MAX_PLATFORM_RESPONSE_BODY_BYTES}-byte response body limit" - ))); - } - - let connection_tokens = response_connection_tokens(&resp); - let mut edge_builder = edgezero_core::http::response_builder().status(status); - for (name, value) in resp.headers().entries() { - // The Workers runtime auto-decompresses gzip/br/deflate and handles - // chunked transfer — strip these headers so the proxy layer does not - // attempt a second decompression pass on the already-decoded body. - // Content-Length is stripped too: the origin value describes the - // compressed payload, not the decoded bytes returned by `bytes()`, - // and forwarding it stale would truncate pass-through responses. - // The accurate length is set from the decoded body below. - if name.eq_ignore_ascii_case("content-encoding") - || name.eq_ignore_ascii_case("transfer-encoding") - || name.eq_ignore_ascii_case("content-length") - { - continue; - } - // Strip hop-by-hop headers (and any header named in the upstream - // `Connection:` token list) so they are not forwarded downstream, - // matching the Axum adapter's `is_hop_by_hop_response_header`. - if is_hop_by_hop_response_header(&name, &connection_tokens) { - continue; - } - edge_builder = edge_builder.header(name.as_str(), value.as_bytes()); - } - let body_bytes = resp - .bytes() - .await - .change_context(PlatformError::HttpClient)?; - - // Belt-and-suspenders: catches chunked responses with no Content-Length. - if body_bytes.len() > MAX_PLATFORM_RESPONSE_BODY_BYTES { - return Err(Report::new(PlatformError::HttpClient).attach(format!( - "origin response body {} bytes exceeds \ - {MAX_PLATFORM_RESPONSE_BODY_BYTES}-byte limit", - body_bytes.len() - ))); - } - edge_builder = edge_builder.header( - edgezero_core::http::header::CONTENT_LENGTH, - body_bytes.len(), - ); - let edge_resp = edge_builder - .body(edgezero_core::body::Body::from(body_bytes)) - .change_context(PlatformError::HttpClient)?; - - Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) - } -} - -#[cfg(target_arch = "wasm32")] -#[async_trait::async_trait(?Send)] -impl PlatformHttpClient for CloudflareHttpClient { - async fn send( - &self, - request: PlatformHttpRequest, - ) -> Result> { - Self::execute(request).await - } - - fn supports_concurrent_fanout(&self) -> bool { - // Requests start concurrently at dispatch, but `select` and - // `select_until` collect only one response, so multi-provider fan-out - // stays unsupported. - false - } - - async fn send_async( - &self, - request: PlatformHttpRequest, - ) -> Result> { - let backend_name = request.backend_name.clone(); - let pending = PlatformPendingResponse::start( - Self::execute(request), - worker::wasm_bindgen_futures::spawn_local, - ); - Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name)) - } - - async fn select( - &self, - mut pending_requests: Vec, - ) -> Result> { - if pending_requests.is_empty() { - return Err(Report::new(PlatformError::HttpClient) - .attach("select called with an empty pending_requests list")); - } - - reject_multi_provider_fanout(pending_requests.len())?; - - let ready_platform = pending_requests.remove(0); - let mut pending = ready_platform - .downcast::>>>() - .map_err(|_| { - Report::new(PlatformError::HttpClient) - .attach("unexpected inner type in CloudflareHttpClient::select") - })?; - - let ready = pending.wait().await.unwrap_or_else(|error| { - Err(Report::new(PlatformError::HttpClient) - .attach(format!("dispatched response task stopped: {error}"))) - }); - Ok(PlatformSelectResult { - ready, - remaining: pending_requests, - failed_backend_name: None, - }) - } - - async fn select_until( - &self, - mut pending_requests: Vec, - deadline: web_time::Instant, - ) -> Result, Report> { - if pending_requests.is_empty() { - return Err(Report::new(PlatformError::HttpClient) - .attach("select_until called with an empty pending_requests list")); - } - if pending_requests.len() > 1 { - return Err(Report::new(PlatformError::HttpClient) - .attach("multi-provider fan-out is not supported on this adapter")); - } - let platform_pending = pending_requests.remove(0); - let backend_name = platform_pending.backend_name().map(str::to_owned); - let mut pending = platform_pending - .downcast::>>>() - .map_err(|_| { - Report::new(PlatformError::HttpClient) - .attach("unexpected pending request type in deadline selection") - })?; - let result = pending - .complete_before_deadline( - async { - // Workers timers use whole signed milliseconds. Recheck the - // absolute deadline after each bounded timer interval. - loop { - let remaining = - deadline.saturating_duration_since(web_time::Instant::now()); - if remaining.is_zero() { - break; - } - worker::Delay::from( - remaining - .max(Duration::from_millis(1)) - .min(Duration::from_millis(i32::MAX as u64)), - ) - .await; - } - }, - deadline, - web_time::Instant::now, - ) - .await; - Ok(result.map(|result| { - let ready = result.unwrap_or_else(|error| { - Err(Report::new(PlatformError::HttpClient) - .attach(format!("dispatched response task stopped: {error}"))) - }); - PlatformSelectResult { - failed_backend_name: ready.as_ref().err().and(backend_name), - ready, - remaining: pending_requests, - } - })) - } -} - // --------------------------------------------------------------------------- // CloudflareSecretStoreAdapter — WASM target only // @@ -627,11 +219,6 @@ impl PlatformSecretStore for CloudflareSecretStoreAdapter { pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { let client_ip = extract_client_ip(ctx); - #[cfg(target_arch = "wasm32")] - let http_client: Arc = Arc::new(CloudflareHttpClient); - #[cfg(not(target_arch = "wasm32"))] - let http_client: Arc = Arc::new(UnavailableHttpClient); - // Config: use the ConfigStoreHandle injected by run_app — no #[cfg] needed. let config_store: Arc = ctx .config_store_default() @@ -646,14 +233,15 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R // Secrets: still requires wasm32-specific env.secret() (async/sync mismatch). #[cfg(target_arch = "wasm32")] - let secret_store: Arc = - edgezero_adapter_cloudflare::context::CloudflareRequestContext::get(ctx.request()) - .map(|cf_ctx| { - Arc::new(CloudflareSecretStoreAdapter { - env: cf_ctx.env().clone(), - }) as Arc - }) - .unwrap_or_else(|| Arc::new(NoopSecretStore)); + let secret_store: Arc = ctx + .extensions() + .get::() + .map(|cf_ctx| { + Arc::new(CloudflareSecretStoreAdapter { + env: cf_ctx.env().clone(), + }) as Arc + }) + .unwrap_or_else(|| Arc::new(NoopSecretStore)); #[cfg(not(target_arch = "wasm32"))] let secret_store: Arc = Arc::new(NoopSecretStore); @@ -666,7 +254,10 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .secret_store(secret_store) .kv_store(kv_store) .backend(Arc::new(NoopBackend)) - .http_client(http_client) + .http_client(EdgeZeroHttpClient::for_request( + ctx, + AuctionTargetId::Cloudflare, + )) .geo(Arc::new(geo)) .client_info(ClientInfo { client_ip, @@ -719,7 +310,7 @@ impl PlatformGeo for CloudflareGeo { } fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { - let headers = ctx.request().headers(); + let headers = ctx.headers(); let country = headers .get("cf-ipcountry") .and_then(|v| v.to_str().ok()) @@ -756,39 +347,12 @@ fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { } fn extract_client_ip(ctx: &edgezero_core::context::RequestContext) -> Option { - ctx.request() - .headers() + ctx.headers() .get("cf-connecting-ip") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse().ok()) } -/// Reject multi-provider auction fan-out at the Cloudflare adapter level. -/// -/// Pending worker requests start in a runtime task at dispatch. Collection -/// still selects one response future at a time, so the adapter retains its -/// single-provider contract. -/// -/// The primary guard lives in the auction orchestrator, which checks -/// `supports_concurrent_fanout()` and rejects multi-provider configurations -/// before any request launches. This `select`-time rejection is -/// defense-in-depth for callers that bypass that check. -/// -/// Extracted as a free function so the critical control-flow is testable on -/// native targets where the `#[cfg(target_arch = "wasm32")]` `select` impl -/// is excluded from the test binary. -#[cfg(any(target_arch = "wasm32", test))] -fn reject_multi_provider_fanout(len: usize) -> Result<(), Report> { - if len >= 2 { - return Err(Report::new(PlatformError::HttpClient).attach(format!( - "CloudflareHttpClient: multi-provider fan-out is not supported \ - ({len} providers submitted). Configure a single auction provider \ - or use the Fastly adapter for parallel DSP fan-out." - ))); - } - Ok(()) -} - #[cfg(target_arch = "wasm32")] struct CloudflareTimer; @@ -908,13 +472,6 @@ mod tests { ); } - #[cfg(target_arch = "wasm32")] - #[test] - fn outbound_request_creation_sets_manual_redirect_mode() { - let init = outbound_request_init(worker::Method::Get, worker::Headers::new()); - assert!(matches!(init.redirect, worker::RequestRedirect::Manual)); - } - #[test] fn auction_http_capabilities_are_explicit() { let capabilities = trusted_server_core::platform::AuctionTargetId::Cloudflare @@ -1032,64 +589,4 @@ mod tests { "should populate longitude" ); } - - // --------------------------------------------------------------------------- - // reject_multi_provider_fanout tests - // --------------------------------------------------------------------------- - - #[test] - fn reject_multi_provider_fanout_passes_empty() { - assert!( - reject_multi_provider_fanout(0).is_ok(), - "len=0 should pass (empty list caught separately in select)" - ); - } - - #[test] - fn reject_multi_provider_fanout_passes_single_provider() { - assert!( - reject_multi_provider_fanout(1).is_ok(), - "single provider should be allowed" - ); - } - - #[test] - fn reject_multi_provider_fanout_rejects_two_providers() { - assert!( - reject_multi_provider_fanout(2).is_err(), - "len=2 should be rejected" - ); - } - - #[test] - fn reject_multi_provider_fanout_rejects_five_providers() { - let err = reject_multi_provider_fanout(5).expect_err("should reject five providers"); - let msg = format!("{err:?}"); - assert!( - msg.contains("5"), - "error message should include provider count" - ); - } - - // ----------------------------------------------------------------------- - // outbound_cache_mode tests - // ----------------------------------------------------------------------- - - #[test] - fn outbound_cache_mode_maps_bypass_to_no_store() { - assert_eq!( - outbound_cache_mode(&trusted_server_core::platform::PlatformCacheIntent::Bypass), - OutboundCacheMode::NoStore, - "a bypass intent should force the Workers `no-store` cache mode" - ); - } - - #[test] - fn outbound_cache_mode_leaves_default_when_not_bypassing() { - assert_eq!( - outbound_cache_mode(&trusted_server_core::platform::PlatformCacheIntent::Default), - OutboundCacheMode::RuntimeDefault, - "a default intent should keep the runtime default cache behavior" - ); - } } diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index f385bf5295..ce23024f16 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -13,6 +13,16 @@ use trusted_server_core::settings::Settings; const LEGACY_ADMIN_DENY_METHODS: &[&str] = &["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"]; +#[test] +fn named_sign_route_buffers_guarded_streamed_json_under_its_existing_cap() { + let settings = trusted_server_core::test_support::named_route_inputs::sign_settings(); + let router = TrustedServerApp::routes_with_settings(settings) + .expect("should build streamed-input routes"); + futures::executor::block_on( + trusted_server_core::test_support::named_route_inputs::assert_sign_inputs(&router), + ); +} + /// Build the full application router from explicit test settings. /// /// The settings baked into the binary contain placeholder secrets that diff --git a/crates/trusted-server-adapter-cloudflare/tests/runtime/deadline.rs b/crates/trusted-server-adapter-cloudflare/tests/runtime/deadline.rs index d3875d76b1..d0fcf7b3bb 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/runtime/deadline.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/runtime/deadline.rs @@ -1,33 +1,44 @@ -//! Local runtime fixture exercising the production HTTP client implementation. +//! Local fixture using the production request-local EdgeZero client and driver. +#![cfg(target_arch = "wasm32")] -#[cfg(target_arch = "wasm32")] -#[allow(dead_code)] -#[path = "../../src/platform.rs"] -mod platform; - -#[cfg(target_arch = "wasm32")] use std::time::Duration; -#[cfg(target_arch = "wasm32")] -use trusted_server_core::platform::{PlatformHttpClient as _, PlatformHttpRequest}; -#[cfg(target_arch = "wasm32")] +use edgezero_core::app::Hooks; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::Response; +use edgezero_core::router::RouterService; +use trusted_server_core::platform::{ + AuctionTargetId, EdgeZeroHttpClient, PlatformError, PlatformHttpRequest, +}; + +struct DeadlineFixture; + +impl Hooks for DeadlineFixture { + fn routes() -> Result { + Ok(RouterService::builder() + .get("/", |context: RequestContext| async move { + exercise(context) + .await + .map(|result| Response::new(Body::from(result))) + .map_err(|_| EdgeError::bad_gateway("deadline fixture exchange fails")) + }) + .build()) + } +} + #[worker::event(fetch)] async fn main( request: worker::Request, - _: worker::Env, - _: worker::Context, + env: worker::Env, + context: worker::Context, ) -> worker::Result { - let uri = request.url()?.to_string(); - let result = exercise(&uri) - .await - .map_err(|error| worker::Error::RustError(format!("{error:?}")))?; - worker::Response::ok(result) + edgezero_adapter_cloudflare::run_app::(request, env, context).await } -#[cfg(target_arch = "wasm32")] -async fn exercise( - uri: &str, -) -> Result> { +async fn exercise(context: RequestContext) -> Result> { + let uri = context.uri().to_string(); let parameter = |name: &str| { uri.split_once('?').and_then(|(_, query)| { query @@ -46,10 +57,10 @@ async fn exercise( .expect("should supply origin delay") .parse() .expect("should parse origin delay"); - let client = platform::CloudflareHttpClient; + let client = EdgeZeroHttpClient::for_request(&context, AuctionTargetId::Cloudflare); let request = edgezero_core::http::request_builder() .uri(upstream) - .body(edgezero_core::body::Body::empty()) + .body(Body::empty()) .expect("should construct fixture request"); let started = web_time::Instant::now(); let pending = client diff --git a/crates/trusted-server-adapter-fastly/Cargo.toml b/crates/trusted-server-adapter-fastly/Cargo.toml index 6c7121d0d6..28eadc5a1f 100644 --- a/crates/trusted-server-adapter-fastly/Cargo.toml +++ b/crates/trusted-server-adapter-fastly/Cargo.toml @@ -42,6 +42,7 @@ urlencoding = { workspace = true } web-time = { workspace = true } [dev-dependencies] +edgezero-adapter-fastly = { workspace = true, features = ["test-utils"] } bytes = { workspace = true } edgezero-core = { workspace = true, features = ["test-utils"] } toml = { workspace = true } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 6928cdd55d..e63a3dd5ca 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -145,8 +145,8 @@ use trusted_server_core::trace::{TraceMetadata, TracePreDispatchHook}; use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; use crate::platform::{ - FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient, - FastlyPlatformSecretStore, UnavailableKvStore, + FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformSecretStore, + UnavailableKvStore, }; // --------------------------------------------------------------------------- @@ -186,6 +186,7 @@ impl RuntimeStoreConfig { /// shared services live here; request-specific state must remain request-local. pub(crate) struct AppState { pub(crate) settings: Arc, + pub(crate) delivery_observation: crate::DeliveryObservation, pub(crate) orchestrator: Arc, pub(crate) registry: Arc, pub(crate) default_kv_store: Arc, @@ -230,6 +231,7 @@ pub(crate) fn build_state_from_settings( let default_kv_store = Arc::new(UnavailableKvStore) as Arc; Ok(Arc::new(AppState { + delivery_observation: crate::DeliveryObservation::default(), settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), @@ -265,12 +267,14 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime // No cooperative timer is installed: Fastly host calls block. EOF consumers // must bound their own transport calls using the remaining finish budget. let client_info = ctx - .request() .extensions() .get::() .cloned() .unwrap_or_else(|| ClientInfo { - client_ip: FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip), + client_ip: ctx + .extensions() + .get::() + .and_then(|c| c.client_ip), ..ClientInfo::default() }); @@ -284,7 +288,12 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .template_cache(Arc::new(crate::template_cache::FastlyTemplateCache::new())) .template_assembler(Arc::new(crate::esi_assembly::FastlyTemplateAssembler)) .backend(Arc::new(FastlyPlatformBackend)) - .http_client(Arc::new(FastlyPlatformHttpClient)) + .http_client( + trusted_server_core::platform::EdgeZeroHttpClient::for_request( + ctx, + trusted_server_core::platform::AuctionTargetId::Fastly, + ), + ) .geo(Arc::new(FastlyPlatformGeo)) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) .client_info(client_info) @@ -548,7 +557,36 @@ async fn execute_named( handler: NamedRouteHandler, ) -> Result { let services = build_per_request_services(&state, &ctx); - let mut req = ctx.into_request(); + if ctx.method() == Method::POST { + let limit = match handler { + NamedRouteHandler::VerifySignature => { + Some(trusted_server_core::request_signing::endpoints::VERIFY_MAX_BODY_BYTES) + } + NamedRouteHandler::RotateKey | NamedRouteHandler::DeactivateKey => { + Some(trusted_server_core::request_signing::endpoints::ADMIN_MAX_BODY_BYTES) + } + NamedRouteHandler::Auction => { + Some(trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE) + } + NamedRouteHandler::FirstPartySign => { + Some(trusted_server_core::proxy::SIGN_MAX_BODY_BYTES) + } + NamedRouteHandler::FirstPartyProxyRebuild => { + Some(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES) + } + NamedRouteHandler::AdminCachePurge => { + Some(trusted_server_core::cache_purge::PURGE_MAX_BODY_BYTES) + } + NamedRouteHandler::BatchSync => { + Some(trusted_server_core::ec::batch_sync::MAX_BODY_SIZE) + } + _ => None, + }; + if let Some(limit) = limit { + ctx.body_bytes(limit).await?; + } + } + let mut req = ctx.into_request()?; // S2S batch sync uses Bearer auth (not EC cookies), so it skips EC // context creation entirely — mirroring the dedicated early arm in the @@ -775,7 +813,7 @@ async fn execute_fallback( ctx: RequestContext, ) -> Result { let services = build_per_request_services(&state, &ctx); - let req = ctx.into_request(); + let req = ctx.into_request()?; Ok(dispatch_fallback(&state, &services, req).await) } @@ -1286,11 +1324,23 @@ pub struct TrustedServerApp; impl TrustedServerApp { pub(crate) fn build_app_with_state( stores: &RuntimeStoreConfig, - ) -> (App, Option>) { + ) -> Result<(App, Option>), EdgeError> { let (router, state) = Self::router_with_state(stores); - let mut app = App::with_name(router, Self::name()); - Self::configure(&mut app); - (app, state) + let mut app = App::with_name_and_platform( + router, + Self::name(), + edgezero_adapter_fastly::FASTLY_PLATFORM, + ); + Self::configure(&mut app)?; + if app.platform() != edgezero_adapter_fastly::FASTLY_PLATFORM { + return Err(EdgeError::internal(fastly::Error::msg( + "configure replaces Fastly platform metadata", + ))); + } + if let Some(state) = state.as_ref() { + app.set_response_egress_observer(state.delivery_observation.clone()); + } + Ok((app, state)) } fn router_with_state(stores: &RuntimeStoreConfig) -> (RouterService, Option>) { @@ -1350,13 +1400,16 @@ impl TrustedServerApp { } } -fn trace_metadata(request: &Request) -> TraceMetadata { +fn trace_metadata(request: &edgezero_core::http::RequestParts) -> TraceMetadata { let client_info = request - .extensions() + .extensions .get::() .cloned() .unwrap_or_else(|| ClientInfo { - client_ip: FastlyRequestContext::get(request).and_then(|context| context.client_ip), + client_ip: request + .extensions + .get::() + .and_then(|context| context.client_ip), ..ClientInfo::default() }); let geo = client_info.client_ip.and_then(|client_ip| { @@ -1375,9 +1428,9 @@ impl Hooks for TrustedServerApp { "TrustedServer" } - fn routes() -> RouterService { + fn routes() -> Result { let stores = RuntimeStoreConfig::logical(); - Self::router_with_state(&stores).0 + Ok(Self::router_with_state(&stores).0) } fn stores() -> StoresMetadata { @@ -1445,6 +1498,16 @@ mod tests { use trusted_server_core::settings::Settings; use trusted_server_core::test_support::nextjs_auction; + #[test] + fn named_sign_route_buffers_guarded_streamed_json_under_its_existing_cap() { + let settings = trusted_server_core::test_support::named_route_inputs::sign_settings(); + let state = build_state_from_settings(settings).expect("should build streamed-input state"); + let router = TrustedServerApp::routes_for_state(&state); + futures::executor::block_on( + trusted_server_core::test_support::named_route_inputs::assert_sign_inputs(&router), + ); + } + #[test] fn hooks_store_metadata_matches_edgezero_manifest() { let manifest: toml::Value = toml::from_str(include_str!("../../../edgezero.toml")) @@ -1615,6 +1678,7 @@ mod tests { let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; let state = Arc::new(super::AppState { + delivery_observation: Default::default(), auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, ), @@ -3236,7 +3300,7 @@ mod tests { let registry = Arc::clone(®istry); let orchestrator = Arc::clone(&orchestrator); Box::pin(async move { - let request = ctx.into_request(); + let request = ctx.into_request()?; let method = request.method().clone(); let mut ec_context = match EcContext::read_from_request(&settings, &request, &services) { @@ -3382,7 +3446,7 @@ mod tests { "should preserve the streaming origin status" ); assert!( - matches!(response.body(), Body::Stream(_)), + response.body().is_stream(), "EdgeZero asset dispatch must stream the origin body, not buffer it" ); } @@ -3426,9 +3490,10 @@ mod tests { gzip.then_some("gzip"), "should retain the requested response compression" ); - let Body::Stream(mut stream) = response.into_body() else { - panic!("should retain lazy Fastly body"); - }; + let mut stream = response + .into_body() + .into_stream() + .expect("should retain lazy Fastly body"); let mut bytes = Vec::new(); block_on(async { while let Some(chunk) = stream.next().await { @@ -3460,7 +3525,7 @@ mod tests { "publisher proxy should succeed against the streaming origin stub" ); assert!( - matches!(response.body(), Body::Stream(_)), + response.body().is_stream(), "EdgeZero publisher dispatch must attach the lazy streaming body, not buffer it" ); assert!( diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs index 4b679faecf..20bbf8d2b5 100644 --- a/crates/trusted-server-adapter-fastly/src/compat.rs +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -1,9 +1,7 @@ -//! Compatibility bridge between `fastly` SDK types and `http` crate types. +//! Trusted forwarding metadata for Fastly SDK ingress. use std::net::IpAddr; -use edgezero_core::body::Body as EdgeBody; -use edgezero_core::http::Response as HttpResponse; use edgezero_core::http::{HeaderMap, HeaderName, HeaderValue}; use trusted_server_core::forwarder::ForwarderPreparation; use trusted_server_core::http_util::SPOOFABLE_FORWARDED_HEADERS; @@ -12,52 +10,6 @@ use trusted_server_core::settings::TrustedClientIpConfig; use crate::platform::resolve_client_ip; -/// Convert an [`HttpResponse`] into a `fastly::Response`. -pub(crate) fn to_fastly_response(resp: HttpResponse) -> fastly::Response { - let (parts, body) = resp.into_parts(); - let mut fastly_resp = fastly::Response::from_status(parts.status.as_u16()); - for (name, value) in &parts.headers { - fastly_resp.append_header(name.as_str(), value.as_bytes()); - } - - match body { - EdgeBody::Once(bytes) => { - if !bytes.is_empty() { - fastly_resp.set_body(bytes.to_vec()); - } - } - EdgeBody::Stream(_) => { - // Streaming bodies cannot cross the compat boundary. Both audited call sites - // (legacy_main buffered arm and edgezero_main after EdgeZero collapses bodies - // to Once) only pass Once bodies — a Stream here is a caller error. - // The assert is suppressed in test builds where the behavior-documentation - // test deliberately exercises this path. - #[cfg(not(test))] - debug_assert!( - false, - "to_fastly_response: streaming body will be silently dropped; \ - use to_fastly_response_skeleton + stream_to_client for streaming responses" - ); - log::warn!("streaming body in compat::to_fastly_response; body will be empty"); - } - } - - fastly_resp -} - -/// Convert an [`HttpResponse`] into a `fastly::Response` without a body. -/// -/// Use this when the caller will stream the body separately through -/// [`fastly::Response::stream_to_client`]. -pub(crate) fn to_fastly_response_skeleton(resp: HttpResponse) -> fastly::Response { - let (parts, _body) = resp.into_parts(); - let mut fastly_resp = fastly::Response::from_status(parts.status.as_u16()); - for (name, value) in &parts.headers { - fastly_resp.append_header(name.as_str(), value.as_bytes()); - } - fastly_resp -} - /// Sanitize forwarded headers on a `fastly::Request`. /// /// Strips configured trust headers and headers that clients can spoof before @@ -134,6 +86,7 @@ pub(crate) fn capture_fastly_forwarder( #[cfg(test)] mod tests { use super::*; + use edgezero_core::body::Body as EdgeBody; use trusted_server_core::forwarder::prepare_trusted_forwarder; use trusted_server_core::http_util::RequestInfo; use trusted_server_core::platform::ClientInfo; @@ -306,29 +259,4 @@ mod tests { "should strip the configured auth header after resolving" ); } - - #[test] - fn to_fastly_response_with_streaming_body_produces_empty_body() { - use edgezero_core::http::StatusCode; - - let stream = futures::stream::empty::(); - let stream_body = EdgeBody::stream(stream); - - let http_resp = edgezero_core::http::response_builder() - .status(StatusCode::OK) - .body(stream_body) - .expect("should build response"); - - let mut fastly_resp = to_fastly_response(http_resp); - - assert_eq!( - fastly_resp.get_status().as_u16(), - 200, - "should preserve status" - ); - assert!( - fastly_resp.take_body_bytes().is_empty(), - "should produce empty body for streaming response" - ); - } } diff --git a/crates/trusted-server-adapter-fastly/src/delivery_tests.rs b/crates/trusted-server-adapter-fastly/src/delivery_tests.rs new file mode 100644 index 0000000000..4b22434efd --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/delivery_tests.rs @@ -0,0 +1,310 @@ +//! Managed Fastly finalization with an injected terminal writer. +//! +//! These fixtures exercise managed request conversion, finalizers and terminal reports. +//! The injected writer does not qualify SDK response writes or network disconnects. + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use edgezero_adapter_fastly::delivery::{ + DeliveryError, FastlyRequestDelivery, FastlyResponseCommitState, FastlyResponseDelivery, + testing::response_delivery, +}; +use edgezero_adapter_fastly::request::FastlyService; +use edgezero_core::app::App; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Response, StatusCode, header}; +use edgezero_core::ingress::AdmissionDecision; +use edgezero_core::response_egress::{ + ResponseEgressEnvelope, ResponseEgressFallbackDisposition, ResponseEgressObserver, + ResponseEgressOutcome, ResponseEgressReport, +}; +use edgezero_core::response_egress_framing::ResponseEgressFramingMode; +use edgezero_core::router::RouterService; +use futures::executor::block_on; + +use super::{DeliveryObservation, apply_terminal_response_effects}; + +#[derive(Clone)] +struct Observer { + delivered: DeliveryObservation, + count: Arc, +} + +impl ResponseEgressObserver for Observer { + fn complete(&self, report: &ResponseEgressReport) { + self.count.fetch_add(1, Ordering::SeqCst); + self.delivered.complete(report); + } +} + +#[derive(Clone, Copy)] +enum WriterResult { + Application, + Fallback, + FallbackFailure, + SourceFailure, +} + +fn router(source_failure: bool) -> RouterService { + RouterService::builder() + .get("/", move |_context: RequestContext| async move { + let body = if source_failure { + Body::from_external_stream(futures::stream::iter([ + Ok(bytes::Bytes::from_static(b"prefix")), + Err(std::io::Error::other("fictional body source failure")), + ])) + } else { + Body::stream(futures::stream::iter([ + bytes::Bytes::from_static(b"first"), + bytes::Bytes::from_static(b"second"), + ])) + }; + let mut response = Response::new(body); + for cookie in ["fixture_a=one", "fixture_b=two"] { + response.headers_mut().append( + header::SET_COOKIE, + cookie.parse().expect("should parse fixture cookie"), + ); + } + response.headers_mut().insert( + header::CACHE_CONTROL, + "public, max-age=60" + .parse() + .expect("should parse fixture cache policy"), + ); + Ok::<_, EdgeError>(response) + }) + .build() +} + +fn write(envelope: ResponseEgressEnvelope, case: WriterResult) -> FastlyResponseDelivery { + let (prepared, _, mut attempt, clock) = envelope + .begin(ResponseEgressFramingMode::HostAutomatic) + .map_err(|_| ()) + .expect("should begin fixture egress"); + let result = if matches!(case, WriterResult::Fallback | WriterResult::FallbackFailure) { + assert!( + attempt.begin_fallback(ResponseEgressOutcome::ConversionError), + "should select one fallback" + ); + assert!(attempt.begin_writing(), "should begin fallback writing"); + let failed = matches!(case, WriterResult::FallbackFailure); + let disposition = if failed { + ResponseEgressFallbackDisposition::Aborted + } else { + ResponseEgressFallbackDisposition::Completed + }; + assert!( + attempt.finish_fallback(disposition, clock.now()), + "should complete fallback once" + ); + if failed { + Err(DeliveryError::Transport) + } else { + Ok(()) + } + } else { + assert!(attempt.begin_writing(), "should begin application writing"); + let body = block_on( + prepared + .into_response() + .into_body() + .into_bytes_bounded(1024), + ); + if matches!(case, WriterResult::SourceFailure) { + assert!(body.is_err(), "should observe the original body failure"); + assert!( + attempt.terminate(ResponseEgressOutcome::SourceError, clock.now()), + "should terminate source failure once" + ); + Err(DeliveryError::Source) + } else { + assert_eq!( + body.expect("should collect fixture body").as_ref(), + b"firstsecond", + "should retain streamed application bytes" + ); + assert!( + attempt.complete(clock.now()), + "should complete application once" + ); + Ok(()) + } + }; + assert!( + !attempt.complete(clock.now()), + "should reject another terminal transition" + ); + response_delivery( + FastlyResponseCommitState::Committed, + attempt.into_terminal_report(), + result, + ) +} + +fn dispatch(case: WriterResult) -> (FastlyRequestDelivery, Observer) { + let observer = Observer { + delivered: DeliveryObservation::default(), + count: Arc::new(AtomicUsize::new(0)), + }; + let mut app = App::new(router(matches!(case, WriterResult::SourceFailure))); + app.set_response_egress_observer(observer.clone()); + observer.delivered.reset(); + let before_delivery = observer.clone(); + let result = FastlyService::new(&app).run_request_with_hooks_for_test( + fastly::Request::get("http://publisher.example.com/"), + |raw, _extensions| { + raw.remove_header("x-forwarded-host"); + }, + |response| { + apply_terminal_response_effects(response, None); + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 2, + "should retain both cookies" + ); + assert!( + !response.headers()[header::CACHE_CONTROL] + .to_str() + .expect("should read cache policy") + .contains("public"), + "should enforce response privacy" + ); + assert!( + !before_delivery.delivered.completed(), + "should not complete at handler return" + ); + 7 + }, + |envelope| write(envelope, case), + ); + (result, observer) +} + +#[test] +fn pull_sync_requires_application_delivery_not_successful_fallback() { + for case in [ + WriterResult::Application, + WriterResult::Fallback, + WriterResult::FallbackFailure, + WriterResult::SourceFailure, + ] { + let (result, observer) = dispatch(case); + let FastlyRequestDelivery::Routed { state, delivery } = result else { + panic!("should retain routed finalization state"); + }; + assert_eq!( + state, 7, + "should retain routed state even when delivery fails" + ); + assert_eq!( + delivery.error(), + match case { + WriterResult::SourceFailure => Some(DeliveryError::Source), + WriterResult::FallbackFailure => Some(DeliveryError::Transport), + _ => None, + }, + "should retain the terminal send result" + ); + assert_eq!( + observer.delivered.completed(), + matches!(case, WriterResult::Application), + "should authorize post-send only after successful application egress" + ); + assert_eq!( + observer.count.load(Ordering::SeqCst), + 1, + "should notify once" + ); + } +} + +#[test] +fn detached_refusal_skips_routed_state_and_application_observer() { + let observer = Observer { + delivered: DeliveryObservation::default(), + count: Arc::new(AtomicUsize::new(0)), + }; + let mut app = App::new(router(false)); + app.set_response_egress_observer(observer.clone()); + let refused = Arc::new(AtomicUsize::new(0)); + let count = Arc::clone(&refused); + app.set_ingress_admission_policy(move |_head| { + let count = Arc::clone(&count); + AdmissionDecision::Refuse { + completion: edgezero_core::ResponseEgressCompletion::new(move |_report| { + count.fetch_add(1, Ordering::SeqCst); + }), + response: edgezero_core::http::response_builder() + .status(StatusCode::FORBIDDEN) + .body(Body::from("refused")) + .expect("should construct refusal"), + } + }); + let finalized = AtomicUsize::new(0); + let result = FastlyService::new(&app).run_request_with_hooks_for_test( + fastly::Request::get("http://publisher.example.com/"), + |_raw, _extensions| {}, + |_response| { + finalized.fetch_add(1, Ordering::SeqCst); + 7 + }, + |envelope| { + let (prepared, _, mut attempt, clock) = envelope + .begin(ResponseEgressFramingMode::HostAutomatic) + .map_err(|_| ()) + .expect("should begin refused egress"); + assert!(attempt.begin_writing(), "should begin refused writing"); + assert_eq!( + block_on( + prepared + .into_response() + .into_body() + .into_bytes_bounded(1024) + ) + .expect("should collect refusal") + .as_ref(), + b"refused", + "should retain bounded refusal bytes" + ); + assert!(attempt.complete(clock.now()), "should complete refusal"); + response_delivery( + FastlyResponseCommitState::Committed, + attempt.into_terminal_report(), + Ok(()), + ) + }, + ); + assert!( + matches!(result, FastlyRequestDelivery::Detached(_)), + "should retain detached disposition" + ); + assert_eq!( + finalized.load(Ordering::SeqCst), + 0, + "should not finalize a routed response" + ); + assert!( + !observer.delivered.completed(), + "should not authorize application post-send work" + ); + assert_eq!( + observer.count.load(Ordering::SeqCst), + 0, + "should not invoke application observer" + ); + assert_eq!( + refused.load(Ordering::SeqCst), + 1, + "should complete the refusal once" + ); +} diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index deb55c827d..114bfc7387 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,12 +1,12 @@ use std::sync::Arc; use edgezero_adapter_fastly::config_store::FastlyConfigStore as EdgeZeroFastlyConfigStore; -use edgezero_adapter_fastly::request::into_core_request; -use edgezero_core::body::Body as EdgeBody; +use edgezero_adapter_fastly::delivery::FastlyRequestDelivery; +use edgezero_adapter_fastly::request::send_request_with_registries_and_hooks; +use edgezero_core::app::Hooks as _; use edgezero_core::config_store::ConfigStoreHandle; -use edgezero_core::error::EdgeError; use edgezero_core::http::{Request as HttpRequest, Response as HttpResponse}; -use edgezero_core::response::IntoResponse; +use edgezero_core::response_egress::{ResponseEgressBodyKind, ResponseEgressOutcome}; use error_stack::Report; use fastly::http::Method as FastlyMethod; use fastly::{Request as FastlyRequest, Response as FastlyResponse}; @@ -23,7 +23,7 @@ use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; use trusted_server_core::integrations::RequestFilterEffects; use trusted_server_core::platform::PlatformGeo as _; -use trusted_server_core::proxy::{AssetProxyCachePolicy, stream_asset_body}; +use trusted_server_core::proxy::AssetProxyCachePolicy; use trusted_server_core::response_privacy::TerminalPrivateResponse; use trusted_server_core::settings::Settings; use trusted_server_core::trace::{TraceTerminalResponse, is_trace_path}; @@ -32,6 +32,8 @@ use trusted_server_core::version_header::{apply_git_version_header, git_version_ mod app; mod backend; mod compat; +#[cfg(test)] +mod delivery_tests; mod ec_kv; mod esi_assembly; mod logging; @@ -108,10 +110,12 @@ fn main() { // `run_custom` completes the callback's SDK result exactly once. // The callback sends its own response and returns `()`, so there // is no error for the SDK to turn into a second response. - let Ok(()) = edgezero_adapter_fastly::lifecycle::run_custom( + if let Err(error) = edgezero_adapter_fastly::lifecycle::run_custom( FastlyRequest::from_client(), |request, sandbox: &mut Sandbox| handle_request(request, sandbox, &mut startup), - ); + ) { + log::error!("Fastly request delivery failed: {error}"); + } } ServeMode::Reuse(limits) => serve_loop(limits, startup), } @@ -137,8 +141,8 @@ fn serve_loop(limits: crate::sandbox::SandboxLimits, mut startup: StartupDiagnos .with_timeout(limits.timeout), |request, sandbox: &mut Sandbox| { counters.observe(sandbox, |sandbox| { - handle_request(request, sandbox, &mut startup); - }); + handle_request(request, sandbox, &mut startup) + }) }, ); @@ -148,6 +152,9 @@ fn serve_loop(limits: crate::sandbox::SandboxLimits, mut startup: StartupDiagnos counters.requests(), counters.attempts() ); + if let Err(error) = summary.into_result() { + log::error!("Fastly serving terminated after delivery failure: {error}"); + } } #[cfg(not(feature = "reusable-sandbox"))] @@ -157,15 +164,19 @@ fn serve_loop(_limits: crate::sandbox::SandboxLimits, _startup: StartupDiagnosti /// Handles one request end to end, sending its own response. /// -/// Returns `()` rather than a response so the streaming publisher path can -/// call [`fastly::Response::stream_to_client`] itself. The SDK's -/// `HandlerResult` impl for `()` treats that as already sent. +/// Routed transmission belongs to the RC. A terminal error stops the SDK +/// callback without asking it to render a second response. Raw probes remain +/// explicit app-build-free exceptions. /// /// Every non-panicking path through this function must send exactly once. In a /// reused sandbox the SDK refuses to wait for the next request until the /// current one is complete, so a missed send stalls the loop rather than /// merely dropping one response. -fn handle_request(req: FastlyRequest, sandbox: &mut Sandbox, startup: &mut StartupDiagnostics) { +fn handle_request( + req: FastlyRequest, + sandbox: &mut Sandbox, + startup: &mut StartupDiagnostics, +) -> Result<(), fastly::Error> { // The framework counts the callback before invoking it, including early // returns, so this is already this request's 1-based ordinal. let ordinal = sandbox.requests(); @@ -176,7 +187,7 @@ fn handle_request(req: FastlyRequest, sandbox: &mut Sandbox, startup: &mut Start // Health probe bypasses logging, settings, and app construction as a cheap liveness signal. if !trace_path && let Some(response) = health_response(&req) { response.send_to_client(); - return; + return Ok(()); } // Marked complete only once installation succeeds, so a failed install is @@ -203,7 +214,7 @@ fn handle_request(req: FastlyRequest, sandbox: &mut Sandbox, startup: &mut Start .map(str::to_owned) .unwrap_or_else(|| format!("{}-{ordinal}", instance_id())); - edgezero_main(req, sandbox, ordinal, &request_id, trace_path); + edgezero_main(req, sandbox, ordinal, &request_id, trace_path) } /// Builds the counters snapshot response. @@ -332,7 +343,7 @@ fn edgezero_main( ordinal: u64, request_id: &str, trace_path: bool, -) { +) -> Result<(), fastly::Error> { let runtime_stores = RuntimeStoreConfig::logical(); // Short-circuit the sandbox counters probe before app construction. It must @@ -354,7 +365,7 @@ fn edgezero_main( .send_to_client(); } } - return; + return Ok(()); } // Short-circuit the JA4 debug probe before app construction. Must run here @@ -375,7 +386,7 @@ fn edgezero_main( .send_to_client(); } } - return; + return Ok(()); } let config_store = @@ -386,7 +397,7 @@ fn edgezero_main( FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) .with_body_text_plain("Internal Server Error") .send_to_client(); - return; + return Ok(()); } }; @@ -400,24 +411,34 @@ fn edgezero_main( // permanent error mode, and the next callback retries construction. let failed_build = sandbox .initialize(|| { - let (app, state) = TrustedServerApp::build_app_with_state(&runtime_stores); + let (app, state) = match TrustedServerApp::build_app_with_state(&runtime_stores) { + Ok(built) => built, + Err(error) => return Err(Err(error)), + }; match state { Some(state) => Ok(RetainedApp { app, state }), - None => Err(app), + None => Err(Ok(Box::new(app))), } }) .err(); let (app, app_state): (&edgezero_core::app::App, Option>) = match (failed_build.as_ref(), sandbox.state()) { - (Some(app), _) => (app, None), + (Some(Ok(app)), _) => (app.as_ref(), None), + (Some(Err(error)), _) => { + log::error!("failed to configure Fastly application: {error}"); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + return Ok(()); + } (None, Some(retained)) => (&retained.app, Some(Arc::clone(&retained.state))), (None, None) => { log::error!("no application available after initialization"); FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) .with_body_text_plain("Internal Server Error") .send_to_client(); - return; + return Ok(()); } }; @@ -478,148 +499,139 @@ fn edgezero_main( let client_ip = client_info.client_ip; let device_signals = (!trace_path).then(|| derive_device_signals(&req)); - // Dispatch directly through the EdgeZero router without an intermediate - // fastly::Response conversion. That preserves duplicate header values such - // as multiple Set-Cookie headers. - let mut response = match into_core_request(req) { - Ok(mut core_req) => { + // The SDK callback exclusively borrows the sandbox, so this bounded observer + // belongs to one dispatch at a time. Only the RC's terminal report establishes + // whether the application body, rather than a fallback, was delivered. + if let Some(state) = app_state.as_ref() { + state.delivery_observation.reset(); + } + let delivery = send_request_with_registries_and_hooks( + app, + TrustedServerApp::stores(), + req, + |_request, extensions| { if let Some(forwarder) = forwarder { - core_req.extensions_mut().insert(forwarder); + extensions.insert(forwarder); } - core_req.extensions_mut().insert(config_store); - if let Some(device_signals) = device_signals { - core_req.extensions_mut().insert(device_signals); + extensions.insert(config_store); + if let Some(signals) = device_signals { + extensions.insert(signals); } - core_req.extensions_mut().insert(client_info); - match futures::executor::block_on(app.router().oneshot(core_req)) { - Ok(response) => response, - Err(error) => edge_error_response(error), + extensions.insert(client_info); + }, + |response| { + if response + .extensions() + .get::() + .is_some() + { + return None; } - } - Err(e) => { - log::error!("EdgeZero request conversion failed: {e}"); - FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) - .with_body_text_plain("Internal Server Error") - .send_to_client(); - return; - } - }; - - if response - .extensions() - .get::() - .is_some() - { - // Trace responses keep their exact hardened headers; no sandbox counters. - send_response_to_client(response, None); - return; - } + let ec_state = response.extensions_mut().remove::(); + let asset_cache_policy = response.extensions_mut().remove::(); + let request_filter_effects = response.extensions_mut().remove::(); + let settings = settings_snapshot.clone().or_else(|| { + load_settings_from_config_store(&runtime_stores) + .ok() + .map(Arc::new) + }); - // Pop response extensions before the Fastly conversion, which drops them. - let ec_state = response.extensions_mut().remove::(); - let asset_cache_policy = response.extensions_mut().remove::(); - let request_filter_effects = response.extensions_mut().remove::(); - - if !take_finalize_sentinel(&mut response) { - if let Some(settings) = settings_snapshot.as_deref() { - apply_entry_point_finalize_headers(settings, &mut response, client_ip); - } else { - match load_settings_from_config_store(&runtime_stores) { - Ok(settings) => { - apply_entry_point_finalize_headers(&settings, &mut response, client_ip); - } - Err(e) => { - log::warn!("entry-point finalize skipped: failed to reload settings: {e:?}"); - // The compiled-in version needs no settings, so a deploy that cannot - // load its config still reports which build is serving. - apply_git_version_header(&mut response); + if !take_finalize_sentinel(response) { + if let Some(settings) = settings.as_deref() { + apply_entry_point_finalize_headers(settings, response, client_ip); + } else { + apply_git_version_header(response); } } - } - } - - if let Some(policy) = asset_cache_policy { - policy.apply_after_route_finalization(&mut response, EdgeCacheHeader::SurrogateControl); - } - - if let Some(mut ec_state) = ec_state { - if let Some(settings) = settings_snapshot.as_deref() { - // Fastly's entry-point hooks are synchronous, and its KV SDK - // completes I/O through blocking hostcalls. Keep this bridge here, - // never in native or worker executors. - match futures::executor::block_on(apply_edgezero_ec_finalize( - settings, - &mut ec_state, - &mut response, - )) { - Ok(partner_registry) => { - send_edgezero_response( - response, - request_filter_effects.as_ref(), - counters.as_ref(), - ); - futures::executor::block_on(run_edgezero_pull_sync_after_send( - settings, - &partner_registry, - &ec_state, - )); - return; - } - Err(e) => { - log::error!( - "EdgeZero EC finalize skipped: failed to build partner registry: {e:?}" - ); - } + if let Some(policy) = asset_cache_policy { + policy.apply_after_route_finalization(response, EdgeCacheHeader::SurrogateControl); } - } else { - match load_settings_from_config_store(&runtime_stores) { - Ok(settings) => { + let pull_sync = match (ec_state, settings) { + (Some(mut ec_state), Some(settings)) => { match futures::executor::block_on(apply_edgezero_ec_finalize( &settings, &mut ec_state, - &mut response, + response, )) { - Ok(partner_registry) => { - send_edgezero_response( - response, - request_filter_effects.as_ref(), - counters.as_ref(), - ); - futures::executor::block_on(run_edgezero_pull_sync_after_send( - &settings, - &partner_registry, - &ec_state, - )); - return; - } - Err(e) => { - log::error!( - "EdgeZero EC finalize skipped: failed to build partner registry: {e:?}" - ); + Ok(registry) => Some((settings, registry, ec_state)), + Err(error) => { + log::error!("EdgeZero EC finalization failed: {error:?}"); + None } } } - Err(e) => { - log::warn!("EdgeZero EC finalize skipped: failed to reload settings: {e:?}"); - } + _ => None, + }; + apply_terminal_response_effects(response, request_filter_effects.as_ref()); + if let Some(counters) = counters.as_ref() { + attach_sandbox_counters(response, counters); + } + pull_sync + }, + ); + + match delivery { + FastlyRequestDelivery::Routed { state, delivery } => { + if let Some(error) = delivery.error() { + return Err(fastly::Error::msg(error.message())); + } + if let Some((settings, registry, ec_state)) = state + && app_state + .as_ref() + .is_some_and(|state| state.delivery_observation.completed()) + { + futures::executor::block_on(run_edgezero_pull_sync_after_send( + &settings, ®istry, &ec_state, + )); + } + } + FastlyRequestDelivery::Detached(delivery) => { + if let Some(error) = delivery.error() { + return Err(fastly::Error::msg(error.message())); } } + FastlyRequestDelivery::NoAttempt(failure) => { + log::error!( + "Fastly request has no response attempt: {}", + failure.message() + ); + FastlyResponse::from_status(fastly::http::StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Internal Server Error") + .send_to_client(); + } + FastlyRequestDelivery::Aborted => { + return Err(fastly::Error::msg("ingress policy aborts the response")); + } + _ => { + return Err(fastly::Error::msg( + "unsupported Fastly delivery disposition", + )); + } } + Ok(()) +} + +/// Bounded terminal fact for the serial SDK sandbox callback. +#[derive(Clone, Default)] +pub(crate) struct DeliveryObservation(Arc); - send_edgezero_response(response, request_filter_effects.as_ref(), counters.as_ref()); +impl DeliveryObservation { + fn reset(&self) { + self.0.store(false, std::sync::atomic::Ordering::Release); + } + + fn completed(&self) -> bool { + self.0.load(std::sync::atomic::Ordering::Acquire) + } } -fn edge_error_response(error: EdgeError) -> HttpResponse { - log::error!("EdgeZero router returned error: {error:?}"); - match error.into_response() { - Ok(response) => response, - Err(error) => { - log::error!("failed to convert EdgeZero error into response: {error:?}"); - edgezero_core::http::response_builder() - .status(edgezero_core::http::StatusCode::INTERNAL_SERVER_ERROR) - .body(EdgeBody::from("Internal Server Error")) - .expect("should build EdgeZero error response") - } +impl edgezero_core::response_egress::ResponseEgressObserver for DeliveryObservation { + fn complete(&self, report: &edgezero_core::response_egress::ResponseEgressReport) { + self.0.store( + report.body_kind == ResponseEgressBodyKind::Application + && report.outcome == ResponseEgressOutcome::Completed, + std::sync::atomic::Ordering::Release, + ); } } @@ -721,74 +733,6 @@ where Some((context, kv)) } -/// Sends a finalized `EdgeZero` response to the client. -/// -/// Streaming `EdgeZero` bodies commit headers first, then pipe chunks to Fastly's -/// client stream so large asset and publisher-origin responses do not -/// materialize in the Wasm heap. -fn send_edgezero_response( - mut response: HttpResponse, - request_filter_effects: Option<&RequestFilterEffects>, - counters: Option<&SandboxCounters>, -) { - apply_terminal_response_effects(&mut response, request_filter_effects); - - send_response_to_client(response, counters); -} - -/// Sends a response, attaching sandbox counters first when supplied. -fn send_response_to_client(mut response: HttpResponse, counters: Option<&SandboxCounters>) { - // Captured before the body is consumed so post-commitment failures can be - // matched back to the response that carried these counters. - let counter_context = counters.map_or_else(String::new, |counters| { - format!( - " [instance={} ordinal={} request={}]", - instance_id(), - counters.ordinal, - counters.request_id - ) - }); - - // Before headers commit, including before `stream_to_client` below. - if let Some(counters) = counters.as_ref() { - attach_sandbox_counters(&mut response, counters); - } - - let (parts, body) = response.into_parts(); - - match body { - EdgeBody::Stream(_) => { - let skeleton = compat::to_fastly_response_skeleton(HttpResponse::from_parts( - parts, - EdgeBody::empty(), - )); - let mut streaming_body = skeleton.stream_to_client(); - match futures::executor::block_on(stream_asset_body(body, &mut streaming_body)) { - Ok(()) => { - if let Err(e) = streaming_body.finish() { - // Also post-commitment: same attribution as the - // streaming failure below. - log::error!( - "failed to finish EdgeZero streaming body{counter_context}: {e}" - ); - } - } - Err(e) => { - // After commitment: log and stop. Returning an error here - // would let the SDK attempt a second response. Counters - // already went out with the headers, so the failure is - // tagged with the same identity for reconciliation. - log::error!("EdgeZero streaming failed{counter_context}: {e:?}"); - drop(streaming_body); - } - } - } - once => { - compat::to_fastly_response(HttpResponse::from_parts(parts, once)).send_to_client(); - } - } -} - /// Apply every late response mutation, then restore privacy invariants before headers commit. fn apply_terminal_response_effects( response: &mut HttpResponse, @@ -967,7 +911,7 @@ mod tests { .expect("should configure trace"); settings.handlers.insert(0,serde_json::from_value(serde_json::json!({"path":"^/_ts/trace","username":"example-user","password":"example-password"})) .expect("should configure trace protection")); - let mut request = edgezero_core::http::request_builder() + let request = edgezero_core::http::request_builder() .uri("https://publisher.example.com/_ts/trace/assets/v1.js") .header( "authorization", @@ -979,8 +923,9 @@ mod tests { ) .body(EdgeBody::empty()) .expect("should build protected asset request"); + let (mut request_head, _) = request.into_parts(); let trusted_server_core::trace::TracePreflight::Ready(dispatch) = - trusted_server_core::trace::preflight(&settings, &mut request) + trusted_server_core::trace::preflight(&settings, &mut request_head) else { panic!("should accept protected fixed asset"); }; @@ -1002,20 +947,10 @@ mod tests { &original, "should bypass every ordinary trace finalizer and preserve protected digest" ); - let native = compat::to_fastly_response(response); - assert_eq!( - native.get_header("etag").map(HeaderValue::as_bytes), - original.get("etag").map(HeaderValue::as_bytes), - "should preserve strong ETag through native conversion" - ); - assert!( - native.get_header("set-cookie").is_none(), - "should never acquire an operator cookie" - ); } #[test] - fn trace_dispatch_terminal_actions_errors_and_head_preserve_native_contract() { + fn trace_dispatch_terminal_actions_errors_and_head_preserve_terminal_contract() { let mut settings = test_settings(); settings .integrations @@ -1121,10 +1056,12 @@ mod tests { )); } let mut response = futures::executor::block_on( - edgezero_core::router::PreDispatchHook::handle(&hook, &mut request), + edgezero_core::router::RouterService::builder() + .pre_dispatch_hook(hook.clone()) + .build() + .oneshot(request), ) - .expect("should build local trace response") - .expect("should intercept reserved request"); + .expect("should intercept reserved request through the protected router"); assert_eq!( response.status(), expected, @@ -1138,26 +1075,20 @@ mod tests { &original, "should bypass hostile ordinary finalizers for every terminal response" ); - let mut native = compat::to_fastly_response(response); - for (name, value) in &original { - assert_eq!( - native.get_header(name.as_str()).map(HeaderValue::as_bytes), - Some(value.as_bytes()), - "should preserve complete trace headers through native conversion" - ); - } assert_eq!( - native - .get_headers() - .filter(|(name, _)| name.as_str() == "set-cookie") + response + .headers() + .get_all(edgezero_core::http::header::SET_COOKIE) + .iter() .count(), usize::from(action.is_some()), "should emit only one deliberate action cookie and none on reads or errors" ); if head { - assert!( - native.take_body_bytes().is_empty(), - "should preserve bodyless trace HEAD through native conversion" + assert_eq!( + response.body().as_bytes(), + Some(b"".as_slice()), + "should retain a bodyless trace HEAD" ); } } @@ -1204,7 +1135,8 @@ mod tests { ] { let req = FastlyRequest::get(url); let native = is_trace_path(req.get_path()); - let core = into_core_request(req).expect("should convert the native request"); + let core = edgezero_adapter_fastly::request::into_core_request(req) + .expect("should convert the native request"); assert_eq!( is_trace_path(core.uri().path()), diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 9bbc25d7ba..5f77c9e82a 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -19,7 +19,7 @@ use edgezero_core::http::{HeaderValue, Response, StatusCode}; use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::response::IntoResponse; use std::net::IpAddr; -use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::auth::enforce_basic_auth_parts; use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_FASTLY_VERSION, @@ -70,8 +70,12 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let client_ip = ctx.request().extensions().get::().map_or_else( - || FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip), + let client_ip = ctx.extensions().get::().map_or_else( + || { + ctx.extensions() + .get::() + .and_then(|c| c.client_ip) + }, |info| info.client_ip, ); @@ -131,7 +135,7 @@ impl AuthMiddleware { #[async_trait(?Send)] impl Middleware for AuthMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { + match enforce_basic_auth_parts(&self.settings, ctx.parts_mut()) { Ok(Some(response)) => return Ok(response), Ok(None) => {} Err(report) => { @@ -591,16 +595,13 @@ mod tests { }), ); let mut ctx = empty_ctx(); - ctx.request_mut().extensions_mut().insert(ClientInfo { + ctx.extensions_mut().insert(ClientInfo { client_ip: Some(reader_ip), ..ClientInfo::default() }); - FastlyRequestContext::insert( - ctx.request_mut(), - FastlyRequestContext { - client_ip: Some(peer_ip), - }, - ); + ctx.extensions_mut().insert(FastlyRequestContext { + client_ip: Some(peer_ip), + }); let handler = Arc::new( |_ctx: RequestContext| async move { Ok::(empty_response()) }, @@ -627,15 +628,10 @@ mod tests { }), ); let mut ctx = empty_ctx(); - ctx.request_mut() - .extensions_mut() - .insert(ClientInfo::default()); - FastlyRequestContext::insert( - ctx.request_mut(), - FastlyRequestContext { - client_ip: Some(peer_ip), - }, - ); + ctx.extensions_mut().insert(ClientInfo::default()); + ctx.extensions_mut().insert(FastlyRequestContext { + client_ip: Some(peer_ip), + }); let handler = Arc::new( |_ctx: RequestContext| async move { Ok::(empty_response()) }, @@ -662,12 +658,9 @@ mod tests { }), ); let mut ctx = empty_ctx(); - FastlyRequestContext::insert( - ctx.request_mut(), - FastlyRequestContext { - client_ip: Some(peer_ip), - }, - ); + ctx.extensions_mut().insert(FastlyRequestContext { + client_ip: Some(peer_ip), + }); let handler = Arc::new( |_ctx: RequestContext| async move { Ok::(empty_response()) }, diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index ea2193d090..ed1938063b 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -1,40 +1,22 @@ //! Fastly-backed implementations of the platform traits defined in //! `trusted-server-core::platform`. -mod deadline; +#[cfg(test)] +mod outbound_tests; -use bytes::Bytes; use error_stack::{Report, ResultExt}; use fastly::geo::{Geo, geo_lookup}; use fastly::{ConfigStore, Request, SecretStore}; -use std::io::Read as _; use std::net::IpAddr; use crate::backend::BackendConfig; pub(crate) use trusted_server_core::platform::UnavailableKvStore; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, - PlatformCacheIntent, PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, - PlatformHttpRequest, PlatformImageOptimizerCrop, PlatformImageOptimizerCropMode, - PlatformImageOptimizerOptions, PlatformImageOptimizerParams, PlatformImageOptimizerRegion, - PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, - StoreName, + PlatformConfigStore, PlatformError, PlatformGeo, PlatformSecretStore, StoreId, StoreName, }; use trusted_server_core::settings::TrustedClientIpConfig; -// Cache-pass requests always have a direct SDK pending handle. Keep this proof -// with the pending request until the deadline collector consumes it. -struct CacheBypassPending(fastly::http::request::PendingRequest); - -fn unwrap_pending( - pending: PlatformPendingRequest, -) -> Result { - match pending.downcast::() { - Ok(pending) => Ok(pending.0), - Err(pending) => pending.downcast(), - } -} - // --------------------------------------------------------------------------- // FastlyPlatformConfigStore // --------------------------------------------------------------------------- @@ -198,504 +180,6 @@ impl PlatformBackend for FastlyPlatformBackend { } } -fn buffered_response_to_platform( - response: &fastly::Response, - backend_name: String, - bytes: Vec, -) -> Result> { - let mut builder = edgezero_core::http::response_builder().status(response.get_status()); - for (name, value) in response.get_headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - let response = builder - .body(edgezero_core::body::Body::from(bytes)) - .change_context(PlatformError::HttpClient)?; - Ok(PlatformResponse::new(response).with_backend_name(backend_name)) -} - -// --------------------------------------------------------------------------- -// FastlyPlatformHttpClient — helpers -// --------------------------------------------------------------------------- - -fn fastly_image_optimizer_region( - region: &str, -) -> Result> { - use fastly::image_optimizer::ImageOptimizerRegion; - - match PlatformImageOptimizerRegion::parse(region) { - Some(PlatformImageOptimizerRegion::UsEast) => Ok(ImageOptimizerRegion::UsEast), - Some(PlatformImageOptimizerRegion::UsCentral) => Ok(ImageOptimizerRegion::UsCentral), - Some(PlatformImageOptimizerRegion::UsWest) => Ok(ImageOptimizerRegion::UsWest), - Some(PlatformImageOptimizerRegion::EuCentral) => Ok(ImageOptimizerRegion::EuCentral), - Some(PlatformImageOptimizerRegion::EuWest) => Ok(ImageOptimizerRegion::EuWest), - Some(PlatformImageOptimizerRegion::Asia) => Ok(ImageOptimizerRegion::Asia), - Some(PlatformImageOptimizerRegion::Australia) => Ok(ImageOptimizerRegion::Australia), - None => Err(Report::new(PlatformError::HttpClient) - .attach(format!("unsupported Image Optimizer region: {region}"))), - } -} - -fn fastly_image_optimizer_format( - format: &str, -) -> Result> { - use fastly::image_optimizer::Format; - - match format.trim().to_ascii_lowercase().as_str() { - "auto" => Ok(Format::Auto), - "avif" => Ok(Format::AVIF), - "gif" => Ok(Format::GIF), - "jpeg" | "jpg" => Ok(Format::JPEG), - "jxl" | "jpegxl" => Ok(Format::JPEGXL), - "mp4" => Ok(Format::MP4), - "png" => Ok(Format::PNG), - "webp" => Ok(Format::WebP), - other => Err(Report::new(PlatformError::HttpClient) - .attach(format!("unsupported Image Optimizer format: {other}"))), - } -} - -fn fastly_resize_filter( - resize_filter: &str, -) -> Result> { - use fastly::image_optimizer::ResizeAlgorithm; - - match resize_filter.trim().to_ascii_lowercase().as_str() { - "nearest" => Ok(ResizeAlgorithm::Nearest), - "bilinear" => Ok(ResizeAlgorithm::Bilinear), - "bicubic" => Ok(ResizeAlgorithm::Bicubic), - "lanczos2" => Ok(ResizeAlgorithm::Lanczos2), - "lanczos3" => Ok(ResizeAlgorithm::Lanczos3), - other => Err(Report::new(PlatformError::HttpClient).attach(format!( - "unsupported Image Optimizer resize filter: {other}" - ))), - } -} - -fn fastly_crop(crop: &PlatformImageOptimizerCrop) -> fastly::image_optimizer::Crop { - use fastly::image_optimizer::{Area, Crop, CropMode, PointOrOffset, Position}; - - let position = match (crop.offset_x, crop.offset_y) { - (Some(x), Some(y)) => Some(Position { - x: Some(PointOrOffset::Offset(x)), - y: Some(PointOrOffset::Offset(y)), - }), - _ => None, - }; - let mode = crop - .mode - .map(|PlatformImageOptimizerCropMode::Smart| CropMode::Smart); - - Crop { - size: Area::AspectRatio((crop.width, crop.height)), - position, - mode, - } -} - -fn apply_fastly_image_optimizer_params( - target: &mut fastly::image_optimizer::ImageOptimizerOptions, - params: PlatformImageOptimizerParams, -) -> Result<(), Report> { - use fastly::image_optimizer::PixelsOrPercentage; - - if let Some(format) = params.format { - target.format = Some(fastly_image_optimizer_format(&format)?); - } - if let Some(quality) = params.quality { - target.quality = Some(quality); - } - if let Some(resize_filter) = params.resize_filter { - target.resize_filter = Some(fastly_resize_filter(&resize_filter)?); - } - if let Some(width) = params.width { - target.width = Some(PixelsOrPercentage::Pixels(width)); - } - if let Some(height) = params.height { - target.height = Some(PixelsOrPercentage::Pixels(height)); - } - if let Some(crop) = params.crop { - target.crop = Some(fastly_crop(&crop)); - } - - Ok(()) -} - -fn apply_fastly_image_optimizer( - req: &mut fastly::Request, - options: PlatformImageOptimizerOptions, -) -> Result<(), Report> { - let region = fastly_image_optimizer_region(&options.region)?; - let mut fastly_options = fastly::image_optimizer::ImageOptimizerOptions::from_region(region); - fastly_options.preserve_query_string_on_origin_request = - Some(options.preserve_query_string_on_origin_request); - apply_fastly_image_optimizer_params(&mut fastly_options, options.params)?; - req.set_image_optimizer(fastly_options); - Ok(()) -} - -/// Convert a platform-neutral [`edgezero_core::http::Request`] to a [`fastly::Request`]. -/// -/// Only buffered `Body::Once` bodies are supported on this path. -/// -/// # Errors -/// -/// Returns [`PlatformError::HttpClient`] when the request body is streaming. -fn edge_request_to_fastly( - request: edgezero_core::http::Request, -) -> Result> { - let (parts, body) = request.into_parts(); - let mut fastly_req = fastly::Request::new(parts.method, parts.uri.to_string()); - for (name, value) in parts.headers.iter() { - // `fastly::Request::new` derives a Host header from the request URI, so - // appending the edge request's own Host would leave a duplicate. Replace - // it instead to keep the in-memory request well-formed. The Host actually - // sent on the wire is still governed by the backend's `override_host` - // (see `BackendConfig::ensure`), which forces the value regardless. - if name == edgezero_core::http::header::HOST { - fastly_req.set_header(name.as_str(), value.as_bytes()); - } else { - fastly_req.append_header(name.as_str(), value.as_bytes()); - } - } - match body { - edgezero_core::body::Body::Once(bytes) => { - if !bytes.is_empty() { - fastly_req.set_body(bytes.to_vec()); - } - } - edgezero_core::body::Body::Stream(_) => { - return Err(Report::new(PlatformError::HttpClient) - .attach("streaming request body is not supported by Fastly request conversion")); - } - } - Ok(fastly_req) -} - -/// Maximum origin response body size copied into WASM heap. -/// -/// `take_body_bytes()` copies the full origin response into a single -/// allocation. This cap prevents oversized origin responses from exhausting -/// the WASM address space. The Content-Length pre-check avoids the copy -/// entirely for responses that declare their size. -const MAX_PLATFORM_RESPONSE_BODY_BYTES: usize = 10 * 1024 * 1024; // 10 MiB - -fn fastly_body_to_edge_stream(body: fastly::Body) -> edgezero_core::body::Body { - let stream = futures::stream::unfold(Some(body), |state| async move { - let mut body = state?; - let mut chunk = vec![0; 8192]; - match body.read(&mut chunk) { - Ok(0) => None, - Ok(bytes_read) => { - chunk.truncate(bytes_read); - Some((Ok(Bytes::from(chunk)), Some(body))) - } - Err(err) => Some((Err(err), None)), - } - }); - - edgezero_core::body::Body::from_stream(stream) -} - -/// Return whether a response is allowed to carry a body. -/// -/// `HEAD` responses and informational, 204, 205, and 304 statuses carry no -/// content. In particular, HEAD and 304 may legitimately retain a -/// `Content-Length` for the corresponding GET representation. -fn response_carries_body(request_is_head: bool, status: fastly::http::StatusCode) -> bool { - !request_is_head - && !status.is_informational() - && status != fastly::http::StatusCode::NO_CONTENT - && status != fastly::http::StatusCode::RESET_CONTENT - && status != fastly::http::StatusCode::NOT_MODIFIED -} - -/// Convert a [`fastly::Response`] to a [`PlatformResponse`] with the given backend name. -fn fastly_response_to_platform( - mut resp: fastly::Response, - backend_name: impl Into, - stream_response: bool, - request_is_head: bool, -) -> Result> { - let status = resp.get_status(); - let response_body_expected = response_carries_body(request_is_head, status); - - // Pre-flight: reject oversized responses before copying bytes into WASM heap. - // Content-Length is advisory but covers most origin responses; chunked - // responses without it fall through to the post-materialization check below. - if response_body_expected - && !stream_response - && let Some(claimed_len) = resp - .get_header("content-length") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.trim().parse::().ok()) - && claimed_len > MAX_PLATFORM_RESPONSE_BODY_BYTES - { - return Err(Report::new(PlatformError::HttpClient).attach(format!( - "origin Content-Length {claimed_len} exceeds \ - {MAX_PLATFORM_RESPONSE_BODY_BYTES}-byte response body limit" - ))); - } - - let mut builder = edgezero_core::http::response_builder().status(status); - for (name, value) in resp.get_headers() { - builder = builder.header(name.as_str(), value.as_bytes()); - } - let body = if !response_body_expected { - edgezero_core::body::Body::empty() - } else if stream_response { - fastly_body_to_edge_stream(resp.take_body()) - } else { - let body_bytes = resp.take_body_bytes(); - - // Belt-and-suspenders: catches chunked responses without Content-Length. - if body_bytes.len() > MAX_PLATFORM_RESPONSE_BODY_BYTES { - return Err(Report::new(PlatformError::HttpClient).attach(format!( - "origin response body {} bytes exceeds \ - {MAX_PLATFORM_RESPONSE_BODY_BYTES}-byte limit", - body_bytes.len() - ))); - } - - edgezero_core::body::Body::from(body_bytes) - }; - let edge_response = builder - .body(body) - .change_context(PlatformError::HttpClient)?; - Ok(PlatformResponse::new(edge_response).with_backend_name(backend_name)) -} - -// --------------------------------------------------------------------------- -// FastlyPlatformHttpClient -// --------------------------------------------------------------------------- - -/// Apply the caller's cache intent to a Fastly request. -/// -/// The two branches are mutually exclusive by construction, which is the point of -/// [`PlatformCacheIntent`]: `set_surrogate_key` "overrides any previous -/// `Request::set_pass` call" (`fastly-0.12.1/src/http/request.rs:2462`), so calling both -/// would silently cancel the bypass. -/// -/// Readthrough is enabled by *omitting* `set_pass`, never by adding a TTL. `set_ttl` -/// carries the same override note and additionally overrides the origin's own -/// `Cache-Control`, including `private` and `no-store` — it would turn the hazard this -/// gate exists to avoid into an API. -fn apply_fastly_cache_intent(request: &mut fastly::Request, intent: &PlatformCacheIntent) { - match intent { - PlatformCacheIntent::Bypass => request.set_pass(true), - PlatformCacheIntent::Shared { surrogate_key } => { - match fastly::http::HeaderValue::from_str(surrogate_key) { - Ok(value) => request.set_surrogate_key(value), - Err(error) => { - // Fail closed. Caching without the key would store an object no purge - // can reach, which is worse than not caching it: the whole rollback - // story for readthrough is "purge the key". - log::error!( - "Surrogate key {surrogate_key:?} is not a valid header value \ - ({error}); bypassing the cache rather than storing an \ - unpurgeable object" - ); - request.set_pass(true); - } - } - } - PlatformCacheIntent::Default => {} - } -} - -/// Fastly implementation of [`PlatformHttpClient`]. -/// -/// - [`send`](PlatformHttpClient::send) converts the platform request to a -/// `fastly::Request`, applies Image Optimizer metadata when present, calls -/// `.send()`, and wraps the response. Asset requests can ask to preserve the -/// response body as a stream instead of buffering it into a single `Vec`. -/// - [`send_async`](PlatformHttpClient::send_async) converts the request and -/// calls `.send_async()`. It rejects Image Optimizer metadata because Fastly's -/// async request path does not expose the IO attachment used by asset routes. -/// - [`select`](PlatformHttpClient::select) downcasts each -/// [`PlatformPendingRequest`] back to `fastly::PendingRequest` and calls -/// `fastly::http::request::select()`. -/// -/// Fastly's Compute HTTP API sends one request to the named backend and returns -/// the origin response; it has no client-side redirect-follow mode. Consequently -/// each trait call below performs exactly one underlying `.send()` or -/// `.send_async()`, and an original 3xx remains visible to core. The host test -/// environment cannot register a real Fastly backend, so the common -/// `StubHttpClient` driver test records the one-send 3xx behavior while adapter -/// tests cover request conversion and the single-send boundary. -/// -/// Deadline collection requires [`PlatformCacheIntent::Bypass`], which all -/// auction requests use. This guarantees a direct SDK pending handle that can -/// join body handles in one timed host wait. Shared-cache requests retain the -/// SDK caching state machine through the unbounded send/select/wait APIs. -pub struct FastlyPlatformHttpClient; - -#[async_trait::async_trait(?Send)] -impl PlatformHttpClient for FastlyPlatformHttpClient { - fn supports_streaming_responses(&self) -> bool { - true - } - - fn supports_pending_streaming_responses(&self) -> bool { - true - } - - async fn send( - &self, - request: PlatformHttpRequest, - ) -> Result> { - let backend_name = request.backend_name.clone(); - let image_optimizer = request.image_optimizer; - let stream_response = request.stream_response; - let cache_intent = request.cache_intent.clone(); - let request_is_head = request.request.method() == edgezero_core::http::Method::HEAD; - let mut fastly_req = edge_request_to_fastly(request.request)?; - if let Some(options) = image_optimizer { - apply_fastly_image_optimizer(&mut fastly_req, options)?; - } - apply_fastly_cache_intent(&mut fastly_req, &cache_intent); - let fastly_resp = fastly_req - .send(&backend_name) - .change_context(PlatformError::HttpClient)?; - fastly_response_to_platform(fastly_resp, backend_name, stream_response, request_is_head) - } - - async fn send_async( - &self, - request: PlatformHttpRequest, - ) -> Result> { - let backend_name = request.backend_name.clone(); - if request.image_optimizer.is_some() { - return Err(Report::new(PlatformError::HttpClient) - .attach("Image Optimizer is not supported with Fastly send_async")); - } - let stream_response = request.stream_response; - let request_method = request.request.method().clone(); - let cache_intent = request.cache_intent.clone(); - let mut fastly_req = edge_request_to_fastly(request.request)?; - apply_fastly_cache_intent(&mut fastly_req, &cache_intent); - let pending = fastly_req - .send_async(&backend_name) - .change_context(PlatformError::HttpClient)?; - let pending = if cache_intent.is_bypass() { - PlatformPendingRequest::new(CacheBypassPending(pending)) - } else { - PlatformPendingRequest::new(pending) - }; - Ok(pending - .with_backend_name(backend_name) - .with_response_handling(stream_response, request_method)) - } - - async fn select( - &self, - pending_requests: Vec, - ) -> Result> { - use fastly::http::request::{PendingRequest, select}; - - if pending_requests.is_empty() { - return Err(Report::new(PlatformError::HttpClient) - .attach("select called with an empty pending_requests list")); - } - - if pending_requests - .iter() - .any(PlatformPendingRequest::stream_response) - { - return Err(Report::new(PlatformError::HttpClient) - .attach("stream-marked pending request requires direct wait")); - } - - let mut fastly_pending: Vec = Vec::with_capacity(pending_requests.len()); - - for platform_req in pending_requests { - let inner = unwrap_pending(platform_req).map_err(|platform_req| { - let backend_name = platform_req.backend_name().unwrap_or(""); - Report::new(PlatformError::HttpClient).attach(format!( - "PlatformPendingRequest inner type is not fastly::PendingRequest for backend '{backend_name}'" - )) - })?; - fastly_pending.push(inner); - } - - let (result, remaining_fastly) = select(fastly_pending); - - // Fastly's select() does not preserve input order for remaining requests, - // so positional backend-name re-association is unreliable. Backend names - // are re-derived from get_backend_name() when each remaining request completes. - let remaining: Vec = remaining_fastly - .into_iter() - .map(PlatformPendingRequest::new) - .collect(); - - let (ready, failed_backend_name) = match result { - Ok(fastly_resp) => { - let Some(backend_name) = fastly_resp.get_backend_name().map(str::to_string) else { - return Err(Report::new(PlatformError::HttpClient) - .attach("select: response has no backend name; correlation impossible")); - }; - let Some(backend_request) = fastly_resp.get_backend_request() else { - return Err(Report::new(PlatformError::HttpClient).attach( - "select: response has no originating request; body semantics unknown", - )); - }; - let request_is_head = backend_request.get_method() == fastly::http::Method::HEAD; - ( - fastly_response_to_platform(fastly_resp, backend_name, false, request_is_head), - None, - ) - } - Err(e) => { - let failed_name = e.backend_name().to_string(); - ( - Err(Report::new(PlatformError::HttpClient).attach(format!( - "fastly select error for backend '{failed_name}': {e}" - ))), - Some(failed_name), - ) - } - }; - - Ok(PlatformSelectResult { - ready, - remaining, - failed_backend_name, - }) - } - - async fn select_until( - &self, - pending_requests: Vec, - deadline: web_time::Instant, - ) -> Result, Report> { - deadline::select_until(pending_requests, deadline) - } - - async fn wait( - &self, - pending: PlatformPendingRequest, - ) -> Result> { - let backend_hint = pending.backend_name().map(str::to_owned); - let stream_response = pending.stream_response(); - let request_is_head = pending.request_method() == Some(&edgezero_core::http::Method::HEAD); - let pending = unwrap_pending(pending).map_err(|pending| { - let backend_name = pending.backend_name().unwrap_or(""); - Report::new(PlatformError::HttpClient).attach(format!( - "PlatformPendingRequest inner type is not fastly::PendingRequest for backend '{backend_name}'" - )) - })?; - let response = pending.wait().change_context(PlatformError::HttpClient)?; - let backend_name = response - .get_backend_name() - .map(str::to_owned) - .or(backend_hint) - .ok_or_else(|| { - Report::new(PlatformError::HttpClient) - .attach("wait: response has no backend name; correlation impossible") - })?; - fastly_response_to_platform(response, backend_name, stream_response, request_is_head) - } -} - // --------------------------------------------------------------------------- // FastlyPlatformGeo // --------------------------------------------------------------------------- @@ -805,12 +289,9 @@ pub fn client_info_from_request(req: &Request, client_ip: Option) -> Cli #[cfg(test)] mod tests { - use std::io; use std::time::Duration; use super::*; - use edgezero_core::body::Body; - use edgezero_core::http::request_builder; use fastly::http::HeaderValue; use trusted_server_core::redacted::Redacted; use trusted_server_core::settings::TrustedClientIpConfig; @@ -990,62 +471,6 @@ mod tests { ); } - #[test] - fn edge_request_to_fastly_replaces_url_derived_host_header() { - let request = request_builder() - .method("GET") - .uri("https://origin.example.com/") - .header(edgezero_core::http::header::HOST, "www.example.com") - .body(Body::empty()) - .expect("should build request"); - - let fastly_req = edge_request_to_fastly(request).expect("should convert request"); - - assert_eq!( - fastly_req.get_header_str(fastly::http::header::HOST), - Some("www.example.com"), - "should replace the URL-derived Host instead of appending a duplicate" - ); - } - - #[test] - fn edge_request_to_fastly_preserves_query_encoding_order_and_duplicates() { - let expected_query = "space=a+b&plus=%2B"e=%27&empty=&x=1&x=2&country=US®ion=CA"; - let request = request_builder() - .method("GET") - .uri(format!( - "https://sdk.example.com/key/loader.js?{expected_query}" - )) - .body(Body::empty()) - .expect("should build request with encoded query"); - - let fastly_req = edge_request_to_fastly(request).expect("should convert request"); - - assert_eq!( - fastly_req.get_url().query(), - Some(expected_query), - "should preserve query encoding and order across Fastly conversion" - ); - assert_eq!( - fastly_req - .get_url() - .query_pairs() - .filter(|(name, _)| name.eq_ignore_ascii_case("country")) - .count(), - 1, - "should retain exactly one country pair" - ); - assert_eq!( - fastly_req - .get_url() - .query_pairs() - .filter(|(name, _)| name.eq_ignore_ascii_case("region")) - .count(), - 1, - "should retain exactly one region pair" - ); - } - // --- FastlyPlatformBackend::predict_name -------------------------------- #[test] @@ -1227,362 +652,6 @@ mod tests { assert_eq!(predicted, ensured); } - // --- FastlyPlatformHttpClient ------------------------------------------- - - #[test] - fn auction_http_capabilities_are_explicit() { - let client = FastlyPlatformHttpClient; - let capabilities = trusted_server_core::platform::AuctionTargetId::Fastly - .descriptor() - .capabilities(); - assert!(client.supports_concurrent_fanout()); - assert!(capabilities.supports_concurrent_provider_fanout()); - } - - #[test] - fn response_conversion_preserves_original_redirect_at_single_send_boundary() { - let mut response = fastly::Response::from_status(fastly::http::StatusCode::FOUND); - response.set_header("location", "https://redirect.example/next"); - - let platform = fastly_response_to_platform(response, "origin", false, false) - .expect("should convert redirect response"); - - assert_eq!(platform.response.status().as_u16(), 302); - assert_eq!( - platform - .response - .headers() - .get("location") - .and_then(|value| value.to_str().ok()), - Some("https://redirect.example/next") - ); - } - - #[test] - fn apply_fastly_cache_intent_sets_pass_for_bypass() { - let mut request = fastly::Request::get("https://example.com/"); - apply_fastly_cache_intent(&mut request, &PlatformCacheIntent::Bypass); - assert!( - format!("{request:?}").contains("cache_override: Pass"), - "bypass should select Fastly pass mode" - ); - } - - #[test] - fn apply_fastly_cache_intent_leaves_default_alone() { - let mut request = fastly::Request::get("https://example.com/"); - apply_fastly_cache_intent(&mut request, &PlatformCacheIntent::Default); - assert!( - format!("{request:?}").contains("cache_override: None"), - "the default intent should preserve Fastly read-through caching" - ); - } - - #[test] - fn apply_fastly_cache_intent_never_passes_on_the_shared_branch() { - // Readthrough is enabled by *omitting* set_pass. If this branch ever set pass as - // well, the surrogate key would reverse it — and the resulting behavior would - // depend on call order rather than on what the code says. - let mut request = fastly::Request::get("https://example.com/"); - apply_fastly_cache_intent( - &mut request, - &PlatformCacheIntent::Shared { - surrogate_key: "ts-origin".to_owned(), - }, - ); - let rendered = format!("{request:?}"); - assert!( - !rendered.contains("cache_override: Pass"), - "the shared branch must not bypass, got: {rendered}" - ); - assert!( - rendered.contains("ts-origin"), - "the shared branch must attach the surrogate key, got: {rendered}" - ); - } - - #[test] - fn a_surrogate_key_that_cannot_be_a_header_value_falls_back_to_bypass() { - // Fail closed: caching without the key would store an object no purge can reach, - // and "purge the key" is the entire rollback story for readthrough. - let mut request = fastly::Request::get("https://example.com/"); - apply_fastly_cache_intent( - &mut request, - &PlatformCacheIntent::Shared { - surrogate_key: "bad\nkey".to_owned(), - }, - ); - assert!( - format!("{request:?}").contains("cache_override: Pass"), - "an unusable key must bypass rather than store something unpurgeable" - ); - } - - #[test] - fn fastly_response_to_platform_allows_oversized_bodiless_content_length() { - let oversized_content_length = (MAX_PLATFORM_RESPONSE_BODY_BYTES + 1).to_string(); - for (request_is_head, status) in [ - (true, fastly::http::StatusCode::OK), - (false, fastly::http::StatusCode::CONTINUE), - (false, fastly::http::StatusCode::NO_CONTENT), - (false, fastly::http::StatusCode::RESET_CONTENT), - (false, fastly::http::StatusCode::NOT_MODIFIED), - ] { - let mut fastly_response = fastly::Response::from_status(status); - fastly_response.set_header( - fastly::http::header::CONTENT_LENGTH, - oversized_content_length.as_str(), - ); - - let platform_response = - fastly_response_to_platform(fastly_response, "origin", false, request_is_head) - .expect("should allow oversized metadata for a bodiless response"); - - assert_eq!( - platform_response - .response - .headers() - .get(edgezero_core::http::header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()), - Some(oversized_content_length.as_str()), - "should preserve the origin Content-Length" - ); - let body = platform_response - .response - .into_body() - .into_bytes() - .expect("should return a buffered body"); - assert!(body.is_empty(), "should return an empty bodiless response"); - } - } - - #[test] - fn fastly_response_to_platform_rejects_oversized_buffered_get_content_length() { - let mut fastly_response = fastly::Response::from_status(200); - fastly_response.set_header( - fastly::http::header::CONTENT_LENGTH, - (MAX_PLATFORM_RESPONSE_BODY_BYTES + 1).to_string(), - ); - - let error = fastly_response_to_platform(fastly_response, "origin", false, false) - .expect_err("should reject oversized buffered GET metadata"); - let expected_error = - format!("exceeds {MAX_PLATFORM_RESPONSE_BODY_BYTES}-byte response body limit"); - - assert!( - format!("{error:?}").contains(&expected_error), - "should retain the buffered response size limit: {error:?}" - ); - } - - #[test] - fn response_carries_body_rejects_all_informational_statuses() { - let status = fastly::http::StatusCode::from_u16(199) - .expect("should construct an informational status code"); - - assert!( - !response_carries_body(false, status), - "should reject every informational response body" - ); - assert!( - !response_carries_body(true, fastly::http::StatusCode::OK), - "should reject HEAD response bodies" - ); - assert!( - response_carries_body(false, fastly::http::StatusCode::OK), - "should retain ordinary GET response bodies" - ); - } - - #[test] - fn fastly_platform_http_client_send_returns_error_for_unregistered_backend() { - let client = FastlyPlatformHttpClient; - let request = request_builder() - .method("GET") - .uri("https://example.com/") - .body(Body::empty()) - .expect("should build test request"); - let err = futures::executor::block_on( - client.send(PlatformHttpRequest::new(request, "nonexistent-backend")), - ) - .expect_err("should return error for unregistered backend"); - - assert!( - matches!(err.current_context(), &PlatformError::HttpClient), - "should be HttpClient error, got: {:?}", - err.current_context() - ); - } - - #[test] - fn fastly_platform_http_client_send_async_returns_error_for_unregistered_backend() { - let client = FastlyPlatformHttpClient; - let request = request_builder() - .method("GET") - .uri("https://example.com/") - .body(Body::empty()) - .expect("should build test request"); - let err = futures::executor::block_on( - client.send_async(PlatformHttpRequest::new(request, "nonexistent-backend")), - ) - .expect_err("should return error for unregistered backend"); - - assert!( - matches!(err.current_context(), &PlatformError::HttpClient), - "should be HttpClient error, got: {:?}", - err.current_context() - ); - } - - #[test] - fn fastly_platform_http_client_select_returns_error_for_empty_list() { - let client = FastlyPlatformHttpClient; - let err = futures::executor::block_on(client.select(vec![])) - .expect_err("should return error for empty pending list"); - - assert!( - matches!(err.current_context(), &PlatformError::HttpClient), - "should be HttpClient error, got: {:?}", - err.current_context() - ); - } - - #[test] - fn fastly_platform_http_client_select_returns_error_for_wrong_inner_type() { - let client = FastlyPlatformHttpClient; - // Wrap a non-PendingRequest type to trigger the downcast failure. - let wrong = PlatformPendingRequest::new(42u32).with_backend_name("origin-a"); - let err = futures::executor::block_on(client.select(vec![wrong])) - .expect_err("should return error for wrong inner type"); - - assert!( - matches!(err.current_context(), &PlatformError::HttpClient), - "should be HttpClient error, got: {:?}", - err.current_context() - ); - assert!( - format!("{err:?}").contains("origin-a"), - "should include backend name in error report: {err:?}" - ); - } - - #[test] - fn fastly_platform_http_client_rejects_stream_marked_pending_from_select() { - let client = FastlyPlatformHttpClient; - let pending = PlatformPendingRequest::new(42_u32) - .with_backend_name("origin-a") - .with_response_handling(true, edgezero_core::http::Method::GET); - let err = futures::executor::block_on(client.select(vec![pending])) - .expect_err("should reject stream-marked pending handles from select"); - - assert!( - format!("{err:?}").contains("stream-marked pending request"), - "should explain that streaming pendings require direct wait: {err:?}" - ); - } - - #[test] - fn fastly_platform_http_client_send_returns_error_for_streaming_body() { - let client = FastlyPlatformHttpClient; - let request = request_builder() - .method("POST") - .uri("https://example.com/") - .body(Body::from_stream(futures::stream::empty::< - Result<_, io::Error>, - >())) - .expect("should build streaming test request"); - - let err = futures::executor::block_on( - client.send(PlatformHttpRequest::new(request, "nonexistent-backend")), - ) - .expect_err("should reject streaming request bodies before sending"); - - assert!( - matches!(err.current_context(), &PlatformError::HttpClient), - "should be HttpClient error, got: {:?}", - err.current_context() - ); - assert!( - format!("{err:?}").contains("streaming request body"), - "should describe the unsupported streaming body: {err:?}" - ); - } - - #[test] - fn fastly_platform_http_client_send_async_rejects_image_optimizer_metadata() { - let client = FastlyPlatformHttpClient; - let request = request_builder() - .method("GET") - .uri("https://example.com/image.jpg") - .body(Body::empty()) - .expect("should build test request"); - let platform_request = PlatformHttpRequest::new(request, "nonexistent-backend") - .with_image_optimizer(PlatformImageOptimizerOptions::new( - "us_east", - PlatformImageOptimizerParams::default(), - )); - - let err = futures::executor::block_on(client.send_async(platform_request)) - .expect_err("should reject async Image Optimizer requests"); - - assert!( - format!("{err:?}").contains("Image Optimizer"), - "should explain unsupported async IO path: {err:?}" - ); - } - - #[test] - fn fastly_platform_http_client_supports_pending_streaming_responses() { - let client = FastlyPlatformHttpClient; - assert!( - client.supports_pending_streaming_responses(), - "should advertise direct pending-response streaming" - ); - - let request = request_builder() - .method("GET") - .uri("https://example.com/image.jpg") - .body(Body::empty()) - .expect("should build test request"); - let platform_request = - PlatformHttpRequest::new(request, "nonexistent-backend").with_stream_response(); - - let err = futures::executor::block_on(client.send_async(platform_request)) - .expect_err("should fail only because the backend is unregistered"); - - assert!( - !format!("{err:?}").contains("streaming responses are not supported"), - "should accept streaming on the async path before backend dispatch: {err:?}" - ); - } - - #[test] - fn fastly_platform_http_client_send_async_returns_error_for_streaming_body() { - let client = FastlyPlatformHttpClient; - let request = request_builder() - .method("POST") - .uri("https://example.com/") - .body(Body::from_stream(futures::stream::empty::< - Result<_, io::Error>, - >())) - .expect("should build streaming test request"); - - let err = futures::executor::block_on( - client.send_async(PlatformHttpRequest::new(request, "nonexistent-backend")), - ) - .expect_err("should reject streaming request bodies before launching async send"); - - assert!( - matches!(err.current_context(), &PlatformError::HttpClient), - "should be HttpClient error, got: {:?}", - err.current_context() - ); - assert!( - format!("{err:?}").contains("streaming request body"), - "should describe the unsupported streaming body: {err:?}" - ); - } - // --- FastlyPlatformBackend::canonicalize_transport_timeout_ms ----------- #[test] diff --git a/crates/trusted-server-adapter-fastly/src/platform/deadline.rs b/crates/trusted-server-adapter-fastly/src/platform/deadline.rs deleted file mode 100644 index 6a8842d180..0000000000 --- a/crates/trusted-server-adapter-fastly/src/platform/deadline.rs +++ /dev/null @@ -1,947 +0,0 @@ -//! Deadline-bounded collection of Fastly auction responses. - -use error_stack::{Report, ResultExt as _}; -use fastly::handle::{BodyHandle, PendingRequestHandle, PollHandleResult}; -use std::io::Read as _; -use trusted_server_core::platform::{ - PlatformError, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, -}; -use web_time::Instant; - -use super::{ - CacheBypassPending, MAX_PLATFORM_RESPONSE_BODY_BYTES, buffered_response_to_platform, - fastly_response_to_platform, response_carries_body, -}; - -// Bound payload retained across all partial bodies, as well as each response. -const MAX_TOTAL_BUFFERED_BODY_BYTES: usize = MAX_PLATFORM_RESPONSE_BODY_BYTES; - -// Keep partially buffered responses alongside requests still awaiting headers so -// an early header with a slow body cannot hold up a completed sibling response. -struct DeadlinePending { - backend_name: String, - request_method: Option, - state: Option, -} - -enum PendingState { - Headers(Option), - Ready(Result>), - Body { - response: Box, - body: BodyHandle, - bytes: Vec, - }, -} - -impl DeadlinePending { - fn from_platform(pending: PlatformPendingRequest) -> Result> { - if pending.stream_response() { - return Err(Report::new(PlatformError::HttpClient) - .attach("stream-marked pending request requires direct wait")); - } - let request_method = pending.request_method().cloned(); - let pending = match pending.downcast::() { - Ok(mut pending) => { - pending.request_method = request_method.or(pending.request_method); - return Ok(pending); - } - Err(pending) => pending, - }; - let backend_name = pending.backend_name().unwrap_or("").to_owned(); - // The marker is constructed only after set_pass(true), which selects - // HostCaching in fastly 0.13.1's Request::get_caching_mode. Its direct - // handle conversion therefore cannot encounter a guest caching state. - let pending = pending.downcast::().map_err(|_| { - Report::new(PlatformError::Unsupported).attach(format!( - "deadline collection requires a cache-bypass request for backend '{backend_name}'" - )) - })?; - let pending = PendingRequestHandle::from(pending.0); - Ok(Self { - backend_name, - request_method, - state: Some(PendingState::Headers(Some(pending))), - }) - } - - fn into_platform(self) -> PlatformPendingRequest { - let backend_name = self.backend_name.clone(); - let request_method = self.request_method.clone(); - let pending = PlatformPendingRequest::new(self).with_backend_name(backend_name); - match request_method { - Some(method) => pending.with_response_handling(false, method), - None => pending, - } - } - - fn request_is_head(&self, response: &fastly::Response) -> Result> { - if let Some(method) = &self.request_method { - return Ok(method == edgezero_core::http::Method::HEAD); - } - response - .get_backend_request() - .map(|request| request.get_method() == fastly::http::Method::HEAD) - .ok_or_else(|| { - Report::new(PlatformError::HttpClient).attach(format!( - "deadline response for backend '{}' has no originating request or method hint", - self.backend_name - )) - }) - } - - fn buffered_bytes(&self) -> usize { - match &self.state { - Some(PendingState::Body { bytes, .. }) => bytes.len(), - Some(PendingState::Ready(Ok(response))) => { - response.response.body().as_bytes().map_or(0, <[u8]>::len) - } - _ => 0, - } - } - - fn poll(&mut self) -> Result, Report> { - let state = self - .state - .take() - .expect("should retain pending collection state"); - let state = match state { - PendingState::Ready(result) => return result.map(Some), - PendingState::Headers(mut slot) => { - match slot.take().expect("should retain a pending request").poll() { - PollHandleResult::Pending(pending) => { - slot = Some(pending); - self.state = Some(PendingState::Headers(slot)); - return Ok(None); - } - PollHandleResult::Done(Err(error)) => { - return Err( - Report::new(PlatformError::HttpClient).attach(error.to_string()) - ); - } - PollHandleResult::Done(Ok((response_handle, body_handle))) => { - let mut response = - fastly::Response::from_handles(response_handle, body_handle); - let request_is_head = self.request_is_head(&response)?; - if !response_carries_body(request_is_head, response.get_status()) { - return fastly_response_to_platform( - response, - self.backend_name.clone(), - false, - request_is_head, - ) - .map(Some); - } - if response - .get_header("content-length") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.trim().parse::().ok()) - .is_some_and(|length| length > MAX_PLATFORM_RESPONSE_BODY_BYTES as u64) - { - return Err(Report::new(PlatformError::HttpClient) - .attach("origin Content-Length exceeds response body limit")); - } - let body = response.take_body().into_handle(); - PendingState::Body { - response: Box::new(response), - body, - bytes: Vec::new(), - } - } - } - } - state => state, - }; - let PendingState::Body { - response, - mut body, - mut bytes, - } = state - else { - unreachable!("headers are polled before buffering bodies"); - }; - if !body_is_ready(&body)? { - self.state = Some(PendingState::Body { - response, - body, - bytes, - }); - return Ok(None); - } - let mut chunk = [0_u8; 8192]; - let read = body - .read(&mut chunk) - .change_context(PlatformError::HttpClient)?; - if read == 0 { - return buffered_response_to_platform(&response, self.backend_name.clone(), bytes) - .map(Some); - } - if bytes.len() + read > MAX_PLATFORM_RESPONSE_BODY_BYTES { - return Err(Report::new(PlatformError::HttpClient) - .attach("origin response body exceeds response body limit")); - } - if bytes.len() + read > bytes.capacity() { - let capacity = bytes - .capacity() - .max(chunk.len()) - .saturating_mul(2) - .min(MAX_PLATFORM_RESPONSE_BODY_BYTES); - bytes.reserve_exact(capacity - bytes.len()); - } - bytes.extend_from_slice(&chunk[..read]); - self.state = Some(PendingState::Body { - response, - body, - bytes, - }); - Ok(None) - } -} - -fn body_is_ready(body: &BodyHandle) -> Result> { - let mut ready = 0; - // SAFETY: the live owned body retains the handle while this hostcall borrows it. - unsafe { fastly_sys::fastly_async_io::is_ready(body.as_u32(), &mut ready) } - .result() - .map_err(|status| { - Report::new(PlatformError::HttpClient) - .attach(format!("body readiness hostcall failed: {status:?}")) - })?; - Ok(ready != 0) -} - -/// Collect one fully buffered response without waiting past the absolute deadline. -/// -/// Dropping response bodies closes their host handles. Fastly has no pending -/// request cancellation ABI: unfinished header requests are abandoned and -/// never waited on past the auction deadline. If collection starts after the -/// deadline, one nonblocking snapshot preserves already available responses. -/// -/// Partial bodies share a 10 MiB payload budget. If a chunk exceeds that -/// capacity, the largest partial response fails with an attributed HTTP client -/// error; its buffered bytes are released and smaller siblings remain collectible. -/// Completed response payload plus retained partial bodies fits the same budget. -/// -/// # Errors -/// -/// Returns an error for an empty request list, invalid aggregate buffer state, -/// or a failure of the timed wait hostcall. Individual provider errors, including -/// unsupported pending handles, are attributed in the returned collection result. -pub(super) fn select_until( - pending_requests: Vec, - deadline: Instant, -) -> Result, Report> { - if pending_requests.is_empty() { - return Err(Report::new(PlatformError::HttpClient) - .attach("select_until called with an empty pending_requests list")); - } - let mut pending = pending_requests - .into_iter() - .map(|pending| { - let backend_name = pending.backend_name().unwrap_or("").to_owned(); - let request_method = pending.request_method().cloned(); - match DeadlinePending::from_platform(pending) { - Ok(pending) => pending, - Err(error) => DeadlinePending { - backend_name, - request_method, - state: Some(PendingState::Ready(Err(error))), - }, - } - }) - .collect::>(); - let mut buffered_bytes: usize = pending.iter().map(DeadlinePending::buffered_bytes).sum(); - if buffered_bytes > MAX_TOTAL_BUFFERED_BODY_BYTES { - return Err(Report::new(PlatformError::HttpClient) - .attach("resumed auction responses exceed aggregate buffer capacity")); - } - let expired_at_entry = Instant::now() >= deadline; - if expired_at_entry { - // Take one nonblocking snapshot. Fully buffered results are cached; - // responses that would require a wait are abandoned, never retried. - for index in 0..pending.len() { - loop { - let previous = pending[index].buffered_bytes(); - let result = pending[index].poll(); - let progressed = - matches!(result, Ok(None)) && pending[index].buffered_bytes() > previous; - match result { - Ok(None) if progressed => {} - Ok(None) => { - pending[index].state = None; - } - result => { - pending[index].state = Some(PendingState::Ready(match result { - Ok(Some(response)) => Ok(response), - Err(error) => Err(error), - Ok(None) => unreachable!("pending results handled above"), - })); - } - } - buffered_bytes = buffered_bytes - previous + pending[index].buffered_bytes(); - enforce_aggregate_budget(&mut pending, &mut buffered_bytes); - if !progressed || !matches!(pending[index].state, Some(PendingState::Body { .. })) { - break; - } - } - } - pending.retain(|item| item.state.is_some()); - } - loop { - for index in 0..pending.len() { - if !expired_at_entry && Instant::now() >= deadline { - // A timed host wait may wake with a ready event at the cutoff. - // Finish with the same nonblocking snapshot as expired entry. - return select_until( - pending - .into_iter() - .map(DeadlinePending::into_platform) - .collect(), - deadline, - ); - } - let previous_bytes = pending[index].buffered_bytes(); - let result = pending[index].poll(); - if let Ok(Some(response)) = result { - pending[index].state = Some(PendingState::Ready(Ok(response))); - } else if let Err(error) = result { - pending[index].state = Some(PendingState::Ready(Err(error))); - } - buffered_bytes = buffered_bytes - previous_bytes + pending[index].buffered_bytes(); - enforce_aggregate_budget(&mut pending, &mut buffered_bytes); - // Capacity eviction may have failed an earlier large sibling. - let ready_index = pending - .iter() - .position(|item| matches!(item.state, Some(PendingState::Ready(_)))); - let Some(ready_index) = ready_index else { - continue; - }; - let mut completed = pending.swap_remove(ready_index); - let Some(PendingState::Ready(ready)) = completed.state.take() else { - unreachable!("should select a ready result"); - }; - let failed_backend_name = ready.as_ref().err().map(|_| completed.backend_name); - return Ok(Some(PlatformSelectResult { - ready, - remaining: pending - .into_iter() - .map(DeadlinePending::into_platform) - .collect(), - failed_backend_name, - })); - } - if expired_at_entry || pending.is_empty() { - return Ok(None); - } - wait_for_progress(&pending, deadline)?; - } -} - -fn enforce_aggregate_budget(pending: &mut [DeadlinePending], buffered_bytes: &mut usize) { - if *buffered_bytes <= MAX_TOTAL_BUFFERED_BODY_BYTES { - return; - } - // The overflow is at most one read chunk. Evict the largest partial body - // first, preserving completed responses and healthy smaller siblings. - let largest = pending - .iter() - .enumerate() - .filter(|(_, item)| matches!(item.state, Some(PendingState::Body { .. }))) - .max_by_key(|(_, item)| item.buffered_bytes()) - .or_else(|| { - pending - .iter() - .enumerate() - .max_by_key(|(_, item)| item.buffered_bytes()) - }) - .map(|(index, _)| index) - .expect("should retain the response exceeding buffer capacity"); - *buffered_bytes -= pending[largest].buffered_bytes(); - pending[largest].state = Some(PendingState::Ready(Err(Report::new( - PlatformError::HttpClient, - ) - .attach("largest partial response exceeds aggregate auction buffer capacity")))); -} - -fn wait_for_progress( - pending: &[DeadlinePending], - deadline: Instant, -) -> Result<(), Report> { - let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { - return Ok(()); - }; - if remaining.is_zero() { - return Ok(()); - } - let mut bodies = Vec::new(); - for pending in pending { - match &pending.state { - Some(PendingState::Headers(Some(handle))) => bodies.push(handle.as_u32()), - Some(PendingState::Body { body, .. }) => { - // SAFETY: these handles remain owned by pending throughout select. - bodies.push(unsafe { body.as_u32() }); - } - _ => {} - } - } - if bodies.is_empty() { - return Ok(()); - } - let timeout_ms = host_select_timeout_ms(remaining); - let mut ready_index = 0; - // SAFETY: bodies is a live slice, and ready_index is writable. A positive - // timeout is essential: the host interprets zero as an unbounded wait. - unsafe { - fastly_sys::fastly_async_io::select( - bodies.as_ptr(), - bodies.len(), - timeout_ms, - &mut ready_index, - ) - } - .result() - .map_err(|status| { - Report::new(PlatformError::HttpClient) - .attach(format!("timed select hostcall failed: {status:?}")) - })?; - Ok(()) -} - -/// Convert a remaining budget into the timed-select hostcall argument. -/// -/// The host treats a zero timeout as unbounded, so sub-millisecond budgets -/// round up to one millisecond instead. -fn host_select_timeout_ms(remaining: std::time::Duration) -> u32 { - u32::try_from(remaining.as_millis()) - .unwrap_or(u32::MAX) - .max(1) -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use super::super::{FastlyPlatformHttpClient, MAX_PLATFORM_RESPONSE_BODY_BYTES}; - use super::{DeadlinePending, PendingState, host_select_timeout_ms}; - use edgezero_core::body::Body; - use trusted_server_core::platform::{ - PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, - }; - use web_time::Instant; - - #[test] - fn sub_millisecond_budget_never_requests_an_unbounded_host_wait() { - assert_eq!( - host_select_timeout_ms(Duration::from_micros(500)), - 1, - "should round a sub-millisecond budget up instead of passing 0" - ); - assert_eq!( - host_select_timeout_ms(Duration::from_millis(25)), - 25, - "should pass whole-millisecond budgets through unchanged" - ); - assert_eq!( - host_select_timeout_ms(Duration::from_secs(u64::MAX)), - u32::MAX, - "should saturate budgets beyond the hostcall range" - ); - } - - #[test] - fn expired_deadline_attributes_an_invalid_pending_handle() { - let result = futures::executor::block_on(FastlyPlatformHttpClient.select_until( - vec![PlatformPendingRequest::new(42_u32).with_backend_name("example")], - Instant::now(), - )) - .expect("should isolate invalid handles even in the nonblocking snapshot") - .expect("should report the invalid pending handle"); - assert_eq!( - result.failed_backend_name.as_deref(), - Some("example"), - "should attribute the invalid pending handle" - ); - let error = result - .ready - .expect_err("should reject the invalid pending handle"); - assert!( - matches!( - error.current_context(), - trusted_server_core::platform::PlatformError::Unsupported - ), - "should reject unsupported deadline handles without panicking" - ); - } - - #[test] - fn deadline_collection_rejects_malformed_handles_with_backend_context() { - let result = futures::executor::block_on(FastlyPlatformHttpClient.select_until( - vec![PlatformPendingRequest::new(42_u32).with_backend_name("example")], - Instant::now() + Duration::from_secs(1), - )); - let result = result - .expect("should isolate malformed pending handles") - .expect("should return the malformed provider's failure"); - assert_eq!( - result.failed_backend_name.as_deref(), - Some("example"), - "should attribute the malformed handle" - ); - let error = result - .ready - .expect_err("should reject a malformed pending handle"); - assert!( - format!("{error:?}").contains("example"), - "should identify the malformed handle's backend" - ); - } - - fn buffered_pending(bytes: Vec, backend_name: &str) -> PlatformPendingRequest { - let mut response = fastly::Response::from_body(bytes); - let body = response.take_body().into_handle(); - DeadlinePending { - backend_name: backend_name.to_owned(), - request_method: Some(edgezero_core::http::Method::GET), - state: Some(PendingState::Body { - response: Box::new(response), - body, - bytes: Vec::new(), - }), - } - .into_platform() - } - - #[test] - fn unsupported_provider_does_not_discard_a_healthy_sibling() { - let deadline = Instant::now() + Duration::from_secs(1); - let failed = PlatformPendingRequest::new(42_u32).with_backend_name("unsupported"); - let healthy = buffered_pending(b"healthy".to_vec(), "healthy"); - let result = futures::executor::block_on( - FastlyPlatformHttpClient.select_until(vec![failed, healthy], deadline), - ) - .expect("should isolate a provider with an unsupported pending handle") - .expect("should report the failed provider"); - assert_eq!( - result.failed_backend_name.as_deref(), - Some("unsupported"), - "should attribute the conversion error to its provider" - ); - assert!( - result.ready.is_err(), - "should report the unsupported handle" - ); - let result = futures::executor::block_on( - FastlyPlatformHttpClient.select_until(result.remaining, deadline), - ) - .expect("should continue collecting the healthy sibling") - .expect("should complete the healthy sibling"); - let response = result.ready.expect("should retain the healthy response"); - assert_eq!( - response.backend_name.as_deref(), - Some("healthy"), - "should preserve the unaffected provider's bid" - ); - } - - #[test] - fn expired_deadline_preserves_an_already_ready_body() { - let pending = buffered_pending(b"ready".to_vec(), "example"); - let result = futures::executor::block_on( - FastlyPlatformHttpClient.select_until(vec![pending], Instant::now()), - ) - .expect("should snapshot an expired ready response") - .expect("should preserve an already complete response without waiting"); - let response = result - .ready - .expect("should retain a successful buffered response"); - assert_eq!( - response.backend_name.as_deref(), - Some("example"), - "should retain the ready response's backend" - ); - assert_eq!( - response.response.body().as_bytes(), - Some(b"ready".as_slice()), - "should retain the complete ready payload" - ); - } - - #[test] - fn remaining_handle_retains_the_original_request_method_hint() { - let pending = buffered_pending(b"body".to_vec(), "example") - .with_response_handling(false, edgezero_core::http::Method::HEAD); - let resumed = DeadlinePending::from_platform(pending) - .expect("should resume a deadline pending request"); - let remaining = resumed.into_platform(); - assert_eq!( - remaining.request_method(), - Some(&edgezero_core::http::Method::HEAD), - "should retain the HEAD method through remaining-handle reconstruction" - ); - let resumed = - DeadlinePending::from_platform(remaining).expect("should resume the remaining handle"); - let response = fastly::Response::from_body("advisory body"); - assert!( - response.get_backend_request().is_none(), - "should exercise a response without SDK originating-request metadata" - ); - assert!( - resumed - .request_is_head(&response) - .expect("should use the preserved method hint"), - "should retain HEAD body semantics after resuming without response origin metadata" - ); - } - - #[test] - fn missing_request_method_and_response_origin_returns_typed_error() { - let pending = buffered_pending(b"body".to_vec(), "example"); - let mut pending = DeadlinePending::from_platform(pending) - .expect("should inspect a buffered pending request"); - pending.request_method = None; - let response = fastly::Response::new(); - let error = pending - .request_is_head(&response) - .expect_err("should reject a response whose request method is unknown"); - assert!( - matches!( - error.current_context(), - trusted_server_core::platform::PlatformError::HttpClient - ), - "should return a typed HTTP client error" - ); - assert!( - format!("{error:?}").contains("example"), - "should identify the backend whose originating method is missing" - ); - } - - #[test] - fn completed_body_moves_into_platform_without_a_payload_copy() { - let bytes = vec![b'x'; 32768]; - let pointer = bytes.as_ptr(); - let response = super::buffered_response_to_platform( - &fastly::Response::new(), - "example".to_owned(), - bytes, - ) - .expect("should move a buffered response into the platform"); - assert_eq!( - response - .response - .body() - .as_bytes() - .expect("should retain a buffered body") - .as_ptr(), - pointer, - "should retain the original allocation without a Fastly host-body roundtrip" - ); - } - - #[test] - fn body_collection_is_fair_and_preserves_partial_siblings() { - let large = vec![b'x'; 32768]; - let pending = vec![ - buffered_pending(large.clone(), "large"), - buffered_pending(b"fast".to_vec(), "fast"), - ]; - let deadline = Instant::now() + Duration::from_secs(10); - let result = - futures::executor::block_on(FastlyPlatformHttpClient.select_until(pending, deadline)) - .expect("should collect one response") - .expect("should complete before deadline"); - let response = result - .ready - .expect("should collect the short response first"); - assert_eq!( - response.backend_name.as_deref(), - Some("fast"), - "should give siblings a fair opportunity to complete" - ); - assert_eq!( - result.remaining.len(), - 1, - "should retain the partial response" - ); - let result = futures::executor::block_on( - FastlyPlatformHttpClient.select_until(result.remaining, deadline), - ) - .expect("should resume body collection") - .expect("should finish the remaining body"); - let response = result.ready.expect("should finish the partial body"); - let (_, body) = response.response.into_parts(); - let bytes = body - .into_bytes() - .expect("should read the complete buffered body"); - assert_eq!( - bytes.as_ref(), - large.as_slice(), - "should preserve all bytes across collector calls" - ); - } - - #[test] - fn body_at_the_exact_limit_remains_eligible() { - let pending = buffered_pending(vec![b'x'; MAX_PLATFORM_RESPONSE_BODY_BYTES], "exact"); - let result = futures::executor::block_on( - FastlyPlatformHttpClient - .select_until(vec![pending], Instant::now() + Duration::from_secs(10)), - ) - .expect("should collect an exactly sized body") - .expect("should complete before the deadline"); - let response = result - .ready - .expect("should accept the configured body-size boundary"); - assert_eq!( - response - .response - .body() - .as_bytes() - .expect("should retain buffered bytes") - .len(), - MAX_PLATFORM_RESPONSE_BODY_BYTES, - "should accept the exact limit without evicting its EOF probe" - ); - } - - #[test] - fn incremental_body_limit_reports_the_failed_backend() { - let pending = buffered_pending(vec![b'x'; MAX_PLATFORM_RESPONSE_BODY_BYTES + 1], "large"); - let result = futures::executor::block_on( - FastlyPlatformHttpClient - .select_until(vec![pending], Instant::now() + Duration::from_secs(30)), - ) - .expect("should return a provider failure") - .expect("should reject the body before deadline"); - assert!( - result.ready.is_err(), - "should enforce the incremental body cap" - ); - assert_eq!( - result.failed_backend_name.as_deref(), - Some("large"), - "should attribute body conversion failures" - ); - } - - #[test] - fn aggregate_body_pressure_evicts_largest_partial_provider() { - let mut large = - DeadlinePending::from_platform(buffered_pending(vec![b'x'; 16384], "large")) - .expect("should construct a large partial response"); - if let Some(PendingState::Body { bytes, .. }) = &mut large.state { - *bytes = vec![b'x'; MAX_PLATFORM_RESPONSE_BODY_BYTES - 1]; - } - let pending = vec![ - buffered_pending(b"healthy".to_vec(), "healthy"), - large.into_platform(), - ]; - let deadline = Instant::now() + Duration::from_secs(10); - let result = - futures::executor::block_on(FastlyPlatformHttpClient.select_until(pending, deadline)) - .expect("should return a capacity result") - .expect("should reject excess growth"); - assert!( - result.ready.is_err(), - "should reject excess aggregate buffering" - ); - assert_eq!( - result.failed_backend_name.as_deref(), - Some("large"), - "should attribute capacity pressure to the largest partial response" - ); - let result = futures::executor::block_on( - FastlyPlatformHttpClient.select_until(result.remaining, deadline), - ) - .expect("should retain the healthy provider") - .expect("should complete the healthy provider"); - assert_eq!( - result - .ready - .expect("should preserve the healthy bid") - .backend_name - .as_deref(), - Some("healthy"), - "should avoid blaming the healthy provider for another provider's large body" - ); - } - - #[test] - fn expired_snapshot_retains_only_ready_results_across_calls() { - let pending = vec![ - buffered_pending(b"one".to_vec(), "one"), - buffered_pending(b"two".to_vec(), "two"), - ]; - let deadline = Instant::now(); - let result = - futures::executor::block_on(FastlyPlatformHttpClient.select_until(pending, deadline)) - .expect("should snapshot ready results") - .expect("should return first ready result"); - assert!( - result.ready.is_ok(), - "should preserve a complete ready response" - ); - let result = futures::executor::block_on( - FastlyPlatformHttpClient.select_until(result.remaining, deadline), - ) - .expect("should use cached ready results") - .expect("should return second ready result"); - assert!( - result.ready.is_ok(), - "should preserve the second complete response" - ); - } - - fn network_pending(path: &str, method: &str) -> PlatformPendingRequest { - let request = edgezero_core::http::request_builder() - .method(method) - .uri(format!("http://example.com/{path}")) - .body(Body::empty()) - .expect("should build fixture request"); - futures::executor::block_on( - FastlyPlatformHttpClient.send_async( - PlatformHttpRequest::new(request, "deadline_origin").with_cache_bypass(), - ), - ) - .expect("should launch fixture request") - } - - #[test] - #[ignore = "requires the local origin started by tests/deadline-origin.mjs"] - fn network_slow_origin_preserves_ready_bid_without_waiting_for_stalled_sibling() { - let fast = network_pending("fast", "GET"); - let stalled = network_pending("stalled", "GET"); - let deadline = Instant::now() + Duration::from_millis(40); - std::thread::sleep(Duration::from_millis(100)); - let started = Instant::now(); - let result = futures::executor::block_on( - FastlyPlatformHttpClient.select_until(vec![stalled, fast], deadline), - ) - .expect("should snapshot already ready bids") - .expect("should retain the complete fast response"); - assert!( - result.ready.is_ok(), - "should preserve a bid available after a slow publisher origin" - ); - assert!( - result.remaining.is_empty(), - "should abandon the stalled sibling in the nonblocking snapshot" - ); - assert!( - started.elapsed() < Duration::from_millis(100), - "should never wait for the stalled sibling after expiration" - ); - } - - #[test] - #[ignore = "requires the local origin started by tests/deadline-origin.mjs"] - fn network_stalled_headers_stop_at_deadline() { - let pending = network_pending("stalled", "GET"); - let started = Instant::now(); - let result = futures::executor::block_on( - FastlyPlatformHttpClient - .select_until(vec![pending], started + Duration::from_millis(80)), - ) - .expect("should stop waiting for stalled headers"); - assert!( - result.is_none(), - "should expire while headers remain pending" - ); - assert!( - started.elapsed() < Duration::from_millis(500), - "should bound the synchronous header wait" - ); - } - - #[test] - #[ignore = "requires the local origin started by tests/deadline-origin.mjs"] - fn network_trickling_body_stops_at_deadline() { - let pending = network_pending("trickle", "GET"); - let started = Instant::now(); - let result = futures::executor::block_on( - FastlyPlatformHttpClient - .select_until(vec![pending], started + Duration::from_millis(80)), - ) - .expect("should stop buffering a trickling body"); - assert!( - result.is_none(), - "should expire before the trickling body completes" - ); - assert!( - started.elapsed() < Duration::from_millis(500), - "should bound body reads at the auction deadline" - ); - } - - #[test] - #[ignore = "requires the local origin started by tests/deadline-origin.mjs"] - fn network_fast_sibling_completes_while_another_body_trickles() { - let slow = network_pending("trickle", "GET"); - let fast = network_pending("fast", "GET"); - let request = edgezero_core::http::request_builder() - .uri("http://example.com/fast") - .body(Body::empty()) - .expect("should build the request missing cache bypass"); - let unsupported = futures::executor::block_on( - FastlyPlatformHttpClient - .send_async(PlatformHttpRequest::new(request, "deadline_origin")), - ) - .expect("should dispatch the request missing cache bypass"); - let started = Instant::now(); - let deadline = started + Duration::from_millis(200); - let result = futures::executor::block_on( - FastlyPlatformHttpClient.select_until(vec![unsupported, slow, fast], deadline), - ) - .expect("should isolate a request missing cache bypass") - .expect("should return the unsupported provider's failure"); - assert_eq!( - result.failed_backend_name.as_deref(), - Some("deadline_origin"), - "should attribute missing cache bypass to that request" - ); - let error = result - .ready - .expect_err("should reject the unsupported handle"); - assert!( - matches!( - error.current_context(), - trusted_server_core::platform::PlatformError::Unsupported - ), - "should reject SDK guest-caching handles before direct handle conversion" - ); - let result = futures::executor::block_on( - FastlyPlatformHttpClient.select_until(result.remaining, deadline), - ) - .expect("should collect the completed sibling") - .expect("should finish before deadline"); - let response = result.ready.expect("should collect the fast body"); - let (_, body) = response.response.into_parts(); - let bytes = body.into_bytes().expect("should read the fast body"); - assert_eq!( - bytes.as_ref(), - b"fast", - "should avoid starvation by a trickling sibling" - ); - assert_eq!( - result.remaining.len(), - 1, - "should retain the incomplete sibling" - ); - } - - #[test] - #[ignore = "requires the local origin started by tests/deadline-origin.mjs"] - fn network_head_skips_the_body_and_its_declared_length_limit() { - let pending = network_pending("head", "HEAD"); - let result = futures::executor::block_on( - FastlyPlatformHttpClient - .select_until(vec![pending], Instant::now() + Duration::from_secs(1)), - ) - .expect("should collect HEAD response") - .expect("should finish before deadline"); - assert!( - result.ready.is_ok(), - "should accept HEAD with an advisory large content-length" - ); - } -} diff --git a/crates/trusted-server-adapter-fastly/src/platform/outbound_tests.rs b/crates/trusted-server-adapter-fastly/src/platform/outbound_tests.rs new file mode 100644 index 0000000000..1127770639 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/platform/outbound_tests.rs @@ -0,0 +1,313 @@ +//! Shared TS collection policy under the real request-local Fastly progress driver. +//! Requires the disposable origin started by tests/deadline-origin.mjs. + +use std::time::Duration; + +use edgezero_adapter_fastly::delivery::{ + FastlyRequestDelivery, FastlyResponseCommitState, testing::response_delivery, +}; +use edgezero_adapter_fastly::request::FastlyService; +use edgezero_core::app::App; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Response, request_builder}; +use edgezero_core::response_egress_framing::ResponseEgressFramingMode; +use edgezero_core::router::RouterService; +use futures::executor::block_on; +use trusted_server_core::platform::{ + AuctionTargetId, EdgeZeroHttpClient, PlatformCacheIntent, PlatformHttpClient, + PlatformHttpRequest, PlatformPendingRequest, +}; +use web_time::Instant; + +#[derive(Clone, Copy)] +enum Scenario { + SlowOrigin, + StalledHeaders, + TrickleBody, + Siblings, + Head, +} + +fn outbound(base: &str, path: &str, method: &str, backend: &str) -> PlatformHttpRequest { + PlatformHttpRequest::new( + request_builder() + .method(method) + .uri(format!("{base}/{path}")) + .body(Body::empty()) + .expect("should build the fixture exchange"), + backend, + ) + .with_cache_bypass() +} + +async fn pending( + client: &dyn PlatformHttpClient, + base: &str, + path: &str, + backend: &str, +) -> PlatformPendingRequest { + client + .send_async(outbound(base, path, "GET", backend)) + .await + .expect("should schedule through the incoming Fastly client") +} + +async fn exercise(context: RequestContext, scenario: Scenario) -> Result { + let client = EdgeZeroHttpClient::for_request(&context, AuctionTargetId::Fastly); + let base = fastly::ConfigStore::try_open("deadline_fixture") + .expect("should open fixture inputs") + .try_get("origin") + .expect("should read the fixture origin") + .expect("should supply a loopback origin"); + match scenario { + Scenario::SlowOrigin => { + let fast = pending(client.as_ref(), &base, "fast", "fast").await; + let stalled = pending(client.as_ref(), &base, "stalled", "stalled").await; + let deadline = Instant::now() + Duration::from_millis(40); + // Wait for an actual publisher origin through the same SDK driver. + // The driver can finish the bid while the application awaits that origin. + client + .send(outbound(&base, "slow-origin", "GET", "publisher")) + .await + .expect("should await the slow publisher origin"); + let start = Instant::now(); + let result = client + .select_until(vec![stalled, fast], deadline) + .await + .expect("should take a non-driving completion snapshot") + .expect("should retain a completed buffered bid"); + assert_eq!( + result + .ready + .expect("should preserve the healthy response") + .backend_name + .as_deref(), + Some("fast"), + "should retain the completed provider" + ); + assert!( + result.remaining.is_empty(), + "should cancel unfinished work at the cutoff" + ); + assert!( + start.elapsed() < Duration::from_millis(100), + "should not wait for the stalled sibling after expiry" + ); + } + Scenario::StalledHeaders | Scenario::TrickleBody => { + let path = if matches!(scenario, Scenario::StalledHeaders) { + "stalled" + } else { + "trickle" + }; + let request = pending(client.as_ref(), &base, path, "slow").await; + let start = Instant::now(); + let result = client + .select_until(vec![request], start + Duration::from_millis(80)) + .await + .expect("should bound the full exchange wait"); + assert!( + result.is_none(), + "should not accept headers or partial bytes as a completed response" + ); + assert!( + start.elapsed() < Duration::from_millis(500), + "should stop at the collection cutoff" + ); + } + Scenario::Siblings => { + let slow = pending(client.as_ref(), &base, "trickle", "slow").await; + let fast = pending(client.as_ref(), &base, "fast", "fast").await; + let failed = pending(client.as_ref(), &base, "failed", "failed").await; + let mut cached = outbound(&base, "fast", "GET", "default-cache"); + cached.cache_intent = PlatformCacheIntent::Default; + let cached = client + .send_async(cached) + .await + .expect("should schedule a default-cache exchange through the SDK"); + let deadline = Instant::now() + Duration::from_millis(300); + let mut requests = vec![slow, fast, failed, cached]; + let mut successes = Vec::new(); + let mut errors = Vec::new(); + for _ in 0..3 { + let result = client + .select_until(requests, deadline) + .await + .expect("should isolate completed siblings") + .expect("should finish before the deadline"); + match result.ready { + Ok(response) => { + assert_eq!( + response.response.body().as_bytes(), + Some(b"fast".as_slice()), + "should retain exact buffered bytes" + ); + successes.push( + response + .backend_name + .expect("should retain successful provider correlation"), + ); + } + Err(_) => errors.push( + result + .failed_backend_name + .expect("should attribute the failed provider"), + ), + } + requests = result.remaining; + } + successes.sort(); + assert_eq!( + successes, + ["default-cache", "fast"], + "should retain bypass and SDK-default cache exchanges without starvation" + ); + assert_eq!( + errors, + ["failed"], + "should isolate the genuine transport error" + ); + assert_eq!( + requests.len(), + 1, + "should retain only the incomplete sibling before the cutoff" + ); + } + Scenario::Head => { + let request = client + .send_async(outbound(&base, "head", "HEAD", "head")) + .await + .expect("should schedule HEAD"); + let result = client + .select_until(vec![request], Instant::now() + Duration::from_secs(1)) + .await + .expect("should collect HEAD") + .expect("should finish before cutoff"); + let response = result + .ready + .expect("should accept advisory Content-Length above the body limit"); + assert_eq!( + response.response.body().as_bytes(), + Some(b"".as_slice()), + "should not read a HEAD body" + ); + let coded = client + .send_async(outbound(&base, "gzip", "GET", "coded")) + .await + .expect("should schedule an original coded entity"); + let coded = client + .select_until(vec![coded], Instant::now() + Duration::from_secs(1)) + .await + .expect("should collect coded bytes") + .expect("should complete coded response") + .ready + .expect("should retain the coded response"); + assert_eq!( + coded.response.headers()["content-encoding"], + "gzip", + "should retain Fastly's original coding policy" + ); + let body = coded + .response + .body() + .as_bytes() + .expect("should retain buffered encoded entity bytes"); + assert!( + body.starts_with(&[31, 139, 8]), + "should not replace encoded bytes with decoded text" + ); + assert_eq!( + body.len().to_string(), + coded.response.headers()["content-length"] + .to_str() + .expect("should retain coded length"), + "should account for original entity bytes" + ); + } + } + Ok(Response::new(Body::from("okay"))) +} + +fn run(scenario: Scenario) { + let router = RouterService::builder() + .get("/", move |context: RequestContext| { + exercise(context, scenario) + }) + .build(); + let app = App::new(router); + let result = FastlyService::new(&app).run_request_with_hooks_for_test( + fastly::Request::get("http://publisher.example.com/"), + |_raw, _extensions| {}, + |response| { + assert_eq!( + response.status().as_u16(), + 200, + "should finish the fixture route" + ) + }, + |envelope| { + let (prepared, _, mut attempt, clock) = envelope + .begin(ResponseEgressFramingMode::HostAutomatic) + .map_err(|_| ()) + .expect("should begin fixture egress"); + assert!(attempt.begin_writing(), "should begin writing once"); + assert_eq!( + block_on( + prepared + .into_response() + .into_body() + .into_bytes_bounded(1024) + ) + .expect("should collect the fixture result") + .as_ref(), + b"okay", + "should complete the production collection path" + ); + assert!( + attempt.complete(clock.now()), + "should finish managed egress" + ); + response_delivery( + FastlyResponseCommitState::Committed, + attempt.into_terminal_report(), + Ok(()), + ) + }, + ); + assert!( + matches!(result, FastlyRequestDelivery::Routed { delivery, .. } if delivery.error().is_none()), + "should retain successful managed dispatch" + ); +} + +#[test] +#[ignore = "requires the local origin started by tests/deadline-origin.mjs"] +fn network_slow_origin_preserves_ready_bid_without_waiting_for_stalled_sibling() { + run(Scenario::SlowOrigin); +} + +#[test] +#[ignore = "requires the local origin started by tests/deadline-origin.mjs"] +fn network_stalled_headers_stop_at_deadline() { + run(Scenario::StalledHeaders); +} + +#[test] +#[ignore = "requires the local origin started by tests/deadline-origin.mjs"] +fn network_trickling_body_stops_at_deadline() { + run(Scenario::TrickleBody); +} + +#[test] +#[ignore = "requires the local origin started by tests/deadline-origin.mjs"] +fn network_fast_sibling_completes_while_another_body_trickles() { + run(Scenario::Siblings); +} + +#[test] +#[ignore = "requires the local origin started by tests/deadline-origin.mjs"] +fn network_head_skips_the_body_and_its_declared_length_limit() { + run(Scenario::Head); +} diff --git a/crates/trusted-server-adapter-fastly/tests/deadline-origin.mjs b/crates/trusted-server-adapter-fastly/tests/deadline-origin.mjs index c985097677..6767b78e4e 100644 --- a/crates/trusted-server-adapter-fastly/tests/deadline-origin.mjs +++ b/crates/trusted-server-adapter-fastly/tests/deadline-origin.mjs @@ -10,6 +10,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { promisify } from 'node:util' +import { gzipSync } from 'node:zlib' const execute = promisify(execFile) const interruption = new AbortController() @@ -67,6 +68,17 @@ async function serveOrigin(request, response) { response.end() return } + if (request.url === '/gzip') { + const body = gzipSync(Buffer.from('fast')) + response.writeHead(200, { 'Content-Length': body.length, 'Content-Encoding': 'gzip' }) + response.end(body) + return + } + if (request.url === '/failed') { + request.socket.destroy() + return + } + if (request.url === '/slow-origin') await pause(100) if (request.url === '/stalled') await pause(2000) if (request.url === '/trickle') { response.writeHead(200, { 'Content-Length': 100 }) @@ -107,8 +119,10 @@ async function main() { const config = join(directory, 'fastly.toml') await writeFile( config, - '[local_server.backends.deadline_origin]\n' + - `url = "http://127.0.0.1:${server.address().port}"\n` + '[local_server.config_stores.deadline_fixture]\n' + + 'format = "inline-toml"\n' + + '[local_server.config_stores.deadline_fixture.contents]\n' + + `origin = "http://127.0.0.1:${server.address().port}"\n` ) const environment = { ...process.env } if (existsSync('/etc/ssl/cert.pem')) @@ -120,7 +134,7 @@ async function main() { '-C', config, binary, - 'platform::deadline::tests::network_', + 'platform::outbound_tests::network_', '--ignored', '--nocapture', ], diff --git a/crates/trusted-server-adapter-spin/Cargo.toml b/crates/trusted-server-adapter-spin/Cargo.toml index 47cf7b589c..bdfef13a51 100644 --- a/crates/trusted-server-adapter-spin/Cargo.toml +++ b/crates/trusted-server-adapter-spin/Cargo.toml @@ -24,14 +24,11 @@ spin = ["edgezero-adapter-spin/spin"] [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } -brotli = { workspace = true } bytes = { workspace = true } edgezero-adapter-spin = { workspace = true } edgezero-core = { workspace = true } error-stack = { workspace = true } -flate2 = { workspace = true } futures = { workspace = true } -http-body-util = { workspace = true } log = { workspace = true } web-time = { workspace = true } trusted-server-core = { workspace = true } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index edf956da2d..0d7946fc69 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -27,7 +27,7 @@ use trusted_server_core::ec::admin::{ }; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; -use trusted_server_core::http_util::{RuntimeRequestOrigin, sanitize_forwarded_headers}; +use trusted_server_core::http_util::RuntimeRequestOrigin; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; use trusted_server_core::platform::{ClientInfo, RuntimeServices}; #[cfg(all(feature = "spin", target_arch = "wasm32"))] @@ -87,14 +87,7 @@ fn build_state() -> Result, Report> { fn load_startup_settings() -> Result> { let config_store_name = StoreName::from(SPIN_DEFAULT_CONFIG_STORE); let config_key = default_config_key(); - let config_store = - futures::executor::block_on(SpinConfigStore::open(config_store_name.as_ref().to_owned())) - .map_err(|error| { - Report::new(TrustedServerError::Configuration { - message: "failed to open Spin Trusted Server config store".to_string(), - }) - .attach(error.to_string()) - })?; + let config_store = SpinConfigStore::for_label(config_store_name.as_ref().to_owned()); let config_handle = ConfigStoreHandle::new(Arc::new(config_store)); let config_adapter = ConfigStoreHandleAdapter(config_handle); let raw_envelope = config_adapter @@ -293,8 +286,8 @@ fn parse_client_addr(raw: &str) -> Option { // which rejects a relative path. Rebuilding an absolute URI from the trusted // scheme+host lets those handlers validate the signed target instead of failing // with "Invalid URL". -pub(crate) fn normalize_spin_request(req: &mut Request) { - sanitize_forwarded_headers(req); +pub(crate) fn normalize_spin_request(req: &mut edgezero_core::http::RequestParts) { + trusted_server_core::http_util::sanitize_forwarded_header_map(&mut req.headers); // Strip any client-supplied `x-forwarded-for`. Spin exposes no trusted // upstream forwarded-for chain — the only trusted client IP comes from the @@ -302,7 +295,7 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { // inbound `x-forwarded-for` to origins and Prebid copies it into its outbound // headers, so leaving it would let a Spin client spoof the IP seen by // downstream services while `device.ip` uses the trusted runtime address. - req.headers_mut().remove("x-forwarded-for"); + req.headers.remove("x-forwarded-for"); // Re-derive the trusted client IP from the *last* `spin-client-addr` header // and overwrite `SpinRequestContext`, which `build_runtime_services` reads @@ -313,26 +306,23 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { // hashing, `/auction` `device.ip`, and integration `X-Forwarded-For` consume. // Selecting the last occurrence pins the value to the trusted runtime one. let trusted_client_addr = req - .headers() + .headers .get_all("spin-client-addr") .iter() .next_back() .and_then(|v| v.to_str().ok()) .and_then(parse_client_addr); let trusted_full_url = req - .headers() + .headers .get_all("spin-full-url") .iter() .next_back() .and_then(|v| v.to_str().ok()) .map(str::to_owned); - SpinRequestContext::insert( - req, - SpinRequestContext { - client_addr: trusted_client_addr, - full_url: trusted_full_url.clone(), - }, - ); + req.extensions.insert(SpinRequestContext { + client_addr: trusted_client_addr, + full_url: trusted_full_url.clone(), + }); // Spin's WASI HTTP bridge copies synthetic `spin-client-addr` and // `spin-full-url` headers onto the core request. They are runtime metadata, @@ -340,7 +330,7 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { // values are captured into `SpinRequestContext` above; remove every copy here // so the shared publisher/integration handlers neither forward them to // publisher origins nor mistake them for client-controlled input. - req.headers_mut().remove("spin-client-addr"); + req.headers.remove("spin-client-addr"); // Spin's WASI HTTP bridge appends its synthetic `spin-full-url` *after* the // original client headers, so when duplicate names are present the trusted @@ -350,7 +340,7 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { // by publisher HTML rewriting, integration URL rewriting, and request // signing. Select the last occurrence as the trusted authority, then strip // every copy so none reach publisher origins or the shared handlers. - req.headers_mut().remove("spin-full-url"); + req.headers.remove("spin-full-url"); let Some((scheme, host)) = trusted_full_url .as_deref() .and_then(scheme_host_from_spin_url) @@ -360,31 +350,30 @@ pub(crate) fn normalize_spin_request(req: &mut Request) { let Ok(origin) = InboundOrigin::parse(&scheme, &host, OriginSource::RuntimeUri) else { return; }; - req.extensions_mut() - .insert(RuntimeRequestOrigin::new(origin)); + req.extensions.insert(RuntimeRequestOrigin::new(origin)); // Always set Host from the trusted spin-full-url rather than preserving any // incoming value. Spin's WASI HTTP bridge does not normally surface the // incoming Host header (without it extract_request_host() returns "" and // classify_response_route falls back to BufferedUnmodified, skipping the HTML // processor), but when a Host *is* present it is client-controllable. Keeping - // it while rebuilding req.uri() from the spin-full-url host below would let the + // it while rebuilding req.uri from the spin-full-url host below would let the // shared RequestInfo path (publisher HTML rewriting, integration URL rewriting, - // signing context) read one host while handlers parsing req.uri() see another. + // signing context) read one host while handlers parsing req.uri see another. // Overriding from the single trusted authority keeps both consistent. if let Ok(hval) = HeaderValue::from_str(&host) { - req.headers_mut().insert(header::HOST, hval); + req.headers.insert(header::HOST, hval); } // Promote the path-only URI to an absolute one so the shared first-party - // proxy/click/sign handlers can parse `req.uri()` as a full URL. + // proxy/click/sign handlers can parse `req.uri` as a full URL. let path_and_query = req - .uri() + .uri .path_and_query() .map(|pq| pq.as_str().to_owned()) .unwrap_or_else(|| "/".to_string()); if let Ok(uri) = format!("{scheme}://{host}{path_and_query}").parse() { - *req.uri_mut() = uri; + req.uri = uri; } } @@ -551,16 +540,16 @@ impl Hooks for TrustedServerApp { "TrustedServer" } - fn routes() -> RouterService { + fn routes() -> Result { let state = match build_state() { Ok(s) => s, Err(ref e) => { log::error!("failed to build application state: {:?}", e); - return startup_error_router(e); + return Ok(startup_error_router(e)); } }; - build_router(&state) + Ok(build_router(&state)) } } @@ -617,7 +606,7 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let services = s.services_for_request(&ctx); - let req = ctx.into_request(); + let req = ctx.into_request()?; Ok(handle_trusted_server_discovery(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) } @@ -629,7 +618,11 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let services = s.services_for_request(&ctx); - let req = ctx.into_request(); + ctx.body_bytes( + trusted_server_core::request_signing::endpoints::VERIFY_MAX_BODY_BYTES, + ) + .await?; + let req = ctx.into_request()?; Ok(handle_verify_signature(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) } @@ -652,7 +645,7 @@ fn build_router(state: &Arc) -> RouterService { let admin_eids_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let req = ctx.into_request(); + let req = ctx.into_request()?; let result = PartnerRegistry::from_config(&s.settings.ec.partners) .and_then(|registry| handle_admin_eids_lookup(®istry, &req)); Ok::(result.unwrap_or_else(|e| http_error(&e))) @@ -665,12 +658,14 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let services = s.services_for_request(&ctx); + ctx.body_bytes(trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE) + .await?; // Request normalization (forwarded-header stripping, trusted // Host/scheme/client-IP derivation) is applied centrally by // `NormalizeMiddleware` before this handler runs, so the signed // OpenRTB metadata that auction signing derives from // `RequestInfo::from_request` uses the trusted runtime authority. - let mut req = ctx.into_request(); + let mut req = ctx.into_request()?; if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -703,7 +698,7 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let services = s.services_for_request(&ctx); - let mut req = ctx.into_request(); + let mut req = ctx.into_request()?; if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -739,7 +734,7 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let services = s.services_for_request(&ctx); - let req = ctx.into_request(); + let req = ctx.into_request()?; Ok(handle_first_party_proxy(&s.settings, &services, req) .await .unwrap_or_else(|e| http_error(&e))) @@ -752,7 +747,7 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let services = s.services_for_request(&ctx); - let req = ctx.into_request(); + let req = ctx.into_request()?; Ok(handle_first_party_click(&s.settings, &services, req) .await .unwrap_or_else(|e| http_error(&e))) @@ -765,7 +760,11 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let services = s.services_for_request(&ctx); - let req = ctx.into_request(); + if ctx.method() == Method::POST { + ctx.body_bytes(trusted_server_core::proxy::SIGN_MAX_BODY_BYTES) + .await?; + } + let req = ctx.into_request()?; Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await .unwrap_or_else(|e| http_error(&e))) @@ -782,7 +781,11 @@ fn build_router(state: &Arc) -> RouterService { let s = Arc::clone(&s); async move { let services = s.services_for_request(&ctx); - let req = ctx.into_request(); + if ctx.method() == Method::POST { + ctx.body_bytes(trusted_server_core::proxy::REBUILD_MAX_BODY_BYTES) + .await?; + } + let req = ctx.into_request()?; Ok( handle_first_party_proxy_rebuild(&s.settings, &services, req) .await @@ -798,7 +801,7 @@ fn build_router(state: &Arc) -> RouterService { ctx: RequestContext, ) -> Result { let services = state.services_for_request(&ctx); - let mut req = ctx.into_request(); + let mut req = ctx.into_request()?; if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); } @@ -981,7 +984,7 @@ fn build_router(state: &Arc) -> RouterService { } } -fn trace_metadata(state: &AppState, request: &Request) -> TraceMetadata { +fn trace_metadata(state: &AppState, request: &edgezero_core::http::RequestParts) -> TraceMetadata { if let Some(services) = &state.services { let client_info = services.client_info().clone(); let geo = client_info.client_ip.and_then(|client_ip| { @@ -993,7 +996,7 @@ fn trace_metadata(state: &AppState, request: &Request) -> TraceMetadata { return TraceMetadata { client_info, geo }; } let client_ip = request - .headers() + .headers .get_all("spin-client-addr") .iter() .next_back() @@ -1012,6 +1015,13 @@ fn trace_metadata(state: &AppState, request: &Request) -> TraceMetadata { mod tests { use super::*; + fn normalize_spin_request(request: &mut Request) { + let original = std::mem::replace(request, Request::new(edgezero_core::body::Body::empty())); + let (mut parts, body) = original.into_parts(); + super::normalize_spin_request(&mut parts); + *request = Request::from_parts(parts, body); + } + fn multi_provider_settings() -> Settings { let mut settings = Settings::from_toml( r#" @@ -1056,6 +1066,16 @@ mod tests { settings } + #[test] + fn named_sign_route_buffers_guarded_streamed_json_under_its_existing_cap() { + let settings = trusted_server_core::test_support::named_route_inputs::sign_settings(); + let router = TrustedServerApp::routes_with_settings(settings) + .expect("should build streamed-input routes"); + futures::executor::block_on( + trusted_server_core::test_support::named_route_inputs::assert_sign_inputs(&router), + ); + } + #[test] fn startup_registers_aps_renderer_route() { let mut settings = multi_provider_settings(); diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 4dc3cbee47..2828206eeb 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -5,9 +5,9 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; -use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::auth::enforce_basic_auth_parts; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; -use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; +use trusted_server_core::http_util::sanitize_trusted_client_ip_header_map; use trusted_server_core::settings::Settings; // --------------------------------------------------------------------------- @@ -38,8 +38,8 @@ impl SanitizeRequestMiddleware { #[async_trait(?Send)] impl Middleware for SanitizeRequestMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - sanitize_trusted_client_ip_headers( - ctx.request_mut(), + sanitize_trusted_client_ip_header_map( + ctx.headers_mut(), self.settings.trusted_client_ip.as_ref(), ); next.run(ctx).await @@ -106,7 +106,7 @@ impl AuthMiddleware { #[async_trait(?Send)] impl Middleware for AuthMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - match enforce_basic_auth(&self.settings, ctx.request_mut()) { + match enforce_basic_auth_parts(&self.settings, ctx.parts_mut()) { Ok(Some(response)) => return Ok(response), Ok(None) => {} Err(report) => { @@ -149,7 +149,7 @@ impl NormalizeMiddleware { #[async_trait(?Send)] impl Middleware for NormalizeMiddleware { async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { - crate::app::normalize_spin_request(ctx.request_mut()); + crate::app::normalize_spin_request(ctx.parts_mut()); next.run(ctx).await } } @@ -333,8 +333,8 @@ mod tests { let handler_observed = Arc::clone(&handler_observed); async move { *handler_observed.lock().expect("should lock observation") = Some(( - ctx.request().headers().contains_key("x-reader-ip"), - ctx.request().headers().contains_key("x-reader-ip-auth"), + ctx.headers().contains_key("x-reader-ip"), + ctx.headers().contains_key("x-reader-ip-auth"), )); Ok::(empty_response()) } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index 53b6386373..70f9dc6361 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -6,65 +6,12 @@ use bytes::Bytes; use edgezero_core::config_store::ConfigStoreHandle; use edgezero_core::key_value_store::{KvHandle, KvPage, KvStore}; use error_stack::{Report, ResultExt as _}; -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -use http_body_util::BodyExt; -use trusted_server_core::platform::{ - BackendNamingPolicy, ClientInfo, GeoInfo, KvError, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformGeo, PlatformHttpClient, PlatformKvStore, - PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, -}; - -#[cfg(not(all(feature = "spin", target_arch = "wasm32")))] -use trusted_server_core::platform::UnavailableHttpClient; - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -use std::io::Read as _; -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -use trusted_server_core::platform::PlatformHttpRequest; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] use trusted_server_core::platform::{ - PlatformPendingRequest, PlatformPendingResponse, PlatformResponse, PlatformSelectResult, + AuctionTargetId, BackendNamingPolicy, ClientInfo, EdgeZeroHttpClient, GeoInfo, KvError, + PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, + PlatformKvStore, PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableKvStore, }; -// Limit both streamed bytes and decompressed bytes to 8 MiB to preserve -// headroom in the Spin WASM component heap. The streaming limit rejects an -// oversized frame before retaining it in the collected response body. -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -const MAX_DECOMPRESSED_SIZE: usize = 8 * 1024 * 1024; - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -type HeaderPairs = Vec<(String, Vec)>; -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -type BufferedResponseParts = (HeaderPairs, Vec); - -/// Collect an outbound response body, rejecting it once it exceeds -/// [`MAX_DECOMPRESSED_SIZE`] streamed bytes. -/// -/// The limit applies frame by frame, so an oversized body fails before it is -/// fully retained in the component heap. -/// -/// # Errors -/// -/// Returns `PlatformError::HttpClient` when the body exceeds the limit or a -/// frame cannot be read. -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -async fn collect_limited_body(body: B) -> Result, Report> -where - B: BodyExt, - B::Data: bytes::Buf, - B::Error: Into>, -{ - let collected = http_body_util::Limited::new(body, MAX_DECOMPRESSED_SIZE) - .collect() - .await - .map_err(|error| { - Report::new(PlatformError::HttpClient).attach(format!( - "failed to read Spin outbound response body: {error}" - )) - })?; - Ok(collected.to_bytes().to_vec()) -} - #[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] const SPIN_VARIABLE_HEX: &[u8; 16] = b"0123456789abcdef"; @@ -148,6 +95,7 @@ impl PlatformConfigStore for ConfigStoreHandleAdapter { Report::new(PlatformError::ConfigStore) .attach(format!("key `{key}` not found in Spin config store")) }) + .map(|value| value.as_str().to_owned()) } fn put(&self, _: &StoreId, _: &str, _: &str) -> Result<(), Report> { @@ -262,414 +210,6 @@ impl KvStore for KvHandleAdapter { } } -// --------------------------------------------------------------------------- -// Response policy helpers -// --------------------------------------------------------------------------- - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn sanitize_response_headers(headers: HeaderPairs) -> HeaderPairs { - let connection_tokens = connection_header_tokens(&headers); - - headers - .into_iter() - .filter(|(name, _)| !is_hop_by_hop_response_header(name, &connection_tokens)) - .collect() -} - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn connection_header_tokens(headers: &HeaderPairs) -> Vec { - headers - .iter() - .filter(|(name, _)| name.eq_ignore_ascii_case("connection")) - .filter_map(|(_, value)| std::str::from_utf8(value).ok()) - .flat_map(|value| value.split(',')) - .map(str::trim) - .filter(|token| !token.is_empty()) - .map(str::to_ascii_lowercase) - .collect() -} - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn is_hop_by_hop_response_header(name: &str, connection_tokens: &[String]) -> bool { - let lowercase_name = name.to_ascii_lowercase(); - matches!( - lowercase_name.as_str(), - "connection" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - | "keep-alive" - ) || connection_tokens - .iter() - .any(|token| token == &lowercase_name) -} - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn content_encoding(headers: &HeaderPairs) -> Option { - headers - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case("content-encoding")) - .and_then(|(_, value)| std::str::from_utf8(value).ok()) - .map(str::trim) - .map(str::to_ascii_lowercase) -} - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn reject_unsupported_request_contracts( - request: &PlatformHttpRequest, -) -> Result<(), Report> { - if request.image_optimizer.is_some() { - return Err(Report::new(PlatformError::HttpClient) - .attach("Spin outbound HTTP does not support Image Optimizer metadata")); - } - if request.stream_response { - return Err(Report::new(PlatformError::HttpClient) - .attach("Spin outbound HTTP does not support streaming responses")); - } - Ok(()) -} - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn response_must_not_have_body(method: &edgezero_core::http::Method, status: u16) -> bool { - method == edgezero_core::http::Method::HEAD - || (100..200).contains(&status) - || status == 204 - || status == 304 -} - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn apply_spin_response_policy( - method: &edgezero_core::http::Method, - status: u16, - headers: HeaderPairs, - body: Vec, -) -> Result> { - let mut headers = sanitize_response_headers(headers); - - // Bound the buffered body on every path, not just decompressed ones. An - // identity response or an unsupported encoding (e.g. zstd) is returned as-is, - // so without this check a large origin/ad-server/proxy response would be - // forwarded after being fully buffered instead of failing with a typed error. - // For gzip/br this also rejects an overlarge compressed body before - // decompression; `decompress_body` separately bounds the expanded size. - enforce_response_size(body.len())?; - - if response_must_not_have_body(method, status) { - return Ok((headers, body)); - } - - let Some(encoding) = content_encoding(&headers) else { - return Ok((headers, body)); - }; - - if !matches!(encoding.as_str(), "gzip" | "br") { - return Ok((headers, body)); - } - - let body = decompress_body(&body, &encoding, MAX_DECOMPRESSED_SIZE)?; - headers.retain(|(name, _)| { - !name.eq_ignore_ascii_case("content-encoding") - && !name.eq_ignore_ascii_case("content-length") - }); - Ok((headers, body)) -} - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn enforce_response_size(len: usize) -> Result<(), Report> { - if len > MAX_DECOMPRESSED_SIZE { - return Err(Report::new(PlatformError::HttpClient).attach(format!( - "response body exceeded maximum size of {MAX_DECOMPRESSED_SIZE} bytes" - ))); - } - Ok(()) -} - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn decompress_body( - body: &[u8], - encoding: &str, - max_size: usize, -) -> Result, Report> { - match encoding { - "gzip" => { - let decoder = flate2::read::GzDecoder::new(body); - read_decompressed(decoder, encoding, max_size) - } - "br" => { - let decoder = brotli::Decompressor::new(body, 8192); - read_decompressed(decoder, encoding, max_size) - } - _ => Ok(body.to_vec()), - } -} - -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn read_decompressed( - reader: R, - encoding: &str, - max_size: usize, -) -> Result, Report> -where - R: std::io::Read, -{ - let mut decoded = Vec::new(); - reader - .take(max_size as u64 + 1) - .read_to_end(&mut decoded) - .map_err(|e| { - Report::new(PlatformError::HttpClient) - .attach(format!("{encoding} decompression failed: {e}")) - })?; - if decoded.len() > max_size { - return Err(Report::new(PlatformError::HttpClient).attach(format!( - "{encoding} decompression exceeded maximum size of {max_size} bytes" - ))); - } - Ok(decoded) -} - -// --------------------------------------------------------------------------- -// SpinPlatformHttpClient — WASM target + spin feature only -// --------------------------------------------------------------------------- - -/// Returns `true` for headers forbidden on WASI HTTP outbound requests. -/// -/// WASI HTTP (wasi:http@0.2) forbids `host` on outgoing requests — the -/// authority is conveyed separately via `OutgoingRequest::set_authority`. -/// Hop-by-hop headers (`connection`, `keep-alive`, `transfer-encoding`, -/// `upgrade`, `proxy-connection`) are HTTP/1.1 transport-layer concerns -/// that must not appear in WASI HTTP requests. -// Compiled for tests on native targets so unit tests can exercise the filter -// without requiring wasm32; the only production call site is inside the -// `#[cfg(all(feature = "spin", target_arch = "wasm32"))]` block below. -#[cfg(any(test, all(feature = "spin", target_arch = "wasm32")))] -fn is_wasi_forbidden_outbound_header(name: &str) -> bool { - name.eq_ignore_ascii_case("host") - || name.eq_ignore_ascii_case("connection") - || name.eq_ignore_ascii_case("keep-alive") - || name.eq_ignore_ascii_case("transfer-encoding") - || name.eq_ignore_ascii_case("upgrade") - || name.eq_ignore_ascii_case("proxy-connection") -} - -/// `spin_sdk::http::send`-backed HTTP client for the Spin runtime. -/// -/// Auction configuration supports one provider at a time, so -/// [`PlatformHttpClient::supports_concurrent_fanout`] reports `false`. -/// Requests start in a runtime task at dispatch, overlapping publisher-origin work. -/// Deadline selection bounds waiting for the full send and response body collection. -/// Dropping the pending operation cancels its WASI asynchronous operations. -/// -/// Spin's WASI HTTP API sends one request and returns the original response; it -/// exposes no redirect-follow policy. Each trait call therefore reaches exactly -/// one `spin_sdk::http::send` boundary and returns an original 3xx to core. Host -/// tests cannot instantiate Spin's WASI transport, so the common -/// `StubHttpClient` driver records the one-send 3xx behavior while adapter tests -/// cover request/response policy around that single boundary. -/// -/// Backend phase timeouts remain unsupported. Callers requiring an absolute -/// cutoff use [`PlatformHttpClient::select_until`]. -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -pub struct SpinPlatformHttpClient; - -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -impl SpinPlatformHttpClient { - async fn execute( - request: PlatformHttpRequest, - ) -> Result> { - reject_unsupported_request_contracts(&request)?; - - let method = request.request.method().clone(); - let uri = request.request.uri().to_string(); - - let mut builder = spin_sdk::http::Request::builder() - .method(into_spin_method(&method)) - .uri(uri.clone()); - - for (name, value) in request.request.headers() { - // WASI HTTP forbids these headers on outbound requests: - // `host` is conveyed via set_authority; hop-by-hop headers are HTTP/1.1 only. - if is_wasi_forbidden_outbound_header(name.as_str()) { - continue; - } - match value.to_str() { - Ok(value) => { - builder = builder.header(name.as_str(), value); - } - Err(_) => { - log::warn!( - "dropping non-UTF-8 outbound request header (Spin WASI limitation): {}", - name - ); - } - } - } - - let (_, body) = request.request.into_parts(); - let body_bytes = match body { - edgezero_core::body::Body::Once(bytes) => bytes.to_vec(), - edgezero_core::body::Body::Stream(_) => { - // TODO: streaming request bodies unsupported; large proxy POBs (e.g. - // /first-party/proxy-rebuild) will fail until Spin WASI HTTP buffering - // is added to this client. - return Err(Report::new(PlatformError::HttpClient) - .attach("streaming request bodies are not supported on Spin outbound HTTP")); - } - }; - let spin_request = builder - .body(spin_sdk::http::FullBody::new(Bytes::from(body_bytes))) - .map_err(|error| { - Report::new(PlatformError::HttpClient) - .attach(format!("failed to build Spin outbound request: {error}")) - })?; - - let spin_response: spin_sdk::http::Response = - spin_sdk::http::send(spin_request).await.map_err(|e| { - Report::new(PlatformError::HttpClient) - .attach(format!("outbound request to {uri} failed: {e}")) - })?; - - let status = spin_response.status().as_u16(); - let headers: HeaderPairs = spin_response - .headers() - .iter() - .map(|(name, value)| (name.to_string(), value.as_bytes().to_vec())) - .collect(); - let body = collect_limited_body(spin_response.into_body()).await?; - - let (headers, body) = apply_spin_response_policy(&method, status, headers, body)?; - let mut edge_builder = edgezero_core::http::response_builder().status(status); - for (name, value) in headers { - edge_builder = edge_builder.header(name.as_str(), value.as_slice()); - } - let edge_resp = edge_builder - .body(edgezero_core::body::Body::from(body)) - .change_context(PlatformError::HttpClient)?; - - Ok(PlatformResponse::new(edge_resp).with_backend_name(request.backend_name)) - } -} - -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -#[async_trait::async_trait(?Send)] -impl PlatformHttpClient for SpinPlatformHttpClient { - fn supports_concurrent_fanout(&self) -> bool { - // Requests start concurrently at dispatch, but `select` and - // `select_until` collect only one response, so multi-provider fan-out - // stays unsupported. - false - } - - async fn send( - &self, - request: PlatformHttpRequest, - ) -> Result> { - Self::execute(request).await - } - - async fn send_async( - &self, - request: PlatformHttpRequest, - ) -> Result> { - let backend_name = request.backend_name.clone(); - let pending = - PlatformPendingResponse::start(Self::execute(request), spin_sdk::wasip3::spawn); - Ok(PlatformPendingRequest::new(pending).with_backend_name(backend_name)) - } - - async fn select( - &self, - mut pending_requests: Vec, - ) -> Result> { - if pending_requests.is_empty() { - return Err(Report::new(PlatformError::HttpClient) - .attach("select called with an empty pending_requests list")); - } - - if pending_requests.len() >= 2 { - return Err(Report::new(PlatformError::HttpClient).attach(format!( - "SpinPlatformHttpClient: multi-provider fan-out is not supported \ - ({} providers submitted). Configure a single auction provider \ - or use the Fastly adapter for parallel DSP fan-out.", - pending_requests.len() - ))); - } - - let ready_platform = pending_requests.remove(0); - let mut pending = ready_platform - .downcast::>>>() - .map_err(|_| { - Report::new(PlatformError::HttpClient) - .attach("unexpected inner type in SpinPlatformHttpClient::select") - })?; - - let ready = pending.wait().await.unwrap_or_else(|error| { - Err(Report::new(PlatformError::HttpClient) - .attach(format!("dispatched response task stopped: {error}"))) - }); - Ok(PlatformSelectResult { - ready, - remaining: pending_requests, - failed_backend_name: None, - }) - } - - async fn select_until( - &self, - mut pending_requests: Vec, - deadline: web_time::Instant, - ) -> Result, Report> { - if pending_requests.is_empty() { - return Err(Report::new(PlatformError::HttpClient) - .attach("select_until called with an empty pending_requests list")); - } - if pending_requests.len() > 1 { - return Err(Report::new(PlatformError::HttpClient) - .attach("multi-provider fan-out is not supported on this adapter")); - } - let platform_pending = pending_requests.remove(0); - let backend_name = platform_pending.backend_name().map(str::to_owned); - let mut pending = platform_pending - .downcast::>>>() - .map_err(|_| { - Report::new(PlatformError::HttpClient) - .attach("unexpected pending request type in deadline selection") - })?; - let result = pending - .complete_before_deadline( - async { - let remaining = deadline.saturating_duration_since(web_time::Instant::now()); - if !remaining.is_zero() { - spin_sdk::time::sleep(remaining).await; - } - }, - deadline, - web_time::Instant::now, - ) - .await; - Ok(result.map(|result| { - let ready = result.unwrap_or_else(|error| { - Err(Report::new(PlatformError::HttpClient) - .attach(format!("dispatched response task stopped: {error}"))) - }); - PlatformSelectResult { - failed_backend_name: ready.as_ref().err().and(backend_name), - ready, - remaining: pending_requests, - } - })) - } -} - -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -fn into_spin_method(method: &edgezero_core::http::Method) -> spin_sdk::http::Method { - spin_sdk::http::Method::from_bytes(method.as_str().as_bytes()) - .expect("should convert valid HTTP method") -} - // --------------------------------------------------------------------------- // SpinSecretStoreAdapter — WASM target + spin feature only // --------------------------------------------------------------------------- @@ -730,11 +270,6 @@ impl PlatformSecretStore for SpinSecretStoreAdapter { pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices { let client_ip = extract_client_ip(ctx); - #[cfg(all(feature = "spin", target_arch = "wasm32"))] - let http_client: Arc = Arc::new(SpinPlatformHttpClient); - #[cfg(not(all(feature = "spin", target_arch = "wasm32")))] - let http_client: Arc = Arc::new(UnavailableHttpClient); - let config_store: Arc = ctx .config_store_default() .map(|h| Arc::new(ConfigStoreHandleAdapter(h)) as Arc) @@ -755,7 +290,7 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R .secret_store(secret_store) .kv_store(kv_store) .backend(Arc::new(NoopBackend)) - .http_client(http_client) + .http_client(EdgeZeroHttpClient::for_request(ctx, AuctionTargetId::Spin)) .geo(Arc::new(NullGeo)) .client_info(ClientInfo { client_ip, @@ -802,7 +337,8 @@ impl PlatformGeo for NullGeo { /// Cloudflare adapters receive. Deployments fronting Spin with such a hop must /// account for this. fn extract_client_ip(ctx: &edgezero_core::context::RequestContext) -> Option { - edgezero_adapter_spin::context::SpinRequestContext::get(ctx.request()) + ctx.extensions() + .get::() .and_then(|c| c.client_addr) } @@ -822,8 +358,10 @@ impl trusted_server_core::platform::PlatformTimer for SpinTimer { mod tests { use super::*; + use edgezero_core::ConfigValue; use edgezero_core::body::Body; - use edgezero_core::config_store::{ConfigStore, ConfigStoreError}; + use edgezero_core::config_store::{BoundedStoreRead, ConfigStore, ConfigStoreError}; + use edgezero_core::time::{Deadline, MonotonicClock}; use trusted_server_core::platform::AuctionTargetId; #[test] @@ -834,16 +372,40 @@ mod tests { use edgezero_core::context::RequestContext; use edgezero_core::http::request_builder; use edgezero_core::params::PathParams; - use flate2::Compression; - use flate2::write::GzEncoder; - use std::io::Write as _; struct InMemoryConfigStore(std::collections::BTreeMap); #[async_trait::async_trait(?Send)] impl ConfigStore for InMemoryConfigStore { - async fn get(&self, key: &str) -> Result, ConfigStoreError> { - Ok(self.0.get(key).cloned()) + async fn get(&self, key: &str) -> Result, ConfigStoreError> { + Ok(self.0.get(key).cloned().map(ConfigValue::from)) + } + + async fn get_bounded( + &self, + key: &str, + clock: &MonotonicClock, + deadline: Deadline, + max_backend_bytes: u64, + max_value_bytes: u64, + ) -> Result, ConfigStoreError> { + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + let value = self.get(key).await?; + let backend_bytes = value.as_ref().map_or(0, |value| { + u64::try_from(value.as_str().len()).expect("should fit the fixture length") + }); + if deadline.is_expired_at(clock.now()) { + return Err(ConfigStoreError::DeadlineExceeded); + } + if backend_bytes > max_backend_bytes.min(max_value_bytes) { + return Err(ConfigStoreError::ValueTooLarge); + } + Ok(BoundedStoreRead { + value, + backend_bytes, + }) } } @@ -856,64 +418,6 @@ mod tests { RequestContext::new(req, PathParams::default()) } - fn gzip_bytes(input: &[u8]) -> Vec { - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder - .write_all(input) - .expect("should write gzip test payload"); - encoder.finish().expect("should finish gzip test payload") - } - - fn brotli_bytes(input: &[u8]) -> Vec { - let mut compressed = Vec::new(); - { - let mut compressor = brotli::CompressorWriter::new(&mut compressed, 4096, 5, 21); - compressor - .write_all(input) - .expect("should write brotli test payload"); - } - compressed - } - - fn platform_request() -> PlatformHttpRequest { - let req = request_builder() - .method("GET") - .uri("https://origin.example/asset.png") - .body(Body::empty()) - .expect("should build platform HTTP request"); - PlatformHttpRequest::new(req, "origin") - } - - fn apply_get_response_policy( - headers: HeaderPairs, - body: Vec, - ) -> Result> { - apply_spin_response_policy(&edgezero_core::http::Method::GET, 200, headers, body) - } - - #[test] - fn response_policy_preserves_original_redirect_at_single_send_boundary() { - let (headers, body) = apply_spin_response_policy( - &edgezero_core::http::Method::GET, - 302, - vec![( - "location".to_string(), - b"https://redirect.example/next".to_vec(), - )], - Vec::new(), - ) - .expect("should preserve redirect response"); - - assert_eq!( - headers, - vec![( - "location".to_string(), - b"https://redirect.example/next".to_vec(), - )] - ); - assert!(body.is_empty()); - } - #[test] fn extract_client_ip_reads_spin_request_context() { let mut req = request_builder() @@ -1103,286 +607,6 @@ mod tests { ); } - #[test] - fn unsupported_request_contracts_reject_image_optimizer_metadata() { - use trusted_server_core::platform::{ - PlatformImageOptimizerOptions, PlatformImageOptimizerParams, - }; - - let request = platform_request().with_image_optimizer(PlatformImageOptimizerOptions::new( - "us_west", - PlatformImageOptimizerParams::default(), - )); - - assert!( - reject_unsupported_request_contracts(&request).is_err(), - "Spin must reject Image Optimizer metadata instead of silently dropping it" - ); - } - - #[test] - fn unsupported_request_contracts_reject_stream_response() { - let request = platform_request().with_stream_response(); - - assert!( - reject_unsupported_request_contracts(&request).is_err(), - "Spin must reject streaming-response requests instead of buffering silently" - ); - } - - #[test] - fn response_policy_strips_transfer_encoding() { - let (headers, body) = apply_get_response_policy( - vec![ - ("transfer-encoding".to_string(), b"chunked".to_vec()), - ("x-preserve".to_string(), b"yes".to_vec()), - ], - b"ok".to_vec(), - ) - .expect("should apply response policy"); - - assert_eq!(body, b"ok", "should preserve body"); - assert!( - headers.iter().all(|(name, _)| name != "transfer-encoding"), - "should strip transfer-encoding" - ); - assert!( - headers.iter().any(|(name, _)| name == "x-preserve"), - "should preserve ordinary headers" - ); - } - - #[test] - fn response_policy_strips_headers_named_by_connection() { - let (headers, _) = apply_get_response_policy( - vec![ - ("connection".to_string(), b"keep-alive, x-remove".to_vec()), - ("x-remove".to_string(), b"drop".to_vec()), - ("x-keep".to_string(), b"keep".to_vec()), - ], - b"ok".to_vec(), - ) - .expect("should apply response policy"); - - assert!( - headers.iter().all(|(name, _)| name != "connection"), - "should strip connection" - ); - assert!( - headers.iter().all(|(name, _)| name != "x-remove"), - "should strip headers named by connection" - ); - assert!( - headers.iter().any(|(name, _)| name == "x-keep"), - "should preserve ordinary response headers" - ); - } - - #[test] - fn response_policy_decodes_gzip_and_strips_encoding_headers() { - let (headers, body) = apply_get_response_policy( - vec![ - ("content-encoding".to_string(), b"gzip".to_vec()), - ("content-length".to_string(), b"999".to_vec()), - ("content-type".to_string(), b"text/plain".to_vec()), - ], - gzip_bytes(b"hello gzip"), - ) - .expect("should decode gzip body"); - - assert_eq!(body, b"hello gzip", "should decode gzip payload"); - assert!( - headers.iter().all(|(name, _)| { - !name.eq_ignore_ascii_case("content-encoding") - && !name.eq_ignore_ascii_case("content-length") - }), - "should strip content-encoding and content-length after decode" - ); - assert!( - headers - .iter() - .any(|(name, value)| name == "content-type" && value == b"text/plain"), - "should preserve ordinary headers" - ); - } - - #[test] - fn response_policy_decodes_brotli_and_strips_encoding_headers() { - let (headers, body) = apply_get_response_policy( - vec![ - ("content-encoding".to_string(), b"br".to_vec()), - ("content-length".to_string(), b"999".to_vec()), - ], - brotli_bytes(b"hello brotli"), - ) - .expect("should decode brotli body"); - - assert_eq!(body, b"hello brotli", "should decode brotli payload"); - assert!( - headers.iter().all(|(name, _)| { - !name.eq_ignore_ascii_case("content-encoding") - && !name.eq_ignore_ascii_case("content-length") - }), - "should strip content-encoding and content-length after decode" - ); - } - - #[test] - fn response_policy_preserves_unsupported_encoding() { - let (headers, body) = apply_get_response_policy( - vec![("content-encoding".to_string(), b"zstd".to_vec())], - b"raw body".to_vec(), - ) - .expect("should preserve unsupported encoding"); - - assert_eq!(body, b"raw body", "should preserve raw body bytes"); - assert!( - headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case("content-encoding") && value == b"zstd" - }), - "should preserve unsupported encoding header" - ); - } - - #[test] - fn response_policy_skips_decoding_for_head_responses() { - let (headers, body) = apply_spin_response_policy( - &edgezero_core::http::Method::HEAD, - 200, - vec![ - ("content-encoding".to_string(), b"gzip".to_vec()), - ("content-length".to_string(), b"999".to_vec()), - ("transfer-encoding".to_string(), b"chunked".to_vec()), - ], - Vec::new(), - ) - .expect("should apply HEAD response policy without decoding"); - - assert!(body.is_empty(), "should preserve the empty HEAD body"); - assert!( - headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case("content-encoding") && value.as_slice() == b"gzip" - }), - "should preserve content-encoding on HEAD responses" - ); - assert!( - headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case("content-length") && value.as_slice() == b"999" - }), - "should preserve content-length on HEAD responses" - ); - assert!( - headers - .iter() - .all(|(name, _)| !name.eq_ignore_ascii_case("transfer-encoding")), - "should still strip hop-by-hop headers on HEAD responses" - ); - } - - #[test] - fn response_policy_skips_decoding_for_no_body_statuses() { - for status in [100, 101, 204, 304] { - let (headers, body) = apply_spin_response_policy( - &edgezero_core::http::Method::GET, - status, - vec![ - ("content-encoding".to_string(), b"gzip".to_vec()), - ("content-length".to_string(), b"999".to_vec()), - ("connection".to_string(), b"close".to_vec()), - ], - Vec::new(), - ) - .expect("should apply no-body response policy without decoding"); - - assert!( - body.is_empty(), - "status {status} should preserve the empty body" - ); - assert!( - headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case("content-encoding") && value.as_slice() == b"gzip" - }), - "status {status} should preserve content-encoding" - ); - assert!( - headers.iter().any(|(name, value)| { - name.eq_ignore_ascii_case("content-length") && value.as_slice() == b"999" - }), - "status {status} should preserve content-length" - ); - assert!( - headers - .iter() - .all(|(name, _)| !name.eq_ignore_ascii_case("connection")), - "status {status} should still strip hop-by-hop headers" - ); - } - } - - #[test] - fn response_policy_reports_gzip_decode_failure() { - let result = apply_get_response_policy( - vec![("content-encoding".to_string(), b"gzip".to_vec())], - b"not gzip".to_vec(), - ); - - assert!(result.is_err(), "should reject invalid gzip payload"); - } - - #[test] - fn response_policy_rejects_oversized_identity_body() { - let oversized = vec![0u8; MAX_DECOMPRESSED_SIZE + 1]; - let result = apply_get_response_policy(Vec::new(), oversized); - - assert!( - result.is_err(), - "should reject an identity response larger than the size ceiling" - ); - } - - #[test] - fn response_policy_rejects_oversized_unsupported_encoding_body() { - let oversized = vec![0u8; MAX_DECOMPRESSED_SIZE + 1]; - let result = apply_get_response_policy( - vec![("content-encoding".to_string(), b"zstd".to_vec())], - oversized, - ); - - assert!( - result.is_err(), - "should reject an unsupported-encoding response larger than the size ceiling" - ); - } - - #[test] - fn response_policy_allows_body_at_size_ceiling() { - let at_limit = vec![0u8; MAX_DECOMPRESSED_SIZE]; - let (_, body) = apply_get_response_policy(Vec::new(), at_limit) - .expect("should accept an identity body exactly at the ceiling"); - - assert_eq!( - body.len(), - MAX_DECOMPRESSED_SIZE, - "should pass through a body at the ceiling unchanged" - ); - } - - #[test] - fn decompression_limit_is_enforced() { - let compressed = gzip_bytes(b"expanded body"); - let result = decompress_body(&compressed, "gzip", 4); - - assert!( - result.is_err(), - "should reject decoded bodies larger than the configured limit" - ); - assert_eq!( - MAX_DECOMPRESSED_SIZE, - 8 * 1024 * 1024, - "production limit must stay within Spin WASM heap budget" - ); - } - #[test] fn spin_variable_name_rejects_digit_leading_key() { // Encoder enforces the lowercase-letter start contract at the boundary so @@ -1402,67 +626,4 @@ mod tests { "should reject uppercase-leading key at the encoder boundary" ); } - - #[test] - fn wasi_forbidden_headers_are_identified() { - for header in &[ - "host", - "Host", - "HOST", - "connection", - "keep-alive", - "transfer-encoding", - "upgrade", - "proxy-connection", - ] { - assert!( - is_wasi_forbidden_outbound_header(header), - "{header} should be forbidden in WASI HTTP outbound requests" - ); - } - for header in &[ - "user-agent", - "accept", - "content-type", - "x-custom", - "authorization", - ] { - assert!( - !is_wasi_forbidden_outbound_header(header), - "{header} should be allowed in WASI HTTP outbound requests" - ); - } - } -} - -#[cfg(test)] -mod deadline_tests { - use futures::executor::block_on; - - #[test] - fn spin_body_collection_rejects_oversized_stream() { - let body = http_body_util::Full::new(bytes::Bytes::from(vec![ - 0; - super::MAX_DECOMPRESSED_SIZE - + 1 - ])); - let result = block_on(super::collect_limited_body(body)); - assert!( - result.is_err(), - "should reject a response exceeding the streamed body cap" - ); - } - - #[test] - fn spin_body_collection_accepts_body_at_limit() { - let body = - http_body_util::Full::new(bytes::Bytes::from(vec![0; super::MAX_DECOMPRESSED_SIZE])); - let result = block_on(super::collect_limited_body(body)) - .expect("should collect a body at the limit"); - assert_eq!( - result.len(), - super::MAX_DECOMPRESSED_SIZE, - "should retain the complete permitted body" - ); - } } diff --git a/crates/trusted-server-adapter-spin/tests/runtime/deadline.rs b/crates/trusted-server-adapter-spin/tests/runtime/deadline.rs index 95cd0013da..546c95f8a8 100644 --- a/crates/trusted-server-adapter-spin/tests/runtime/deadline.rs +++ b/crates/trusted-server-adapter-spin/tests/runtime/deadline.rs @@ -1,28 +1,44 @@ -//! Local runtime fixture exercising the production HTTP client implementation. +//! Local fixture using the production request-local EdgeZero client and driver. +#![cfg(all(feature = "spin", target_arch = "wasm32"))] -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -#[allow(dead_code)] -#[path = "../../src/platform.rs"] -mod platform; - -#[cfg(all(feature = "spin", target_arch = "wasm32"))] use std::time::Duration; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -use trusted_server_core::platform::{PlatformHttpClient as _, PlatformHttpRequest}; -#[cfg(all(feature = "spin", target_arch = "wasm32"))] +use edgezero_core::app::Hooks; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::Response; +use edgezero_core::router::RouterService; +use trusted_server_core::platform::{ + AuctionTargetId, EdgeZeroHttpClient, PlatformError, PlatformHttpRequest, +}; + +struct DeadlineFixture; + +impl Hooks for DeadlineFixture { + fn routes() -> Result { + Ok(RouterService::builder() + .get("/", |context: RequestContext| async move { + exercise(context) + .await + .map(|result| Response::new(Body::from(result))) + .map_err(|_| EdgeError::bad_gateway("deadline fixture exchange fails")) + }) + .build()) + } +} + #[spin_sdk::http_service] -async fn handle(request: spin_sdk::http::Request) -> Result { - let result = exercise(&request.uri().to_string()) +async fn handle( + request: spin_sdk::http::Request, +) -> Result { + edgezero_adapter_spin::run_app::(request) .await - .map_err(|error| format!("{error:?}"))?; - Ok(result) + .map_err(|_| spin_sdk::http::Error::from("deadline fixture dispatch fails")) } -#[cfg(all(feature = "spin", target_arch = "wasm32"))] -async fn exercise( - uri: &str, -) -> Result> { +async fn exercise(context: RequestContext) -> Result> { + let uri = context.uri().to_string(); let parameter = |name: &str| { uri.split_once('?').and_then(|(_, query)| { query @@ -41,10 +57,10 @@ async fn exercise( .expect("should supply origin delay") .parse() .expect("should parse origin delay"); - let client = platform::SpinPlatformHttpClient; + let client = EdgeZeroHttpClient::for_request(&context, AuctionTargetId::Spin); let request = edgezero_core::http::request_builder() .uri(upstream) - .body(edgezero_core::body::Body::empty()) + .body(Body::empty()) .expect("should construct fixture request"); let started = web_time::Instant::now(); let pending = client @@ -64,7 +80,12 @@ async fn exercise( ); "complete" } - Err(error) if format!("{error:?}").contains("length limit exceeded") => { + Err(error) + if matches!( + error.downcast_ref::(), + Some(EdgeError::ResponseTooLarge { .. }) + ) => + { assert_eq!( result.failed_backend_name.as_deref(), Some("fixture"), diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index 9b185e0430..e5e16c792f 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -76,6 +76,7 @@ temp-env = { workspace = true } tokio = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +edgezero-adapter-axum = { workspace = true, features = ["axum"] } tempfile = { workspace = true } [build-dependencies] diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 8f9c775b8c..684710ff0b 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -44,7 +44,7 @@ const MAX_CLIENT_EID_SOURCE_BYTES: usize = 255; /// the largest realistic Prebid-derived auction request (hundreds of ad units /// with EID arrays) while preventing an authenticated client from consuming /// arbitrary WASM linear memory. -const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; +pub const MAX_AUCTION_BODY_SIZE: usize = 256 * 1024; /// Handle auction request from `POST /auction`. /// @@ -708,11 +708,11 @@ mod tests { StubHttpClient, noop_services, }; use crate::platform::{ClientInfo, PlatformHttpClient, PlatformHttpRequest, PlatformResponse}; + use crate::test_support::PreDispatchHookTestExt as _; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; use crate::trace::TracePreDispatchHook; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; - use edgezero_core::router::PreDispatchHook as _; use serde_json::json; use std::sync::{Arc, Mutex}; @@ -1247,7 +1247,7 @@ mod tests { Arc::clone(&settings), Arc::new(|_| panic!("should not load API setup metadata")), ) - .handle(&mut request) + .handle_request(&mut request) .await .expect("should freeze trace gate"); let mut ec_context = make_ec_context(Jurisdiction::NonRegulated, None); @@ -1349,7 +1349,7 @@ mod tests { Arc::clone(&settings), Arc::new(|_| panic!("should not load API setup metadata")), ) - .handle(&mut request) + .handle_request(&mut request) .await .expect("should freeze cookie gate"); let response = handle_auction( @@ -1473,7 +1473,7 @@ mod tests { Arc::new(|_| panic!("should not load setup metadata on API capture")), ); assert!( - hook.handle(&mut request) + hook.handle_request(&mut request) .await .expect("should freeze API cookies") .is_none(), diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index d64a57286d..32e4b7ce88 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -13,7 +13,9 @@ use crate::auction::plan::{ AuctionPlan, AuctionPlanConfig, BidderId, BidderRouteConfig, NotificationConfig, ProviderConfig, ProviderId, RoutingMode, }; -use crate::auction::provider::{GenericOpenRtbProvider, ProviderRequestOutcome}; +use crate::auction::provider::{ + GenericOpenRtbProvider, ProviderRequestOutcome, RoutedProviderDispatch, +}; use crate::auction::routing::route_auction; use crate::auction::test_support::canonical_parity_auction_request; use crate::auction::types::{AdFormat, AdSlot, BidStatus, MediaType}; @@ -1525,11 +1527,13 @@ fn prebid_endpoint_normalization_reaches_generic_execution_and_preserves_custom_ .request_bids_routed( &routed.inputs()[0], &routed, - 321, - 321, - None, - &services, - &mut reserved_backend_names, + RoutedProviderDispatch { + logical_budget_ms: 321, + transport_timeout_ms: 321, + signer: None, + services: &services, + reserved_backend_names: &mut reserved_backend_names, + }, ) .await .expect("should launch one Prebid Server request"); diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index e3e4656cf3..976af1049d 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -17,6 +17,7 @@ use super::openrtb::unused_bidder_params_count; use super::plan::AuctionPlan; use super::provider::{ AuctionProvider, GenericOpenRtbProvider, ProviderParseState, ProviderRequestOutcome, + RoutedProviderDispatch, }; #[cfg(test)] use super::routing::RoutedAuction; @@ -500,11 +501,13 @@ impl AuctionOrchestratorHarness { .request_bids_routed( input, routed, - logical_budget_ms, - transport_timeout_ms, - signer, - context.services, - &mut reserved_backend_names, + RoutedProviderDispatch { + logical_budget_ms, + transport_timeout_ms, + signer, + services: context.services, + reserved_backend_names: &mut reserved_backend_names, + }, ) .await { @@ -1844,11 +1847,13 @@ impl AuctionOrchestrator { .request_bids_routed( input, &routed, - logical_budget_ms, - transport_timeout_ms, - signer.as_ref(), - context.services, - &mut reserved_backend_names, + RoutedProviderDispatch { + logical_budget_ms, + transport_timeout_ms, + signer: signer.as_ref(), + services: context.services, + reserved_backend_names: &mut reserved_backend_names, + }, ) .await { diff --git a/crates/trusted-server-core/src/auction/provider.rs b/crates/trusted-server-core/src/auction/provider.rs index d68ebde4d8..0b25c89528 100644 --- a/crates/trusted-server-core/src/auction/provider.rs +++ b/crates/trusted-server-core/src/auction/provider.rs @@ -94,8 +94,8 @@ pub trait AuctionProvider: Send + Sync { /// an immediate response for a legitimate outcome that requires no HTTP call. /// The orchestrator handles waiting for and parsing pending responses. /// Outbound auction and mediator requests must use - /// [`PlatformHttpRequest::with_cache_bypass`]: they carry personalized data, - /// and Fastly requires this marker for deadline-bounded collection. + /// [`PlatformHttpRequest::with_cache_bypass`]: personalized bids must not + /// be replayed from an intermediary cache. /// /// # Errors /// @@ -223,6 +223,20 @@ pub(crate) enum GenericOpenRtbParseState { }, } +/// Per-launch policy and resources for one routed provider request. +pub(crate) struct RoutedProviderDispatch<'a> { + /// Remaining logical auction budget used in the request payload. + pub(crate) logical_budget_ms: u32, + /// Existing adapter-specific transport phase cap. + pub(crate) transport_timeout_ms: u32, + /// Optional auction-local signer. + pub(crate) signer: Option<&'a RequestSigner>, + /// Request-local platform services. + pub(crate) services: &'a RuntimeServices, + /// Backend names already owned by another provider in this auction. + pub(crate) reserved_backend_names: &'a mut HashSet, +} + impl GenericOpenRtbProvider { pub(crate) fn new(plan: ProviderPlan) -> Self { Self { plan } @@ -266,20 +280,19 @@ impl GenericOpenRtbProvider { /// execution context here, while this method reuses /// [`ProviderRequestOutcome`] and [`ProviderParseState`] for the existing /// request/parse token boundary. - #[allow( - clippy::too_many_arguments, - reason = "the internal driver keeps routed inputs, both budgets, signer, services, and collision state explicit" - )] pub(crate) async fn request_bids_routed( &self, input: &ProviderAuctionInput, routed: &RoutedAuction, - logical_budget_ms: u32, - transport_timeout_ms: u32, - signer: Option<&RequestSigner>, - services: &RuntimeServices, - reserved_backend_names: &mut HashSet, + dispatch: RoutedProviderDispatch<'_>, ) -> Result> { + let RoutedProviderDispatch { + logical_budget_ms, + transport_timeout_ms, + signer, + services, + reserved_backend_names, + } = dispatch; let signing_params = SigningParams::new( input.common_request().id.clone(), input.common_request().publisher.domain.clone(), diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index e563fd6163..28e868acf1 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -84,51 +84,94 @@ pub fn enforce_basic_auth( settings: &Settings, req: &mut Request, ) -> Result>, Report> { - // Cleared before any early return so no inherited marker can survive a call - // that did not itself validate a credential. Without this, a request marked - // upstream and then routed to an unprotected path would keep an assertion - // nothing checked. req.extensions_mut().remove::(); + let decision = check_basic_auth( + settings, + req.uri().path(), + req.headers(), + req.extensions().get::().is_some(), + )?; + Ok(apply_auth_decision(decision, req.extensions_mut())) +} + +/// Enforces the same authentication policy without acquiring an input body. +/// +/// # Errors +/// +/// Returns an error when handler configuration is invalid. +/// +/// # Examples +/// +/// ```ignore +/// let response = enforce_basic_auth_parts(&settings, context.parts_mut())?; +/// ``` +pub fn enforce_basic_auth_parts( + settings: &Settings, + parts: &mut edgezero_core::http::RequestParts, +) -> Result>, Report> { + parts.extensions.remove::(); + let decision = check_basic_auth( + settings, + parts.uri.path(), + &parts.headers, + parts.extensions.get::().is_some(), + )?; + Ok(apply_auth_decision(decision, &mut parts.extensions)) +} - let path = req.uri().path(); +#[derive(Clone, Copy)] +enum AuthDecision { + Unprotected, + Authenticated([u8; 32]), + Unauthorized, +} + +fn apply_auth_decision( + decision: AuthDecision, + extensions: &mut http::Extensions, +) -> Option> { + match decision { + AuthDecision::Unprotected => None, + AuthDecision::Authenticated(digest) => { + extensions.insert(EdgeTerminatedAuthorization(digest)); + None + } + AuthDecision::Unauthorized => Some(unauthorized_response()), + } +} + +fn check_basic_auth( + settings: &Settings, + path: &str, + headers: &http::HeaderMap, + trace_logging: bool, +) -> Result> { let Some(handler) = settings.handler_for_path(path)? else { if Settings::is_admin_path(path) { return Err(Report::new(TrustedServerError::Configuration { message: format!("Admin path `{path}` has no configured handler"), })); } - return Ok(None); + return Ok(AuthDecision::Unprotected); }; - - let Some((username, password, authorization_digest)) = extract_credentials(req) else { - return Ok(Some(unauthorized_response())); + let Some((username, password, digest)) = extract_credentials(headers) else { + return Ok(AuthDecision::Unauthorized); }; - // Hash before comparing to normalise lengths — `ct_eq` on raw byte slices - // short-circuits when lengths differ, which would leak credential length. - // SHA-256 produces fixed-size digests so the comparison is truly constant-time. - // - // Note: constant-time guarantees are best-effort on WASM targets because the - // runtime optimiser/JIT may re-introduce variable-time paths. This is an - // inherent limitation of all constant-time code in managed runtimes. + // Compare fixed-size digests, retaining both comparisons even on failure. let username_match = Sha256::digest(handler.username.expose().as_bytes()) .ct_eq(&Sha256::digest(username.as_bytes())); let password_match = Sha256::digest(handler.password.expose().as_bytes()) .ct_eq(&Sha256::digest(password.as_bytes())); - if bool::from(username_match & password_match) { - // Record that TS itself consumed this credential, so the shared template - // cache can distinguish it from a credential meant for the origin. - req.extensions_mut() - .insert(EdgeTerminatedAuthorization(authorization_digest)); - Ok(None) + Ok(AuthDecision::Authenticated(digest)) } else { - if req.extensions().get::().is_some() { + if trace_logging { log::warn!("trace_auth_failed"); } else { - log::warn!("Basic auth failed for path: {}", req.uri().path()); + log::warn!("Basic auth failed for path: {path}"); } - Ok(Some(unauthorized_response())) + Ok(AuthDecision::Unauthorized) } } @@ -142,11 +185,11 @@ pub fn enforce_basic_auth( /// operator credential was used. #[must_use] pub fn authenticated_username(req: &Request) -> Option { - extract_credentials(req).map(|(username, _password, _digest)| username) + extract_credentials(req.headers()).map(|(username, _password, _digest)| username) } -fn extract_credentials(req: &Request) -> Option<(String, String, [u8; 32])> { - let mut header_values = req.headers().get_all(header::AUTHORIZATION).iter(); +fn extract_credentials(headers: &http::HeaderMap) -> Option<(String, String, [u8; 32])> { + let mut header_values = headers.get_all(header::AUTHORIZATION).iter(); let header_value = header_values.next()?; if header_values.next().is_some() { return None; diff --git a/crates/trusted-server-core/src/cache_purge.rs b/crates/trusted-server-core/src/cache_purge.rs index f77f43e4dd..6cd7aea78c 100644 --- a/crates/trusted-server-core/src/cache_purge.rs +++ b/crates/trusted-server-core/src/cache_purge.rs @@ -27,7 +27,7 @@ use crate::http_util::enforce_max_body_size; use crate::platform::{RuntimeServices, reader_url_surrogate_key}; /// Purge bodies name a scope and, for a URL purge, one URL. Nothing here is unbounded. -const PURGE_MAX_BODY_BYTES: usize = 4096; +pub const PURGE_MAX_BODY_BYTES: usize = 4096; /// The request body, before its scope is validated. /// diff --git a/crates/trusted-server-core/src/ec/batch_sync.rs b/crates/trusted-server-core/src/ec/batch_sync.rs index 78d0b57637..9af405d859 100644 --- a/crates/trusted-server-core/src/ec/batch_sync.rs +++ b/crates/trusted-server-core/src/ec/batch_sync.rs @@ -29,6 +29,9 @@ use super::log_id; use super::rate_limiter::RateLimiter; use super::registry::PartnerRegistry; +/// Maximum buffered input for a server-to-server batch sync. +pub const MAX_BODY_SIZE: usize = 2 * 1024 * 1024; + const REASON_INVALID_EC_ID: &str = "invalid_ec_id"; const REASON_INVALID_PARTNER_UID: &str = "invalid_partner_uid"; const REASON_INELIGIBLE: &str = "ineligible"; @@ -143,7 +146,6 @@ async fn handle_batch_sync_with_writer( } // 3. Parse body (with size limit to prevent OOM before validation) - const MAX_BODY_SIZE: usize = 2 * 1024 * 1024; // 2 MB if content_length_exceeds_limit(&req, MAX_BODY_SIZE) { return Ok(error_response( StatusCode::PAYLOAD_TOO_LARGE, diff --git a/crates/trusted-server-core/src/forwarder.rs b/crates/trusted-server-core/src/forwarder.rs index 94a8fc6343..00f874ea92 100644 --- a/crates/trusted-server-core/src/forwarder.rs +++ b/crates/trusted-server-core/src/forwarder.rs @@ -65,14 +65,35 @@ pub fn prepare_trusted_forwarder(request: &mut Request, settings: &Setting let prepared = ForwarderPreparation::capture(request.headers(), settings); request.extensions_mut().insert(prepared); } - request.headers_mut().remove(FORWARDER_AUTH_HEADER); + // The decision is typed metadata; raw fields cannot escape downstream. + strip_forwarding_headers(request.headers_mut(), settings); +} + +/// Captures and removes forwarding fields at the head-only admission boundary. +/// +/// # Examples +/// +/// ```ignore +/// prepare_trusted_forwarder_parts(request.parts_mut(), &settings); +/// ``` +pub fn prepare_trusted_forwarder_parts( + parts: &mut edgezero_core::http::RequestParts, + settings: &Settings, +) { + if parts.extensions.get::().is_none() { + let prepared = ForwarderPreparation::capture(&parts.headers, settings); + parts.extensions.insert(prepared); + } + strip_forwarding_headers(&mut parts.headers, settings); +} + +fn strip_forwarding_headers(headers: &mut HeaderMap, settings: &Settings) { + headers.remove(FORWARDER_AUTH_HEADER); if let Some(config) = &settings.trusted_forwarder { - request.headers_mut().remove(config.auth_header.as_str()); + headers.remove(config.auth_header.as_str()); } - // The decision is now typed metadata. Raw forwarding fields must not - // influence later consumers or escape to publishers and vendors. for name in ["forwarded", "x-forwarded-host", "x-forwarded-proto"] { - request.headers_mut().remove(name); + headers.remove(name); } } @@ -160,6 +181,15 @@ fn parse_public_origin(scheme: &str, authority: &str) -> Option { }) } +pub(crate) fn public_origin_parts( + parts: &edgezero_core::http::RequestParts, +) -> Option<&PublicOrigin> { + parts + .extensions + .get::() + .and_then(|prepared| prepared.origin.as_ref()) +} + pub(crate) fn public_origin(request: &Request) -> Option<&PublicOrigin> { request .extensions() diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 4a6bf1edb4..e9c8486e49 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -60,10 +60,24 @@ pub const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ pub fn sanitize_trusted_client_ip_headers( req: &mut Request, config: Option<&TrustedClientIpConfig>, +) { + sanitize_trusted_client_ip_header_map(req.headers_mut(), config); +} + +/// Removes configured client-IP trust fields without acquiring the body. +/// +/// # Examples +/// +/// ```ignore +/// sanitize_trusted_client_ip_header_map(context.headers_mut(), settings.trusted_client_ip.as_ref()); +/// ``` +pub fn sanitize_trusted_client_ip_header_map( + headers: &mut http::HeaderMap, + config: Option<&TrustedClientIpConfig>, ) { if let Some(config) = config { - req.headers_mut().remove(config.ip_header.as_str()); - req.headers_mut().remove(config.auth_header.as_str()); + headers.remove(config.ip_header.as_str()); + headers.remove(config.auth_header.as_str()); } } @@ -73,10 +87,21 @@ pub fn sanitize_trusted_client_ip_headers( /// `X-Forwarded-Host: evil.com` from hijacking all URL rewriting. /// See . pub fn sanitize_forwarded_headers(req: &mut Request) { + sanitize_forwarded_header_map(req.headers_mut()); +} + +/// Removes spoofable forwarding fields without acquiring the input body. +/// +/// # Examples +/// +/// ```ignore +/// sanitize_forwarded_header_map(context.headers_mut()); +/// ``` +pub fn sanitize_forwarded_header_map(headers: &mut http::HeaderMap) { for header in SPOOFABLE_FORWARDED_HEADERS { - if req.headers().contains_key(*header) { + if headers.contains_key(*header) { log::debug!("Stripped spoofable header: {header}"); - req.headers_mut().remove(*header); + headers.remove(*header); } } } diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 74a21a64fe..8a92369c29 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -949,8 +949,9 @@ impl GoogleTagManagerIntegration { body: EdgeBody, max_size: usize, ) -> Result, PayloadSizeError> { - match body { - EdgeBody::Once(bytes) => { + match body.into_stream() { + Err(body) => { + let bytes = body.into_bytes().expect("should retain the buffered body"); if bytes.len() > max_size { log::warn!( "POST body size {} exceeds max {} (rejected before proxy)", @@ -964,7 +965,7 @@ impl GoogleTagManagerIntegration { } Ok(bytes.to_vec()) } - EdgeBody::Stream(mut stream) => { + Ok(mut stream) => { let mut body_bytes = Vec::new(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.map_err(|error| { diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index c8a61b9388..8e8a35d551 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -1470,7 +1470,7 @@ mod tests { assert_eq!(success.status(), StatusCode::OK); assert_eq!(streaming_stub.recorded_stream_response_flags(), vec![true]); assert!( - matches!(success.body(), EdgeBody::Stream(_)), + success.body().is_stream(), "streaming-capable clients should preserve the origin response stream" ); let streamed_body = success diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 953db44931..2323cbf201 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -186,8 +186,9 @@ pub(crate) async fn collect_body_bounded( max_bytes: usize, integration: &'static str, ) -> Result, Report> { - match body { - EdgeBody::Once(bytes) => { + match body.into_stream() { + Err(body) => { + let bytes = body.into_bytes().expect("should retain the buffered body"); if bytes.len() > max_bytes { return Err(Report::new(TrustedServerError::RequestTooLarge { message: format!( @@ -198,7 +199,7 @@ pub(crate) async fn collect_body_bounded( } Ok(bytes.to_vec()) } - EdgeBody::Stream(mut stream) => { + Ok(mut stream) => { let mut body_bytes = Vec::new(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.map_err(|error| { @@ -246,8 +247,9 @@ pub(crate) async fn collect_response_bounded( max_bytes: usize, integration: &'static str, ) -> Result, Report> { - match body { - EdgeBody::Once(bytes) => { + match body.into_stream() { + Err(body) => { + let bytes = body.into_bytes().expect("should retain the buffered body"); if bytes.len() > max_bytes { return Err(Report::new(TrustedServerError::Integration { integration: integration.to_string(), @@ -259,7 +261,7 @@ pub(crate) async fn collect_response_bounded( } Ok(bytes.to_vec()) } - EdgeBody::Stream(mut stream) => { + Ok(mut stream) => { let mut body_bytes = Vec::new(); while let Some(chunk_result) = stream.next().await { let chunk = chunk_result.map_err(|error| { diff --git a/crates/trusted-server-core/src/integrations/sourcepoint.rs b/crates/trusted-server-core/src/integrations/sourcepoint.rs index aa88ac08a9..4acc5fb2d8 100644 --- a/crates/trusted-server-core/src/integrations/sourcepoint.rs +++ b/crates/trusted-server-core/src/integrations/sourcepoint.rs @@ -1183,7 +1183,7 @@ mod tests { ) -> Result> { let mut response = self.stub.send(request).await?; let body = std::mem::replace(response.response.body_mut(), EdgeBody::empty()); - let EdgeBody::Once(bytes) = body else { + let Some(bytes) = body.into_bytes() else { panic!("should receive a buffered stub body"); }; let reads = Arc::clone(&self.reads); @@ -1308,7 +1308,7 @@ mod tests { .expect("should pass through encoded responses regardless of size"); assert!( - matches!(response.body(), EdgeBody::Stream(_)), + response.body().is_stream(), "should leave encoded response bodies streaming" ); assert_eq!( @@ -1542,7 +1542,7 @@ mod tests { .expect("should pass through a declared oversized response"); assert!( - matches!(response.body(), EdgeBody::Stream(_)), + response.body().is_stream(), "should retain the original stream" ); assert_eq!( @@ -1610,7 +1610,7 @@ mod tests { .expect("should pass through an ineligible response"); assert!( - matches!(response.body(), EdgeBody::Stream(_)), + response.body().is_stream(), "should leave ineligible bodies streaming" ); assert_eq!( @@ -2655,10 +2655,7 @@ mod tests { } fn take_body_bytes(resp: Response) -> Vec { - match resp.into_body() { - EdgeBody::Once(b) => b.to_vec(), - EdgeBody::Stream(_) => vec![], - } + resp.into_body().into_bytes().unwrap_or_default().to_vec() } #[test] diff --git a/crates/trusted-server-core/src/platform/http.rs b/crates/trusted-server-core/src/platform/http.rs index e1fe8ff1f2..bdea92ce84 100644 --- a/crates/trusted-server-core/src/platform/http.rs +++ b/crates/trusted-server-core/src/platform/http.rs @@ -75,6 +75,18 @@ pub struct PlatformHttpRequest { /// Adapters that cannot preserve streaming response bodies should return an /// error rather than silently buffering large asset responses. pub stream_response: bool, + /// Maximum encoded outbound upload size. + pub max_request_body_bytes: u64, + /// Explicit upstream response ceiling, independent of streaming selection. + /// + /// Absent limits retain the target's buffered default and do not add a stream cap. + pub max_response_body_bytes: Option, + /// Original absolute request budget, when supplied by a caller. + pub deadline: Option, + /// Explicit total transport budget, including response body reads. + /// + /// When absent, the SDK applies its own timeout/deadline contract. + pub timeout: Option, } impl PlatformHttpRequest { @@ -87,9 +99,39 @@ impl PlatformHttpRequest { image_optimizer: None, cache_intent: PlatformCacheIntent::Default, stream_response: false, + max_request_body_bytes: edgezero_core::outbound::DEFAULT_OUTBOUND_REQUEST_BODY_BYTES, + max_response_body_bytes: None, + deadline: None, + timeout: None, } } + /// Sets the response byte ceiling without changing streaming selection. + #[must_use] + pub fn with_response_limit(mut self, max_bytes: u64) -> Self { + self.max_response_body_bytes = Some(max_bytes); + self + } + + /// Sets the complete transport budget. + #[must_use] + pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Sets an absolute deadline without restarting the caller's budget. + #[must_use] + pub fn with_deadline( + mut self, + deadline: edgezero_core::time::Deadline, + timeout: std::time::Duration, + ) -> Self { + self.deadline = Some(deadline); + self.timeout = Some(timeout); + self + } + /// Attach Image Optimizer metadata to the request. /// /// The current Fastly adapter supports this on [`PlatformHttpClient::send`] diff --git a/crates/trusted-server-core/src/platform/image_optimizer.rs b/crates/trusted-server-core/src/platform/image_optimizer.rs index 9a3f1709eb..3839bcbb24 100644 --- a/crates/trusted-server-core/src/platform/image_optimizer.rs +++ b/crates/trusted-server-core/src/platform/image_optimizer.rs @@ -2,9 +2,9 @@ //! //! These DTOs describe the closed image transformation set that Trusted Server //! attaches to an outbound asset proxy request. They intentionally avoid Fastly -//! SDK types so the profile-table logic can stay in core while the Fastly -//! adapter remains responsible for translating metadata into -//! `fastly::image_optimizer::ImageOptimizerOptions`. +//! SDK types. The shared HTTP bridge translates them into +//! [`edgezero_core::outbound::OutboundImageOptimizerOptions`], which the +//! `EdgeZero` Fastly adapter applies. //! //! Unsupported adapters should reject requests carrying this metadata rather //! than silently dropping transformations. @@ -55,10 +55,9 @@ impl PlatformImageOptimizerRegion { /// Platform-neutral Image Optimizer options for an upstream request. /// -/// Core code stores only a closed transformation set here. The Fastly adapter is -/// responsible for translating these values to SDK-specific -/// `ImageOptimizerOptions`, while adapters without Image Optimizer support -/// should reject requests carrying this metadata. +/// Core code stores a closed transformation set here. The shared HTTP bridge +/// translates it into [`edgezero_core::outbound::OutboundImageOptimizerOptions`]. +/// Unsupported targets reject the metadata rather than dropping it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PlatformImageOptimizerOptions { /// Image Optimizer processing region understood by the target adapter. diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index e4733106e3..74a301c6e4 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -39,7 +39,7 @@ mod error; mod http; mod image_optimizer; mod kv; -mod pending_response; +mod outbound; mod template_assembly; mod template_cache; #[cfg(test)] @@ -63,7 +63,7 @@ pub use image_optimizer::{ PlatformImageOptimizerParams, PlatformImageOptimizerRegion, }; pub use kv::UnavailableKvStore; -pub use pending_response::PlatformPendingResponse; +pub use outbound::EdgeZeroHttpClient; pub use template_assembly::{ PlatformTemplateAssembler, TemplateAssemblyError, UnavailableTemplateAssembler, contains_publisher_esi_directive, diff --git a/crates/trusted-server-core/src/platform/outbound.rs b/crates/trusted-server-core/src/platform/outbound.rs new file mode 100644 index 0000000000..c692f0c47d --- /dev/null +++ b/crates/trusted-server-core/src/platform/outbound.rs @@ -0,0 +1,485 @@ +//! TS request policy on the single request-scoped `EdgeZero` transport. + +#[cfg(all(test, not(target_arch = "wasm32")))] +#[path = "outbound_native_tests.rs"] +mod native_tests; + +use std::sync::Arc; +use std::task::{Context, Poll}; + +use async_trait::async_trait; +use edgezero_core::http::{HeaderValue, header}; +use edgezero_core::outbound::{ + HttpClient, OutboundCachePolicy, OutboundCropFocus, OutboundImageFormat, + OutboundImageOptimizerOptions, OutboundImageOptimizerRegion, OutboundPending, OutboundRequest, + OutboundResizeFilter, OutboundSendMode, OutboundTime, ResponseDecoding, validate_for_dispatch, +}; +use edgezero_core::time::Deadline; +use error_stack::{Report, ResultExt as _}; +use futures::future::{Either, poll_fn, select}; +use web_time::Instant; + +use super::{ + AuctionTargetId, PlatformCacheIntent, PlatformError, PlatformHttpClient, PlatformHttpRequest, + PlatformImageOptimizerOptions, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, +}; + +/// Applies TS correlation and collection policy to EdgeZero-owned exchanges. +/// +/// The incoming request supplies the transport, progress driver and paired time. +/// This type creates no transport, executor, SDK handle or independent timer. +pub struct EdgeZeroHttpClient { + client: HttpClient, + time: OutboundTime, + target: AuctionTargetId, +} + +impl EdgeZeroHttpClient { + /// Uses the incoming request's client and its existing clock/timer factory. + /// + /// # Examples + /// + /// ```ignore + /// let client = EdgeZeroHttpClient::new(request_client, AuctionTargetId::Axum); + /// ``` + #[must_use] + pub fn new(client: HttpClient, target: AuctionTargetId) -> Self { + let time = client.time(); + Self { + client, + time, + target, + } + } + + /// Uses only the incoming request's transport, with no process-wide fallback. + /// + /// # Examples + /// + /// ```ignore + /// let client = EdgeZeroHttpClient::for_request(&context, AuctionTargetId::Axum); + /// ``` + #[must_use] + pub fn for_request( + context: &edgezero_core::context::RequestContext, + target: AuctionTargetId, + ) -> Arc { + match context.http_client() { + Some(client) => Arc::new(Self::new(client, target)), + None => Arc::new(super::UnavailableHttpClient), + } + } + + fn prepare( + &self, + request: PlatformHttpRequest, + ) -> Result> { + if request.stream_response && !self.supports_streaming_responses() { + return Err(http_error( + "this target does not support streaming responses", + )); + } + let hosts = request.request.headers().get_all(header::HOST); + let mut hosts = hosts.iter(); + let authority = hosts.next().cloned(); + if hosts.next().is_some() { + return Err(http_error("outbound request has multiple Host values")); + } + let target = request.request.uri().clone(); + let mut outbound = OutboundRequest::from_request(request.request, target) + .change_context(PlatformError::HttpClient)? + .max_request_body_bytes(request.max_request_body_bytes); + if let Some(authority) = authority { + outbound = outbound + .host_authority_override( + authority + .to_str() + .change_context(PlatformError::HttpClient)?, + ) + .change_context(PlatformError::HttpClient)?; + } + if let Some(deadline) = request.deadline { + outbound = outbound.deadline(deadline); + } + if let Some(timeout) = request.timeout { + outbound = outbound.timeout(timeout); + } + let buffered_limit = if request.stream_response { + None + } else { + match self.target { + AuctionTargetId::Fastly | AuctionTargetId::Cloudflare => Some(10 * 1024 * 1024), + AuctionTargetId::Spin => Some(8 * 1024 * 1024), + AuctionTargetId::Axum => None, + } + }; + let response_limit = match (request.max_response_body_bytes, buffered_limit) { + (Some(explicit), Some(target)) => Some(explicit.min(target)), + (explicit, target) => explicit.or(target), + }; + if let Some(limit) = response_limit { + outbound = outbound + .max_response_bytes(limit) + .max_encoded_response_bytes(limit) + .max_decoded_response_bytes(limit); + } + outbound = match self.target { + AuctionTargetId::Fastly => outbound.connect_timeout(std::time::Duration::from_secs(1)), + AuctionTargetId::Axum => outbound.connect_timeout(std::time::Duration::from_secs(5)), + AuctionTargetId::Cloudflare | AuctionTargetId::Spin => outbound, + }; + let cache_policy = match request.cache_intent { + PlatformCacheIntent::Default => OutboundCachePolicy::PlatformDefault, + PlatformCacheIntent::Bypass => OutboundCachePolicy::Bypass, + PlatformCacheIntent::Shared { surrogate_key } => { + match HeaderValue::from_str(&surrogate_key) { + Ok(value) => OutboundCachePolicy::PlatformDefaultWithSurrogateKey(value), + Err(_) => { + log::warn!( + "outbound cache tags are invalid; bypass the intermediary cache" + ); + OutboundCachePolicy::Bypass + } + } + } + }; + outbound = outbound + .cache_policy(cache_policy) + .response_decoding(match self.target { + AuctionTargetId::Fastly | AuctionTargetId::Axum => ResponseDecoding::Original, + AuctionTargetId::Cloudflare | AuctionTargetId::Spin => ResponseDecoding::Decode, + }); + if let Some(options) = request.image_optimizer { + outbound = outbound.image_optimizer(image_options(options)?); + } + if request.stream_response { + outbound = outbound.stream_response(); + } + Ok(outbound) + } + + async fn prepare_pending( + &self, + mut request: PlatformHttpRequest, + ) -> Result> { + // Begin requires a buffered upload. Preserve an explicit caller budget + // across preparation, without making the SDK's default a TS setting. + // The original guarded input remains responsible for ingress constraints. + let timeout = request + .timeout + .map(|timeout| self.time.clock().deadline_after(timeout)); + let deadline = match (request.deadline, timeout) { + (Some(deadline), Some(timeout)) => Some(Deadline::at_instant( + deadline.instant().min(timeout.instant()), + )), + (deadline, timeout) => deadline.or(timeout), + }; + request.deadline = deadline; + let outbound = self.prepare(request)?; + // Validate before reading input so response-owned bodies cannot be + // laundered into fresh upload bytes. Begin's upload check runs after + // bounded materialization; its optimizer restriction remains pre-read. + let mode = if outbound.image_optimizer_options().is_some() { + OutboundSendMode::Begin + } else { + OutboundSendMode::Send + }; + validate_for_dispatch(&outbound, mode).change_context(PlatformError::HttpClient)?; + let mut parts = outbound.into_parts(); + let buffered = Box::pin(async move { + let maximum = usize::try_from(parts.max_request_body_bytes) + .change_context(PlatformError::HttpClient)?; + let body = std::mem::replace(&mut parts.body, edgezero_core::body::Body::empty()); + let bytes = body + .into_bytes_bounded(maximum) + .await + .change_context(PlatformError::HttpClient)?; + parts.body = edgezero_core::body::Body::from_bytes(bytes); + OutboundRequest::from_parts(parts).change_context(PlatformError::HttpClient) + }); + let Some(deadline) = deadline else { + return buffered.await; + }; + match select(self.time.wait_until(deadline), buffered).await { + Either::Left((result, _)) => { + result.change_context(PlatformError::HttpClient)?; + Err(Report::new(PlatformError::DeadlineExceeded)) + } + Either::Right((request, _)) => request, + } + } + + fn collection_handles( + &self, + requests: Vec, + ) -> Result, Report> { + if requests.len() > 1 && !self.supports_concurrent_fanout() { + return Err( + http_error("this target does not support multi-provider fanout") + .attach(format!("{} providers submitted", requests.len())), + ); + } + if requests.is_empty() || requests.iter().any(PlatformPendingRequest::stream_response) { + return Err(http_error( + "fanout collection requires pending buffered exchanges; use wait for streamed responses", + )); + } + recover(requests) + } +} + +struct Pending { + backend_name: String, + pending: OutboundPending, +} + +fn http_error(message: &'static str) -> Report { + Report::new(PlatformError::HttpClient).attach(message) +} + +fn recover(requests: Vec) -> Result, Report> { + requests + .into_iter() + .map(|request| { + request.downcast::().map_err(|request| { + http_error("pending exchange belongs to another client") + .attach(format!("{request:?}")) + }) + }) + .collect() +} + +fn poll_ready(handles: &mut [Pending], context: &mut Context<'_>) -> Poll { + for (index, handle) in handles.iter_mut().enumerate() { + if handle.pending.poll_ready(context).is_ready() { + return Poll::Ready(index); + } + } + Poll::Pending +} + +async fn completed(mut handles: Vec, index: usize) -> PlatformSelectResult { + let selected = handles.swap_remove(index); + let backend_name = selected.backend_name; + let ready = selected + .pending + .collect() + .await + .change_context(PlatformError::HttpClient) + .and_then(|response| { + response + .into_response() + .change_context(PlatformError::HttpClient) + }) + .map(|response| PlatformResponse::new(response).with_backend_name(backend_name.clone())); + let failed_backend_name = ready.as_ref().err().map(|_| backend_name); + let remaining = handles + .into_iter() + .map(|handle| { + let backend = handle.backend_name.clone(); + PlatformPendingRequest::new(handle).with_backend_name(backend) + }) + .collect(); + PlatformSelectResult { + ready, + remaining, + failed_backend_name, + } +} + +#[async_trait(?Send)] +impl PlatformHttpClient for EdgeZeroHttpClient { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let backend_name = request.backend_name.clone(); + let response = self + .client + .send(self.prepare(request)?) + .await + .change_context(PlatformError::HttpClient)? + .into_response() + .change_context(PlatformError::HttpClient)?; + Ok(PlatformResponse::new(response).with_backend_name(backend_name)) + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let backend_name = request.backend_name.clone(); + let streamed = request.stream_response; + let method = request.request.method().clone(); + if streamed && !self.supports_pending_streaming_responses() { + return Err(http_error( + "this target does not support pending streamed responses", + )); + } + let outbound = self.prepare_pending(request).await?; + let pending = self + .client + .begin(outbound) + .await + .change_context(PlatformError::HttpClient)?; + Ok(PlatformPendingRequest::new(Pending { + backend_name: backend_name.clone(), + pending, + }) + .with_backend_name(backend_name) + .with_response_handling(streamed, method)) + } + + async fn select_until( + &self, + requests: Vec, + deadline: Instant, + ) -> Result, Report> { + let mut handles = self.collection_handles(requests)?; + if self.time.clock().now() < deadline { + // Poll/register the paired timer first, before the readiness future + // can drive Fastly progress. Timer expiry must not consume handles. + let ready_index = { + let ready = Box::pin(poll_fn(|context| poll_ready(&mut handles, context))); + match select(self.time.wait_until(Deadline::at_instant(deadline)), ready).await { + Either::Left((result, _)) => { + result.change_context(PlatformError::HttpClient)?; + None + } + Either::Right((index, _)) => Some(index), + } + }; + if let Some(index) = ready_index { + return Ok(Some(completed(handles, index).await)); + } + } + // One non-driving completion snapshot. Dropping unfinished handles + // cancels their work; subsequent calls can drain only this snapshot. + handles.retain_mut(|handle| handle.pending.is_ready()); + if handles.is_empty() { + return Ok(None); + } + Ok(Some(completed(handles, 0).await)) + } + + async fn select( + &self, + requests: Vec, + ) -> Result> { + let mut handles = self.collection_handles(requests)?; + let index = poll_fn(|context| poll_ready(&mut handles, context)).await; + Ok(completed(handles, index).await) + } + + async fn wait( + &self, + request: PlatformPendingRequest, + ) -> Result> { + let mut handles = recover(vec![request])?; + poll_fn(|context| poll_ready(&mut handles, context)).await; + completed(handles, 0).await.ready + } + + fn supports_concurrent_fanout(&self) -> bool { + matches!(self.target, AuctionTargetId::Fastly | AuctionTargetId::Axum) + } + + fn supports_streaming_responses(&self) -> bool { + self.target == AuctionTargetId::Fastly + } + + fn supports_pending_streaming_responses(&self) -> bool { + self.target == AuctionTargetId::Fastly + } +} + +fn image_options( + options: PlatformImageOptimizerOptions, +) -> Result> { + let mut output = OutboundImageOptimizerOptions::new() + .region( + match super::PlatformImageOptimizerRegion::parse(&options.region) { + Some(super::PlatformImageOptimizerRegion::UsEast) => { + OutboundImageOptimizerRegion::UsEast + } + Some(super::PlatformImageOptimizerRegion::UsCentral) => { + OutboundImageOptimizerRegion::UsCentral + } + Some(super::PlatformImageOptimizerRegion::UsWest) => { + OutboundImageOptimizerRegion::UsWest + } + Some(super::PlatformImageOptimizerRegion::EuCentral) => { + OutboundImageOptimizerRegion::EuCentral + } + Some(super::PlatformImageOptimizerRegion::EuWest) => { + OutboundImageOptimizerRegion::EuWest + } + Some(super::PlatformImageOptimizerRegion::Asia) => { + OutboundImageOptimizerRegion::Asia + } + Some(super::PlatformImageOptimizerRegion::Australia) => { + OutboundImageOptimizerRegion::Australia + } + None => return Err(http_error("unsupported image optimizer region")), + }, + ) + .preserve_origin_query(options.preserve_query_string_on_origin_request); + let params = options.params; + if let Some(format) = params.format { + output = output.format(match format.trim().to_ascii_lowercase().as_str() { + "auto" => OutboundImageFormat::Auto, + "avif" => OutboundImageFormat::Avif, + "gif" => OutboundImageFormat::Gif, + "jpg" | "jpeg" => OutboundImageFormat::Jpeg, + "jxl" | "jpegxl" => OutboundImageFormat::JpegXl, + "mp4" => OutboundImageFormat::Mp4, + "png" => OutboundImageFormat::Png, + "webp" => OutboundImageFormat::WebP, + _ => return Err(http_error("unsupported image format")), + }); + } + if let Some(quality) = params.quality { + output = output + .quality(u8::try_from(quality).change_context(PlatformError::HttpClient)?) + .change_context(PlatformError::HttpClient)?; + } + if let Some(filter) = params.resize_filter { + output = output.resize_filter(match filter.trim().to_ascii_lowercase().as_str() { + "bicubic" => OutboundResizeFilter::Bicubic, + "bilinear" => OutboundResizeFilter::Bilinear, + "lanczos2" => OutboundResizeFilter::Lanczos2, + "lanczos3" => OutboundResizeFilter::Lanczos3, + "nearest" => OutboundResizeFilter::Nearest, + _ => return Err(http_error("unsupported image resize filter")), + }); + } + if let Some(width) = params.width { + output = output + .width(width) + .change_context(PlatformError::HttpClient)?; + } + if let Some(height) = params.height { + output = output + .height(height) + .change_context(PlatformError::HttpClient)?; + } + if let Some(crop) = params.crop { + let focus = match (crop.mode, crop.offset_x, crop.offset_y) { + (Some(_), None, None) => OutboundCropFocus::Smart, + (None, None, None) => OutboundCropFocus::Default, + (None, Some(x), Some(y)) => OutboundCropFocus::Percent { + x: u8::try_from(x).change_context(PlatformError::HttpClient)?, + y: u8::try_from(y).change_context(PlatformError::HttpClient)?, + }, + _ => { + return Err(http_error( + "image crop offsets must be paired and cannot accompany smart crop", + )); + } + }; + output = output + .crop(crop.width, crop.height, focus) + .change_context(PlatformError::HttpClient)?; + } + Ok(output) +} diff --git a/crates/trusted-server-core/src/platform/outbound_native_tests.rs b/crates/trusted-server-core/src/platform/outbound_native_tests.rs new file mode 100644 index 0000000000..40fc234280 --- /dev/null +++ b/crates/trusted-server-core/src/platform/outbound_native_tests.rs @@ -0,0 +1,817 @@ +//! Runs the TS collection policy against the actual `EdgeZero` native provider. + +use edgezero_core::body::Body; +use edgezero_core::http::HeaderValue; +use futures::{FutureExt as _, StreamExt as _}; +use http::Request; +use std::time::Duration; +use tokio::task::LocalSet; + +use crate::test_support::native_outbound::{ + Reply, client, closed, gzip_hello, observed, origin, request, +}; + +use super::*; + +async fn ready(request: PlatformPendingRequest) -> PlatformPendingRequest { + let mut pending = request + .downcast::() + .expect("should retain a managed exchange"); + tokio::time::timeout(Duration::from_secs(3), async { + while !pending.pending.is_ready() { + tokio::task::yield_now().await; + } + }) + .await + .expect("should publish a complete native response"); + let backend = pending.backend_name.clone(); + PlatformPendingRequest::new(pending).with_backend_name(backend) +} + +#[tokio::test(flavor = "current_thread")] +async fn collection_cutoff_bounds_headers_and_full_body_not_just_response_head() { + LocalSet::new() + .run_until(async { + for reply in [Reply::HeldHeaders, Reply::HeldBody, Reply::TrickleBody] { + let client = client(); + let mut origin = origin(reply).await; + let pending = client + .send_async(request(&origin.url, "slow")) + .await + .expect("should schedule the request"); + observed(&mut origin).await; + let start = Instant::now(); + let result = client + .select_until(vec![pending], start + Duration::from_millis(80)) + .await + .expect("should enforce the collection cutoff"); + assert!( + result.is_none(), + "should not treat a response head as a complete buffered response" + ); + assert!( + start.elapsed() < Duration::from_secs(1), + "should not wait for the 30-second transport budget" + ); + closed(&mut origin).await; + } + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn completion_wakeup_preserves_a_healthy_sibling_before_the_cutoff() { + LocalSet::new() + .run_until(async { + let client = client(); + let mut slow = origin(Reply::HeldBody).await; + let slow_request = client + .send_async(request(&slow.url, "slow")) + .await + .expect("should schedule the slow request"); + observed(&mut slow).await; + let mut fast = origin(Reply::Complete).await; + let fast_request = client + .send_async(request(&fast.url, "fast")) + .await + .expect("should schedule the fast request"); + let selection = tokio::time::timeout( + Duration::from_secs(3), + client.select_until( + vec![slow_request, fast_request], + Instant::now() + Duration::from_secs(30), + ), + ) + .await + .expect("should wake on completion rather than only the deadline") + .expect("should select a healthy sibling") + .expect("should retain the completed sibling"); + assert_eq!( + selection + .ready + .expect("should receive the healthy response") + .backend_name + .as_deref(), + Some("fast"), + "should preserve backend correlation" + ); + assert_eq!( + selection.remaining.len(), + 1, + "should retain unfinished siblings before cutoff" + ); + drop(selection.remaining); + observed(&mut fast).await; + closed(&mut slow).await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn expired_snapshot_retains_complete_successes_and_failure_but_cancels_unfinished_work() { + LocalSet::new() + .run_until(async { + let client = client(); + let mut slow = origin(Reply::HeldHeaders).await; + let slow_request = client + .send_async(request(&slow.url, "slow")) + .await + .expect("should schedule unfinished work"); + observed(&mut slow).await; + let mut first = origin(Reply::Complete).await; + let first_request = ready( + client + .send_async(request(&first.url, "first")) + .await + .expect("should start first response"), + ) + .await; + let mut second = origin(Reply::Complete).await; + let second_request = ready( + client + .send_async(request(&second.url, "second")) + .await + .expect("should start second response"), + ) + .await; + let mut failed = origin(Reply::Failed).await; + let failed_request = ready( + client + .send_async(request(&failed.url, "failed")) + .await + .expect("should schedule the failing request"), + ) + .await; + let deadline = Instant::now(); + let mut requests = vec![slow_request, first_request, second_request, failed_request]; + let mut successful = Vec::new(); + let mut errors = Vec::new(); + while let Some(selection) = client + .select_until(requests, deadline) + .await + .expect("should drain only the completion snapshot") + { + match selection.ready { + Ok(response) => { + successful.push(response.backend_name.expect("should retain backend name")) + } + Err(_) => errors.push( + selection + .failed_backend_name + .expect("should attribute the failure"), + ), + } + requests = selection.remaining; + if requests.is_empty() { + break; + } + } + successful.sort(); + assert_eq!( + successful, + ["first", "second"], + "should retain both complete buffered siblings after expiry" + ); + assert_eq!( + errors, + ["failed"], + "should retain a completed error without poisoning healthy siblings" + ); + closed(&mut slow).await; + observed(&mut first).await; + observed(&mut second).await; + observed(&mut failed).await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn dropping_a_collection_borrow_cancels_owned_native_work() { + LocalSet::new() + .run_until(async { + let client = client(); + let mut origin = origin(Reply::HeldBody).await; + let pending = client + .send_async(request(&origin.url, "canceled")) + .await + .expect("should schedule native work"); + observed(&mut origin).await; + let mut collection = Box::pin( + client.select_until(vec![pending], Instant::now() + Duration::from_secs(30)), + ); + assert!( + collection.as_mut().now_or_never().is_none(), + "should retain an unfinished collection" + ); + drop(collection); + closed(&mut origin).await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn request_mapping_preserves_host_cookies_and_uses_cache_metadata_not_wire_tags() { + LocalSet::new() + .run_until(async { + let client = client(); + let mut origin = origin(Reply::Complete).await; + let mut outbound = request( + &format!("{}?dup=one&dup=two+space&symbol=%2f", origin.url), + "origin", + ) + .with_shared_cache("ts-template ts-page-fixture"); + outbound.request.headers_mut().insert( + header::HOST, + HeaderValue::from_static("virtual.example.com"), + ); + let response = client + .send(outbound) + .await + .expect("should send through the single native provider"); + let head = observed(&mut origin).await.to_ascii_lowercase(); + assert!( + head.starts_with("get /?dup=one&dup=two+space&symbol=%2f http/1.1\r\n"), + "should retain query ordering, duplicates and percent encoding" + ); + assert!( + head.contains("host: virtual.example.com\r\n"), + "should carry the checked authority override" + ); + assert!( + !head.contains("surrogate-key:"), + "should not send cache override metadata as an origin header" + ); + assert_eq!( + response + .response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 2, + "should retain duplicate Set-Cookie fields" + ); + assert!( + !response.response.headers().contains_key("x-private-hop"), + "should use the shared head finalizer" + ); + assert_eq!( + response.response.body().as_bytes(), + Some(b"hello".as_slice()), + "should retain the exact body" + ); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn explicit_response_limit_is_enforced_before_ts_parsing() { + LocalSet::new() + .run_until(async { + let client = client(); + let mut origin = origin(Reply::Complete).await; + let pending = client + .send_async(request(&origin.url, "oversized").with_response_limit(4)) + .await + .expect("should schedule the bounded exchange"); + let selection = client + .select_until(vec![pending], Instant::now() + Duration::from_secs(3)) + .await + .expect("should observe the exchange") + .expect("should retain the failure"); + assert!( + selection.ready.is_err(), + "should reject a five-byte response under a four-byte cap" + ); + assert_eq!( + selection.failed_backend_name.as_deref(), + Some("oversized"), + "should retain the failing provider identity" + ); + observed(&mut origin).await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn fully_completed_unobserved_response_survives_its_transport_deadline() { + LocalSet::new() + .run_until(async { + let client = client(); + let mut origin = origin(Reply::Complete).await; + let deadline = client + .time + .clock() + .deadline_after(Duration::from_millis(100)); + let pending = client + .send_async( + request(&origin.url, "late-collection") + .with_deadline(deadline, Duration::from_millis(100)), + ) + .await + .expect("should schedule bounded native work"); + observed(&mut origin).await; + closed(&mut origin).await; + client + .time + .wait_until(deadline) + .await + .expect("should reach the paired deadline"); + let selection = client + .select_until(vec![pending], deadline.instant()) + .await + .expect("should inspect the non-driving snapshot") + .expect("should retain a fully completed response"); + assert_eq!( + selection + .ready + .expect("should not replace completed work with a late timeout") + .response + .body() + .as_bytes(), + Some(b"hello".as_slice()), + "should retain buffered bytes after delayed collection" + ); + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn plain_send_does_not_follow_a_redirect() { + LocalSet::new() + .run_until(async { + let client = client(); + let mut origin = origin(Reply::Redirect).await; + let response = client + .send(request(&origin.url, "redirect")) + .await + .expect("should return the original redirect"); + assert_eq!( + response.response.status().as_u16(), + 302, + "should leave redirect policy to the application" + ); + assert_eq!( + response.response.headers()[header::LOCATION], + "/followed", + "should retain redirect metadata" + ); + observed(&mut origin).await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn head_content_length_is_metadata_not_a_response_body_allocation() { + LocalSet::new() + .run_until(async { + let client = client(); + let mut origin = origin(Reply::Complete).await; + let raw = Request::builder() + .method("HEAD") + .uri(&origin.url) + .body(Body::empty()) + .expect("should construct a HEAD request"); + let response = client + .send(PlatformHttpRequest::new(raw, "head").with_response_limit(4)) + .await + .expect("should allow five-byte metadata without reading a body"); + assert_eq!( + response.response.headers()[header::CONTENT_LENGTH], + "5", + "should retain valid HEAD metadata" + ); + assert_eq!( + response.response.body().as_bytes(), + Some(b"".as_slice()), + "should retain a bodyless response" + ); + observed(&mut origin).await; + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn cache_intents_keep_the_typed_policy_on_every_target() { + let mut client = client(); + for target in [ + AuctionTargetId::Fastly, + AuctionTargetId::Axum, + AuctionTargetId::Cloudflare, + AuctionTargetId::Spin, + ] { + client.target = target; + let tagged = client + .prepare( + request("http://origin.example.com/", "origin") + .with_shared_cache("ts-page ts-template"), + ) + .expect("should map shared cache intent"); + assert!( + matches!(tagged.cache_policy_setting(), OutboundCachePolicy::PlatformDefaultWithSurrogateKey(value) if value.as_bytes() == b"ts-page ts-template"), + "should retain cache keys as metadata rather than wire fields" + ); + let invalid = client + .prepare(request("http://origin.example.com/", "origin").with_shared_cache("bad\nkey")) + .expect("should map an invalid purge tag to bypass"); + assert_eq!( + invalid.cache_policy_setting(), + &OutboundCachePolicy::Bypass, + "should not cache an unpurgeable object" + ); + let bypass = client + .prepare(request("http://origin.example.com/", "origin")) + .expect("should retain explicit bypass"); + assert_eq!( + bypass.cache_policy_setting(), + &OutboundCachePolicy::Bypass, + "should retain personalized bypass" + ); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn collection_capabilities_and_invalid_handle_correlation_remain_explicit() { + let mut client = client(); + for target in [ + AuctionTargetId::Fastly, + AuctionTargetId::Axum, + AuctionTargetId::Cloudflare, + AuctionTargetId::Spin, + ] { + client.target = target; + assert_eq!( + client.supports_concurrent_fanout(), + matches!(target, AuctionTargetId::Fastly | AuctionTargetId::Axum), + "should retain each target's auction contract" + ); + assert_eq!( + client.supports_streaming_responses(), + target == AuctionTargetId::Fastly, + "should preserve all three non-Fastly buffered-only contracts" + ); + assert_eq!( + client.supports_pending_streaming_responses(), + target == AuctionTargetId::Fastly, + "should reserve direct pending streams for Fastly" + ); + if !client.supports_concurrent_fanout() { + let error = client + .select(vec![ + PlatformPendingRequest::new(1_u32), + PlatformPendingRequest::new(2_u32), + ]) + .await + .expect_err("should reject unsupported fanout before inspecting handles"); + assert!( + format!("{error:?}").contains("multi-provider"), + "should report the preserved single-provider restriction" + ); + } + } + client.target = AuctionTargetId::Fastly; + let error = client + .select(vec![ + PlatformPendingRequest::new(42_u32).with_backend_name("origin-a"), + ]) + .await + .expect_err("should reject another client's pending type"); + assert!( + format!("{error:?}").contains("origin-a"), + "should retain invalid-handle backend correlation" + ); + let streamed = PlatformPendingRequest::new(42_u32) + .with_response_handling(true, edgezero_core::http::Method::GET); + let error = client + .select(vec![streamed]) + .await + .expect_err("should require direct wait for a pending stream"); + assert!( + format!("{error:?}").contains("use wait"), + "should reject stream-marked fanout before downcast" + ); +} + +#[test] +fn image_metadata_maps_the_closed_transformation_set_without_sdk_types_in_callers() { + let params = crate::platform::PlatformImageOptimizerParams { + format: Some("JPEGXL".to_owned()), + quality: Some(88), + resize_filter: Some("nearest".to_owned()), + width: Some(200), + height: Some(100), + crop: Some(crate::platform::PlatformImageOptimizerCrop { + width: 16, + height: 9, + mode: None, + offset_x: Some(25), + offset_y: Some(75), + }), + }; + let output = image_options( + PlatformImageOptimizerOptions::new("us-west-2", params) + .with_preserve_query_string_on_origin_request(true), + ) + .expect("should translate checked TS metadata"); + assert_eq!( + output.region_setting(), + Some(&OutboundImageOptimizerRegion::UsWest), + "should retain region aliases" + ); + assert_eq!( + output.format_setting(), + Some(OutboundImageFormat::JpegXl), + "should retain format aliases" + ); + assert_eq!(output.quality_setting(), Some(88), "should retain quality"); + assert_eq!( + output.resize_filter_setting(), + Some(OutboundResizeFilter::Nearest), + "should retain the resize filter" + ); + assert_eq!(output.width_setting(), Some(200), "should retain width"); + assert_eq!(output.height_setting(), Some(100), "should retain height"); + assert_eq!( + output.crop_setting(), + Some((16, 9, OutboundCropFocus::Percent { x: 25, y: 75 })), + "should retain paired crop offsets" + ); + assert!( + output.preserve_origin_query_setting(), + "should retain explicit query permission" + ); +} + +#[test] +fn invalid_image_metadata_is_rejected_before_transport() { + for params in [ + crate::platform::PlatformImageOptimizerParams { + quality: Some(101), + ..Default::default() + }, + crate::platform::PlatformImageOptimizerParams { + width: Some(0), + ..Default::default() + }, + crate::platform::PlatformImageOptimizerParams { + crop: Some(crate::platform::PlatformImageOptimizerCrop { + width: 16, + height: 9, + mode: None, + offset_x: Some(25), + offset_y: None, + }), + ..Default::default() + }, + ] { + assert!( + image_options(PlatformImageOptimizerOptions::new("us_east", params)).is_err(), + "should reject unsupported or incoherent transformations" + ); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn original_content_coding_and_entity_bytes_survive_fastly_and_standalone_native_policy() { + LocalSet::new() + .run_until(async { + let mut client = client(); + for target in [AuctionTargetId::Axum, AuctionTargetId::Fastly] { + client.target = target; + let mut origin = origin(Reply::Gzip).await; + let response = client + .send(request(&origin.url, "coded")) + .await + .expect("should retain original coded bytes"); + assert_eq!( + response.response.headers()[header::CONTENT_ENCODING], + "gzip", + "should preserve the origin coding header" + ); + let expected = gzip_hello(); + assert_eq!( + response.response.body().as_bytes(), + Some(expected.as_slice()), + "should not silently switch the transport to decoded payloads" + ); + assert_eq!( + response.response.headers()[header::CONTENT_LENGTH] + .to_str() + .expect("should read coded length"), + expected.len().to_string(), + "should retain the original representation length" + ); + observed(&mut origin).await; + } + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn default_streaming_does_not_inherit_a_buffered_allocation_cap() { + let mut client = client(); + client.target = AuctionTargetId::Fastly; + let raw = client + .prepare(request("https://asset.example.com/image", "asset").with_stream_response()) + .expect("should retain an uncapped streaming policy") + .into_parts(); + assert_eq!( + raw.response_mode, + edgezero_core::outbound::ResponseMode::Streamed, + "should preserve streamed delivery" + ); + assert_eq!( + raw.max_encoded_response_bytes, None, + "should not apply the buffered10MiB cap to streamed entities" + ); + assert_eq!( + raw.max_decoded_response_bytes, None, + "should not invent an additional stream cap" + ); + let explicit = client + .prepare( + request("https://asset.example.com/image", "asset") + .with_stream_response() + .with_response_limit(4), + ) + .expect("should retain explicit caller policy") + .into_parts(); + assert_eq!( + explicit.max_encoded_response_bytes, + Some(4), + "should retain an explicit stream ceiling" + ); + assert_eq!( + explicit.response_mode, + edgezero_core::outbound::ResponseMode::Streamed, + "should not replace a stream with a buffer" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn tagged_image_requests_fail_exact_default_preflight_without_losing_cache_metadata() { + let mut client = client(); + client.target = AuctionTargetId::Fastly; + let make = || { + let mut request = request("https://asset.example.com/image", "image"); + request.cache_intent = PlatformCacheIntent::Default; + request.with_image_optimizer(PlatformImageOptimizerOptions::new( + "us-east-1", + crate::platform::PlatformImageOptimizerParams::default(), + )) + }; + let plain = client + .prepare(make()) + .expect("should translate a default image request"); + assert_eq!( + plain.cache_policy_setting(), + &OutboundCachePolicy::PlatformDefault, + "should preserve the exact default policy" + ); + edgezero_core::outbound::validate_for_dispatch( + &plain, + edgezero_core::outbound::OutboundSendMode::Send, + ) + .expect("should admit ordinary default image metadata"); + let tagged = client + .prepare(make().with_shared_cache("ts-image")) + .expect("should translate tags as typed metadata"); + assert!( + edgezero_core::outbound::validate_for_dispatch( + &tagged, + edgezero_core::outbound::OutboundSendMode::Send + ) + .is_err(), + "should reject tags combined with image optimization before I/O" + ); + assert!( + matches!( + tagged.cache_policy_setting(), + OutboundCachePolicy::PlatformDefaultWithSurrogateKey(_) + ), + "should not flatten tagged-default into ordinary default" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn buffered_begin_cannot_launder_a_response_owned_body_into_upload_bytes() { + let client = client(); + let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = Arc::clone(&reads); + let stream = futures::stream::iter([bytes::Bytes::from_static(b"fictional-private-body")]) + .inspect(move |_| { + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }); + let owned = edgezero_core::outbound::OutboundScopeOwner::response_body() + .attach(edgezero_core::http::Response::new(Body::stream(stream))) + .expect("should attach the original response owner"); + let request = Request::builder() + .method("POST") + .uri("https://origin.example.com/") + .body(owned.into_body()) + .expect("should construct the invalid upload"); + let error = client + .send_async(PlatformHttpRequest::new(request, "invalid-body")) + .await + .expect_err("should reject response-owned uploads before materialization"); + assert!( + matches!( + error.downcast_ref::(), + Some(edgezero_core::EdgeError::BadRequest { .. }) + ), + "should retain the SDK's typed rejection" + ); + assert_eq!( + reads.load(std::sync::atomic::Ordering::SeqCst), + 0, + "should not consume guarded response data before SDK validation" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn absent_timeout_and_deadline_remain_unset_for_the_sdk() { + let mut client = client(); + for target in [ + AuctionTargetId::Fastly, + AuctionTargetId::Axum, + AuctionTargetId::Cloudflare, + AuctionTargetId::Spin, + ] { + client.target = target; + let mapped = client + .prepare(request("http://origin.example.com/", "origin")) + .expect("should map the request") + .into_parts(); + assert!(mapped.timeout.is_none(), "should inherit the SDK timeout"); + assert!(mapped.deadline.is_none(), "should not invent a deadline"); + let prepared = client + .prepare_pending(request("http://origin.example.com/", "origin")) + .await + .expect("should prepare the buffered upload") + .into_parts(); + assert!( + prepared.timeout.is_none(), + "should leave the SDK default implicit" + ); + assert!( + prepared.deadline.is_none(), + "should not anchor a TS default timer" + ); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn explicit_deadline_is_not_restarted_by_begin_preparation() { + let client = client(); + let deadline = client + .time + .clock() + .deadline_after(Duration::from_millis(300)); + let prepared = client + .prepare_pending( + request("http://origin.example.com/", "origin") + .with_deadline(deadline, Duration::from_secs(2)), + ) + .await + .expect("should retain the tighter explicit deadline") + .into_parts(); + assert_eq!( + prepared + .deadline + .expect("should retain the explicit deadline") + .instant(), + deadline.instant(), + "should retain the original absolute deadline" + ); + assert_eq!( + prepared.timeout, + Some(Duration::from_secs(2)), + "should retain the caller timeout" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn explicit_timeout_bounds_upload_preparation_without_an_independent_timer() { + let client = client(); + let source = futures::stream::pending::>(); + let raw = Request::builder() + .method("POST") + .uri("http://origin.example.com/") + .body(Body::from_stream(source)) + .expect("should construct a pending upload"); + let start = Instant::now(); + let error = client + .prepare_pending( + PlatformHttpRequest::new(raw, "origin").with_timeout(Duration::from_millis(50)), + ) + .await + .expect_err("should enforce the caller's explicit budget during preparation"); + assert!( + matches!(error.current_context(), PlatformError::DeadlineExceeded), + "should retain the explicit application deadline result" + ); + assert!( + start.elapsed() < Duration::from_secs(1), + "should not wait for the SDK default" + ); +} diff --git a/crates/trusted-server-core/src/platform/pending_response.rs b/crates/trusted-server-core/src/platform/pending_response.rs deleted file mode 100644 index 9590578df5..0000000000 --- a/crates/trusted-server-core/src/platform/pending_response.rs +++ /dev/null @@ -1,323 +0,0 @@ -//! Shared ownership and deadline collection for dispatched runtime operations. - -/// Receives a dispatched operation and cancels it when its owner is dropped. -pub struct PlatformPendingResponse { - response: futures::channel::oneshot::Receiver, - abort: futures::future::AbortHandle, -} - -impl PlatformPendingResponse { - /// Starts an operation with the caller's runtime spawner. - /// - /// The operation publishes only its complete result. Dropping this owner - /// aborts queued or running guest work when the executor next polls it. - /// Runtime-specific transport cancellation remains the caller's responsibility. - /// - /// # Examples - /// - /// ```no_run - /// use futures::task::LocalSpawnExt as _; - /// use trusted_server_core::platform::PlatformPendingResponse; - /// let pool = futures::executor::LocalPool::new(); - /// let pending = PlatformPendingResponse::start(async { 42 }, |task| { - /// pool.spawner().spawn_local(task).expect("should spawn operation"); - /// }); - /// ``` - pub fn start( - operation: impl std::future::Future + 'static, - spawn: impl FnOnce(futures::future::LocalBoxFuture<'static, ()>), - ) -> Self { - let (sender, response) = futures::channel::oneshot::channel(); - let (abort, registration) = futures::future::AbortHandle::new_pair(); - spawn(Box::pin(async move { - if let Ok(result) = futures::future::Abortable::new(operation, registration).await { - let _ = sender.send(result); - } - })); - Self { response, abort } - } - - /// Waits for the complete result while retaining cancellation ownership. - /// - /// # Errors - /// - /// Returns a channel error if the dispatched operation stops without a result. - /// - /// # Examples - /// - /// ```no_run - /// # use trusted_server_core::platform::PlatformPendingResponse; - /// # async fn collect(pending: &mut PlatformPendingResponse) -> Result { - /// let result = pending.wait().await?; - /// # Ok(result) - /// # } - /// ``` - pub async fn wait(&mut self) -> Result { - (&mut self.response).await - } - - /// Collects a buffered result, waiting at most until the supplied timer fires. - /// - /// An initial nonblocking probe retains results after an expired deadline. - /// A final probe handles a result published in the same turn as timer expiry. - /// The caller supplies the runtime timer and a monotonic clock; no transport - /// is polled directly here. Returns `None` when the deadline expires without - /// a complete buffered result. The owner remains alive throughout the wait. - /// - /// # Errors - /// - /// Returns a channel error inside `Some` if the operation stops without a result. - /// - /// # Examples - /// - /// ```no_run - /// # use trusted_server_core::platform::PlatformPendingResponse; - /// # async fn collect(pending: &mut PlatformPendingResponse, timer: impl std::future::Future, deadline: web_time::Instant) { - /// let result = pending.complete_before_deadline(timer, deadline, web_time::Instant::now).await; - /// # } - /// ``` - pub async fn complete_before_deadline( - &mut self, - timer: F, - deadline: web_time::Instant, - now: N, - ) -> Option> - where - F: std::future::Future, - N: Fn() -> web_time::Instant, - { - complete_before_deadline(&mut self.response, timer, deadline, now).await - } -} - -impl Drop for PlatformPendingResponse { - fn drop(&mut self) { - self.abort.abort(); - } -} - -async fn complete_before_deadline( - response: &mut futures::channel::oneshot::Receiver, - timer: T, - deadline: web_time::Instant, - now: N, -) -> Option> -where - T: std::future::Future, - N: Fn() -> web_time::Instant, -{ - // Inspect only the task's buffered result, including when collection starts - // after publisher-origin work has exhausted the auction budget. - match response.try_recv() { - Ok(Some(ready)) => return Some(Ok(ready)), - Err(error) => return Some(Err(error)), - Ok(None) => {} - } - if now() >= deadline { - return None; - } - match futures::future::select(response, Box::pin(timer)).await { - futures::future::Either::Left((ready, _)) => Some(ready), - // The timer and task can become ready in the same executor turn. A final - // channel probe retains buffered results without polling transport I/O. - futures::future::Either::Right(((), response)) => response.try_recv().transpose(), - } -} - -#[cfg(test)] -mod deadline_tests { - use std::cell::Cell; - use std::rc::Rc; - use std::time::Duration; - - use futures::executor::{LocalPool, block_on}; - use futures::future::{pending, ready}; - use futures::task::LocalSpawnExt as _; - use web_time::Instant; - - use super::{PlatformPendingResponse, complete_before_deadline}; - - #[test] - fn dispatched_task_starts_before_collection_and_buffers_complete_body() { - let mut executor = LocalPool::new(); - let spawner = executor.spawner(); - let started = Rc::new(Cell::new(false)); - let started_operation = Rc::clone(&started); - let (finish_body, body) = futures::channel::oneshot::channel(); - let mut pending = PlatformPendingResponse::start( - async move { - started_operation.set(true); - body.await.expect("should receive complete response body") - }, - |task| { - spawner - .spawn_local(task) - .expect("should spawn outbound task") - }, - ); - - executor.run_until_stalled(); - assert!(started.get(), "should start outbound work before selection"); - assert_eq!( - pending.response.try_recv().expect("should poll result"), - None, - "should not expose headers before response body completes" - ); - finish_body.send(42).expect("should finish response body"); - executor.run_until_stalled(); - assert_eq!( - pending - .response - .try_recv() - .expect("should poll buffered result"), - Some(42), - "should preserve a complete buffered response without polling transport" - ); - } - - #[test] - fn dropping_dispatched_pending_cancels_started_body_operation() { - struct DropProbe(Rc>); - impl Drop for DropProbe { - fn drop(&mut self) { - self.0.set(true); - } - } - let mut executor = LocalPool::new(); - let spawner = executor.spawner(); - let dropped = Rc::new(Cell::new(false)); - let probe = DropProbe(Rc::clone(&dropped)); - let pending = PlatformPendingResponse::start( - async move { - let _probe = probe; - futures::future::pending::<()>().await; - }, - |task| { - spawner - .spawn_local(task) - .expect("should spawn outbound task") - }, - ); - executor.run_until_stalled(); - assert!(!dropped.get(), "should retain an active body operation"); - drop(pending); - executor.run_until_stalled(); - assert!( - dropped.get(), - "should drop spawned operation after cancellation" - ); - } - - #[test] - fn dropping_queued_dispatch_prevents_transport_poll() { - let mut executor = LocalPool::new(); - let spawner = executor.spawner(); - let started = Rc::new(Cell::new(false)); - let operation_started = Rc::clone(&started); - let pending = PlatformPendingResponse::start( - async move { - operation_started.set(true); - }, - |task| { - spawner - .spawn_local(task) - .expect("should spawn outbound task") - }, - ); - drop(pending); - executor.run_until_stalled(); - assert!( - !started.get(), - "should cancel queued work before transport starts" - ); - } - - #[test] - fn expired_deadline_preserves_buffered_response() { - let (sender, mut response) = futures::channel::oneshot::channel(); - sender.send(42).expect("should buffer response"); - let deadline = Instant::now(); - let result = block_on(complete_before_deadline( - &mut response, - pending(), - deadline, - || deadline, - )); - assert_eq!( - result, - Some(Ok(42)), - "should retain a buffered response after cutoff" - ); - } - - #[test] - fn expired_deadline_returns_without_waiting_for_response() { - let (_sender, mut response) = futures::channel::oneshot::channel::<()>(); - let deadline = Instant::now(); - let result = block_on(complete_before_deadline( - &mut response, - pending(), - deadline, - || deadline, - )); - assert_eq!(result, None, "should stop waiting at an expired deadline"); - } - - #[test] - fn timer_cuts_off_pending_response() { - let (_sender, mut response) = futures::channel::oneshot::channel::<()>(); - let now = Instant::now(); - let result = block_on(complete_before_deadline( - &mut response, - ready(()), - now + Duration::from_secs(1), - || now, - )); - assert_eq!(result, None, "should cut off an unbuffered response"); - } - - #[test] - fn closed_response_channel_reports_failure() { - let (sender, mut response) = futures::channel::oneshot::channel::<()>(); - drop(sender); - let now = Instant::now(); - let result = block_on(complete_before_deadline( - &mut response, - pending(), - now + Duration::from_secs(1), - || now, - )); - assert!( - matches!(result, Some(Err(_))), - "should preserve a dispatched task failure" - ); - } - - #[test] - fn timer_rechecks_result_published_during_cutoff() { - let (sender, mut response) = futures::channel::oneshot::channel(); - let now = Instant::now(); - let clock = Cell::new(now); - let deadline = now + Duration::from_secs(1); - let timer = async { - clock.set(deadline); - sender.send(42).expect("should publish at cutoff"); - }; - let result = block_on(complete_before_deadline( - &mut response, - timer, - deadline, - || clock.get(), - )); - assert_eq!( - result, - Some(Ok(42)), - "should retain result buffered when timer wins" - ); - assert_eq!( - clock.get(), - deadline, - "should exercise the deadline boundary" - ); - } -} diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 70e36f10e5..cf546fb1af 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -37,8 +37,10 @@ const STREAMING_CHUNK_SIZE: usize = 8192; /// Fallback `Content-Type` for image-like responses missing an origin type. const IMAGE_FALLBACK_CONTENT_TYPE: &str = "application/octet-stream"; -const SIGN_MAX_BODY_BYTES: usize = 65536; -const REBUILD_MAX_BODY_BYTES: usize = 65536; +/// Maximum buffered input for first-party request signing. +pub const SIGN_MAX_BODY_BYTES: usize = 65536; +/// Maximum buffered input for first-party proxy rebuilding. +pub const REBUILD_MAX_BODY_BYTES: usize = 65536; fn body_as_reader(body: EdgeBody) -> Cursor { Cursor::new(body.into_bytes().unwrap_or_default()) @@ -249,30 +251,24 @@ pub(crate) fn platform_response_to_fastly( platform_resp: PlatformResponse, ) -> Result, Report> { let (parts, body) = platform_resp.response.into_parts(); - let body_bytes = match body { - EdgeBody::Once(bytes) => bytes.to_vec(), - EdgeBody::Stream(_) => { - return Err(Report::new(TrustedServerError::Proxy { - message: "streaming platform response body is not supported by Fastly response conversion" + if body.is_stream() { + return Err(Report::new(TrustedServerError::Proxy { + message: + "streaming platform response body is not supported by Fastly response conversion" .to_string(), - })); - } - }; - Ok(Response::from_parts(parts, EdgeBody::from(body_bytes))) + })); + } + Ok(Response::from_parts(parts, body)) } fn platform_response_to_fastly_asset(platform_resp: PlatformResponse) -> AssetProxyResponse { let (parts, body) = platform_resp.response.into_parts(); - match body { - EdgeBody::Once(bytes) => AssetProxyResponse::origin_controlled(Response::from_parts( - parts, - EdgeBody::from(bytes.to_vec()), - )), - stream_body @ EdgeBody::Stream(_) => { - let resp = Response::from_parts(parts, EdgeBody::empty()); - AssetProxyResponse::origin_controlled_stream(resp, stream_body) - } + if body.is_stream() { + let response = Response::from_parts(parts, EdgeBody::empty()); + AssetProxyResponse::origin_controlled_stream(response, body) + } else { + AssetProxyResponse::origin_controlled(Response::from_parts(parts, body)) } } @@ -289,15 +285,20 @@ pub async fn stream_asset_body( body: EdgeBody, output: &mut W, ) -> Result<(), Report> { - match body { - EdgeBody::Once(bytes) => { + match body.into_stream() { + Err(body) => { + let bytes = body.as_bytes().ok_or_else(|| { + Report::new(TrustedServerError::Proxy { + message: "buffered response body is unavailable".to_owned(), + }) + })?; output - .write_all(bytes.as_ref()) + .write_all(bytes) .change_context(TrustedServerError::Proxy { message: "failed to write buffered platform response body".to_string(), })?; } - EdgeBody::Stream(mut stream) => { + Ok(mut stream) => { while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|err| { Report::new(TrustedServerError::Proxy { @@ -6335,7 +6336,7 @@ mod tests { #[test] fn stream_asset_body_reports_mid_stream_origin_errors() { futures::executor::block_on(async { - let body = EdgeBody::from_stream(futures::stream::iter(vec![ + let body = EdgeBody::from_external_stream(futures::stream::iter(vec![ Ok::(Bytes::from_static(b"partial")), Err(io::Error::other("origin stream failed")), ])); @@ -6389,7 +6390,7 @@ mod tests { // the client renders nothing behind committed headers. let events = Rc::new(RefCell::new(Vec::new())); let source_events = Rc::clone(&events); - let body = EdgeBody::from_stream(async_stream::stream! { + let body = EdgeBody::from_external_stream(async_stream::stream! { for (index, segment) in ["", ""].into_iter().enumerate() { source_events .borrow_mut() diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index b5a63d3f9a..1fa3904017 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -31,7 +31,8 @@ use brotli::enc::BrotliEncoderParams; use brotli::enc::writer::CompressorWriter; use chrono::{DateTime, Utc}; use cookie::CookieJar; -use edgezero_core::body::Body as EdgeBody; +use edgezero_core::body::{Body as EdgeBody, BodyPayload}; +use edgezero_core::outbound::OutboundScopeOwner; use error_stack::{Report, ResultExt}; use flate2::read::ZlibDecoder; use flate2::write::{GzEncoder, ZlibEncoder}; @@ -186,7 +187,8 @@ fn body_as_reader( } struct BodyChunkSource { - body: Option, + body: Option, + _egress_owner: Option, chunk_size: usize, max_bytes: usize, bytes_seen: usize, @@ -195,8 +197,10 @@ struct BodyChunkSource { impl BodyChunkSource { fn new(body: EdgeBody, chunk_size: usize) -> Self { + let (payload, owner) = body.into_parts(); Self { - body: Some(body), + body: Some(payload), + _egress_owner: owner, chunk_size, max_bytes: usize::MAX, bytes_seen: 0, @@ -215,7 +219,7 @@ impl BodyChunkSource { // silently reporting end-of-stream on the next call. let pulled = match &mut self.body { None => Ok(None), - Some(EdgeBody::Once(bytes)) => { + Some(BodyPayload::Once(bytes)) => { let end = (self.once_offset + self.chunk_size).min(bytes.len()); if self.once_offset >= end { Ok(None) @@ -225,7 +229,7 @@ impl BodyChunkSource { Ok(Some(chunk)) } } - Some(EdgeBody::Stream(stream)) => match stream.next().await { + Some(BodyPayload::Stream(stream)) => match stream.next().await { Some(Ok(chunk)) => Ok(Some(chunk)), Some(Err(err)) => Err(Report::new(TrustedServerError::Proxy { message: format!("Failed to read publisher origin body stream: {err}"), @@ -1599,7 +1603,7 @@ pub enum PublisherResponse { /// Response returned unmodified, ready to send via `send_to_client()`. /// /// On streaming adapters the unmodified body may still be a live - /// [`EdgeBody::Stream`] (the origin fetch requested streaming before the + /// streaming [`EdgeBody`] (the origin fetch requested streaming before the /// response was classified); it passes through to the client untouched. Buffered(Response), /// Response headers are ready for a streaming response. Covers processable @@ -1609,7 +1613,7 @@ pub enum PublisherResponse { /// is streamed regardless of whether any are registered. /// /// Adapters with platform streaming support preserve `body` as - /// [`EdgeBody::Stream`] and attach a lazy processed stream via + /// a streaming [`EdgeBody`] and attach a lazy processed stream via /// [`publisher_response_into_streaming_response`]. Buffered adapters use /// [`buffer_publisher_response_async`] and are bounded by /// `settings.publisher.max_buffered_body_bytes`. @@ -1659,6 +1663,56 @@ pub enum PublisherResponse { }, } +impl PublisherResponse { + fn response_mut(&mut self) -> &mut Response { + match self { + Self::Buffered(response) + | Self::Stream { response, .. } + | Self::AssembleTemplate { response, .. } + | Self::PassThrough { response, .. } => response, + } + } + + fn take_outbound_owner(&mut self) -> Option { + let response = self.response_mut(); + let body = std::mem::replace(response.body_mut(), EdgeBody::empty()); + let (payload, owner) = body.into_parts(); + *response.body_mut() = match payload { + BodyPayload::Once(bytes) => EdgeBody::from_bytes(bytes), + BodyPayload::Stream(stream) => EdgeBody::from_stream(stream), + }; + owner + } + + fn with_outbound_owner( + mut self, + owner: OutboundScopeOwner, + ) -> Result> { + let response = self.response_mut(); + let head = std::mem::replace(response, Response::new(EdgeBody::empty())); + *response = owner + .attach(head) + .change_context(TrustedServerError::Proxy { + message: "publisher outbound scope handoff fails".to_owned(), + })?; + Ok(self) + } +} + +fn attach_outbound_owner( + owner: Option, + response: Response, +) -> Result, Report> { + match owner { + Some(owner) => owner + .attach(response) + .change_context(TrustedServerError::Proxy { + message: "publisher final response scope handoff fails".to_owned(), + }), + None => Ok(response), + } +} + /// Routing decision for a proxied response. /// /// Computed purely from response metadata — no side effects, no body is @@ -1857,6 +1911,27 @@ pub async fn buffer_publisher_response_async( integration_registry: &IntegrationRegistry, orchestrator: &AuctionOrchestrator, services: &RuntimeServices, +) -> Result, Report> { + let owner = publisher_response.take_outbound_owner(); + let response = buffer_publisher_response_async_inner( + publisher_response, + method, + settings, + integration_registry, + orchestrator, + services, + ) + .await?; + attach_outbound_owner(owner, response) +} + +async fn buffer_publisher_response_async_inner( + mut publisher_response: PublisherResponse, + method: &Method, + settings: &Settings, + integration_registry: &IntegrationRegistry, + orchestrator: &AuctionOrchestrator, + services: &RuntimeServices, ) -> Result, Report> { if let Err(error) = integration_registry.ensure_async_html_finish_allowed(settings) { if let PublisherResponse::Stream { params, .. } = &mut publisher_response { @@ -2504,6 +2579,27 @@ pub async fn publisher_response_into_streaming_response( integration_registry: &IntegrationRegistry, orchestrator: Arc, services: RuntimeServices, +) -> Result, Report> { + let owner = publisher_response.take_outbound_owner(); + let response = publisher_response_into_streaming_response_inner( + publisher_response, + method, + settings, + integration_registry, + orchestrator, + services, + ) + .await?; + attach_outbound_owner(owner, response) +} + +async fn publisher_response_into_streaming_response_inner( + mut publisher_response: PublisherResponse, + method: &Method, + settings: Arc, + integration_registry: &IntegrationRegistry, + orchestrator: Arc, + services: RuntimeServices, ) -> Result, Report> { if let Err(error) = integration_registry.ensure_async_html_finish_allowed(&settings) { if let PublisherResponse::Stream { params, .. } = &mut publisher_response { @@ -2637,7 +2733,7 @@ pub async fn publisher_response_into_streaming_response( yield bytes::Bytes::from(trailer); } }; - *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + *response.body_mut() = EdgeBody::from_external_stream::<_, std::io::Error>(stream); Ok(response) } PublisherResponse::PassThrough { mut response, body } => { @@ -2730,7 +2826,7 @@ pub async fn publisher_response_into_streaming_response( } } }; - *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + *response.body_mut() = EdgeBody::from_external_stream::<_, std::io::Error>(stream); return Ok(response); } @@ -2904,7 +3000,7 @@ pub async fn publisher_response_into_streaming_response( } } }; - *response.body_mut() = EdgeBody::from_stream::<_, std::io::Error>(stream); + *response.body_mut() = EdgeBody::from_external_stream::<_, std::io::Error>(stream); Ok(response) } } @@ -4892,6 +4988,30 @@ pub(crate) fn request_can_use_shared_template( /// Returns a [`TrustedServerError`] if the request host is malformed, the proxy /// request fails, or the origin backend is unreachable. pub async fn handle_publisher_request( + settings: &Settings, + services: &RuntimeServices, + kv: Option<&KvIdentityGraph>, + ec_context: &mut EcContext, + auction: AuctionDispatch<'_>, + req: Request, + edge_header: EdgeCacheHeader, +) -> Result> { + let owner = OutboundScopeOwner::response_body(); + let response = owner + .run(handle_publisher_request_scoped( + settings, + services, + kv, + ec_context, + auction, + req, + edge_header, + )) + .await?; + response.with_outbound_owner(owner) +} + +async fn handle_publisher_request_scoped( settings: &Settings, services: &RuntimeServices, kv: Option<&KvIdentityGraph>, @@ -8049,6 +8169,9 @@ fn mark_deprecated_alias(response: &mut Response, is_legacy_alias: boo #[cfg(test)] mod tests { + #[cfg(not(target_arch = "wasm32"))] + mod owner_tests; + use crate::test_support::PreDispatchHookTestExt as _; use std::future::Future as _; use std::io::{self, Read as _}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -8372,7 +8495,7 @@ mod tests { assert_eq!(snapshot.uri(), request.uri()); assert_eq!(snapshot.headers(), request.headers()); assert!( - matches!(snapshot.body(), EdgeBody::Once(bytes) if bytes.is_empty()), + snapshot.body().as_bytes().is_some_and(<[u8]>::is_empty), "snapshot should never duplicate the request body" ); } @@ -10153,7 +10276,6 @@ mod tests { use std::net::{IpAddr, Ipv4Addr}; use chrono::TimeZone as _; - use edgezero_core::router::PreDispatchHook as _; use futures::executor::block_on; use crate::platform::test_support::{ @@ -10173,7 +10295,7 @@ mod tests { Arc::new(|_| panic!("should not query setup metadata for a publisher document")), ); assert!( - block_on(hook.handle(&mut request)) + block_on(hook.handle_request(&mut request)) .expect("should freeze document gate") .is_none(), "should continue publisher routing" @@ -10266,7 +10388,7 @@ mod tests { }), ); assert!( - block_on(hook.handle(&mut request)) + block_on(hook.handle_request(&mut request)) .expect("should freeze ordinary request gate") .is_none(), "should continue ordinary publisher routing" @@ -10521,7 +10643,7 @@ mod tests { Arc::new(settings), Arc::new(|_| panic!("should not fetch setup metadata for a document")), ); - block_on(hook.handle(&mut request)).expect("should capture document gate"); + block_on(hook.handle_request(&mut request)).expect("should capture document gate"); let gate = request .extensions() .get::() @@ -10615,7 +10737,7 @@ mod tests { let registry = IntegrationRegistry::new(&settings).expect("should build publisher registry"); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); - let body = EdgeBody::Stream( + let body = EdgeBody::from_stream( futures::stream::iter([ Ok(bytes::Bytes::from_static(b") -> Vec { - match response.into_body() { - EdgeBody::Stream(mut stream) => { - let mut out = Vec::new(); - while let Some(chunk) = stream.next().await { - out.extend_from_slice(&chunk.expect("stream chunk should read")); - } - out - } - other => other - .into_bytes() - .expect("a non-stream body has its bytes in hand") - .to_vec(), - } + response + .into_body() + .into_bytes_bounded(usize::MAX) + .await + .expect("should collect the fixture response") + .to_vec() } #[tokio::test] @@ -16643,7 +16758,6 @@ mod tests { use std::net::{IpAddr, Ipv4Addr}; use crate::trace::TracePreDispatchHook; - use edgezero_core::router::PreDispatchHook as _; use serde::Deserialize as _; fn decode_script_json(document: &str, prefix: &str) -> serde_json::Value { @@ -16825,7 +16939,7 @@ mod tests { Arc::clone(&settings), Arc::new(|_| panic!("should not load setup metadata")), ) - .handle(&mut request) + .handle_request(&mut request) .await .expect("should freeze document gate"); crate::integrations::gpt_diagnostics::prepare_request( @@ -16960,7 +17074,7 @@ mod tests { Arc::new(|_| panic!("should never query setup metadata for publisher traffic")), ); assert!( - hook.handle(&mut request) + hook.handle_request(&mut request) .await .expect("should freeze incoming publisher cookie health") .is_none(), @@ -17193,7 +17307,7 @@ mod tests { Arc::clone(&settings), Arc::new(|_| panic!("should not load setup metadata")), ) - .handle(&mut request) + .handle_request(&mut request) .await .expect("should freeze trace gate"); crate::integrations::gpt_diagnostics::prepare_request( @@ -17682,7 +17796,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { // So pull exactly one chunk and prove the auction has not been collected // yet — `ad_bids_state` is only written by the collector. No timing // involved, so nothing to be flaky about. - let EdgeBody::Stream(mut stream) = warm.into_body() else { + let Ok(mut stream) = warm.into_body().into_stream() else { panic!("a cache hit must stream, not buffer"); }; let first = stream @@ -23190,7 +23304,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { trace_bootstrap: None, suppress_datadome_client_side_tag: false, }; - let body = EdgeBody::from_stream(futures::stream::iter(vec![Ok::<_, io::Error>( + let body = EdgeBody::from_external_stream(futures::stream::iter(vec![Ok::<_, io::Error>( bytes::Bytes::from_static(b"live"), )])); let mut output = Vec::new(); @@ -23803,7 +23917,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { b"chunk", )))) }); - let body = EdgeBody::from_stream(stream); + let body = EdgeBody::from_external_stream(stream); let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE); { @@ -24117,7 +24231,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .expect("should build streaming response"); assert!( - matches!(response.body(), EdgeBody::Stream(_)), + response.body().is_stream(), "streaming finalize should keep a lazy Body::Stream" ); @@ -24154,7 +24268,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { /// the document head but not yet finished the response. fn origin_chunk_then_pending(chunk: bytes::Bytes) -> EdgeBody { let mut sent = false; - EdgeBody::from_stream(futures::stream::poll_fn(move |_cx| { + EdgeBody::from_external_stream(futures::stream::poll_fn(move |_cx| { if sent { return std::task::Poll::Pending; } @@ -24397,7 +24511,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { noop_services(), )) .expect("should create lazy limited response"); - let EdgeBody::Stream(mut stream) = response.into_body() else { + let Ok(mut stream) = response.into_body().into_stream() else { panic!("should return lazy output") }; let mut failed = false; @@ -24465,7 +24579,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { noop_services(), )) .expect("should prepare lazy EOF output"); - let EdgeBody::Stream(mut stream) = response.into_body() else { + let Ok(mut stream) = response.into_body().into_stream() else { panic!("should return a lazy body"); }; let first = futures::executor::block_on(stream.next()) @@ -25689,11 +25803,12 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { )); let source = b"Example"; let body = match failure { - "read" => { - EdgeBody::from_stream(futures::stream::iter(vec![Err::( - io::Error::other("example origin read failure"), - )])) - } + "read" => EdgeBody::from_external_stream(futures::stream::iter(vec![Err::< + bytes::Bytes, + _, + >( + io::Error::other("example origin read failure"), + )])), "decode" => { let mut encoded = gzip_encode(source); encoded.truncate(encoded.len() / 2); @@ -25924,7 +26039,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { Ok::<_, io::Error>(bytes::Bytes::copy_from_slice(chunk)) }) .collect(); - EdgeBody::from_stream(futures::stream::iter(chunks)) + EdgeBody::from_external_stream(futures::stream::iter(chunks)) } else { EdgeBody::from(encoded.clone()) }; @@ -26627,7 +26742,7 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { .expect("should finalize buffered response"); assert!( - !matches!(response.body(), EdgeBody::Stream(_)), + !response.body().is_stream(), "bodiless {method} {status} must not carry a streaming body" ); assert_bodiless_framing( @@ -29267,9 +29382,12 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { Arc::clone(&settings), Arc::new(|_| panic!("should not load SPA setup metadata")), ); - edgezero_core::router::PreDispatchHook::handle(&hook, &mut request) - .await - .expect("should freeze SPA gate"); + crate::test_support::PreDispatchHookTestExt::handle_request( + &hook, + &mut request, + ) + .await + .expect("should freeze SPA gate"); let mut ec_context = consent_allowing_ec_context(); let slots = article_slot(); let response = handle_page_bids( @@ -29376,10 +29494,13 @@ for (const mode of ['ready', 'queued', 'stale', 'inactive', 'throws']) { Arc::new(|_| panic!("should not load setup metadata on SPA capture")), ); assert!( - edgezero_core::router::PreDispatchHook::handle(&hook, &mut request) - .await - .expect("should freeze SPA cookies") - .is_none(), + crate::test_support::PreDispatchHookTestExt::handle_request( + &hook, + &mut request + ) + .await + .expect("should freeze SPA cookies") + .is_none(), "should keep ordinary SPA routing" ); diff --git a/crates/trusted-server-core/src/publisher/tests/owner_tests.rs b/crates/trusted-server-core/src/publisher/tests/owner_tests.rs new file mode 100644 index 0000000000..6096a24198 --- /dev/null +++ b/crates/trusted-server-core/src/publisher/tests/owner_tests.rs @@ -0,0 +1,322 @@ +//! Publisher ownership through real native origin/probe exchanges and managed egress. +//! This does not qualify a live server's downstream socket writer. + +use std::cell::RefCell; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use edgezero_core::app::App; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::ingress::{IngressDispatchOutcome, IngressFraming, IngressHeadAccounting}; +use edgezero_core::outbound::{ + OutboundCachePolicy, OutboundRequest, OutboundScope, ResponseDecoding, +}; +use edgezero_core::response_egress::{ResponseEgressObserver, ResponseEgressReport}; +use edgezero_core::response_egress_framing::ResponseEgressFramingMode; +use edgezero_core::router::RouterService; +use tokio::task::LocalSet; + +use crate::platform::{PlatformHttpClient as _, PlatformPendingRequest}; +use crate::test_support::native_outbound::{Reply, clients, closed, observed, origin, request}; + +use super::*; + +struct Retained { + scope: OutboundScope, + _pending: PlatformPendingRequest, +} + +thread_local! { + static RETAINED: RefCell> = const { RefCell::new(None) }; +} + +struct RetainedGuard; + +impl Drop for RetainedGuard { + fn drop(&mut self) { + RETAINED.with(|retained| retained.borrow_mut().take()); + } +} + +fn assert_scope_open(open: bool) { + RETAINED.with(|retained| { + assert_eq!( + retained + .borrow() + .as_ref() + .expect("should retain the original owner scope") + .scope + .check() + .is_ok(), + open, + "should retain the exact producer scope until managed delivery terminates" + ); + }); +} + +#[derive(Clone, Copy)] +enum Shape { + Buffered, + PassThrough, + Processed, + Template, +} + +#[derive(Clone, Copy)] +enum Terminal { + Complete, + DropAttempt, + DropEnvelope, +} + +struct Observer(Arc); + +impl ResponseEgressObserver for Observer { + fn complete(&self, _report: &ResponseEgressReport) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +async fn exercise(shape: Shape, buffered: bool, terminal: Terminal) { + let _retained = RetainedGuard; + let mut source = origin(Reply::Html).await; + let mut probe = origin(Reply::HeldHeaders).await; + let source_url = source.url.clone(); + let probe_url = probe.url.clone(); + let (sdk, client) = clients(); + let client = Arc::new(client); + let settings = Arc::new(allow_eof_test_settings(create_test_settings())); + let registry = Arc::new( + IntegrationRegistry::with_plan( + &settings, + Arc::new( + crate::auction::compile_auction_plan(&settings) + .expect("should compile the fixture plan"), + ), + ) + .expect("should build fixture integrations"), + ); + let orchestrator = Arc::new(AuctionOrchestrator::new(settings.auction.clone())); + let router = RouterService::builder() + .get("/", move |_context: RequestContext| { + let client = Arc::clone(&client); + let sdk = sdk.clone(); + let settings = Arc::clone(&settings); + let registry = Arc::clone(®istry); + let orchestrator = Arc::clone(&orchestrator); + let source_url = source_url.clone(); + let probe_url = probe_url.clone(); + async move { + let owner = OutboundScopeOwner::response_body(); + let (pending, origin) = owner + .run(async { + let pending = client + .send_async(request(&probe_url, "retained-probe")) + .await + .map_err(|_| EdgeError::bad_gateway("fixture probe dispatch fails"))?; + let origin = sdk + .send( + OutboundRequest::get(&source_url)? + .cache_policy(OutboundCachePolicy::Bypass) + .response_decoding(ResponseDecoding::Original) + .stream_response(), + ) + .await + .map_err(|_| EdgeError::bad_gateway("fixture origin dispatch fails"))?; + Ok::<_, EdgeError>((pending, origin.into_response()?)) + }) + .await?; + RETAINED.with(|retained| { + assert!( + retained + .borrow_mut() + .replace(Retained { + scope: owner.scope(), + _pending: pending + }) + .is_none(), + "should retain exactly one probe handle" + ); + }); + let (mut parts, body) = origin.into_parts(); + if matches!(shape, Shape::Processed | Shape::Template) { + parts.headers.remove(header::CONTENT_LENGTH); + } + let publisher = match shape { + Shape::Buffered => { + PublisherResponse::Buffered(Response::from_parts(parts, body)) + } + Shape::PassThrough => PublisherResponse::PassThrough { + response: Response::from_parts(parts, EdgeBody::empty()), + body, + }, + Shape::Processed => PublisherResponse::Stream { + response: Response::from_parts(parts, EdgeBody::empty()), + body, + params: Box::new(html_stream_params("", None)), + }, + Shape::Template => { + body.into_bytes_bounded(1024).await?; + PublisherResponse::AssembleTemplate { + response: Response::from_parts(parts, EdgeBody::empty()), + template: format!( + "fixture{AD_ASSEMBLY_SEAM}" + ) + .into_bytes(), + params: Box::new(html_stream_params("", None)), + } + } + } + .with_outbound_owner(owner) + .map_err(|_| EdgeError::bad_gateway("fixture owner attachment fails"))?; + let services = noop_services(); + if buffered { + buffer_publisher_response_async( + publisher, + &Method::GET, + &settings, + ®istry, + &orchestrator, + &services, + ) + .await + } else { + publisher_response_into_streaming_response( + publisher, + &Method::GET, + settings, + ®istry, + orchestrator, + services, + ) + .await + } + .map_err(|_| EdgeError::bad_gateway("fixture finalization fails")) + } + }) + .build(); + let count = Arc::new(AtomicUsize::new(0)); + let mut app = App::new(router); + app.set_response_egress_observer(Observer(Arc::clone(&count))); + let callback = OutboundScopeOwner::callback(); + let incoming = HttpRequest::builder() + .uri("http://publisher.example.com/") + .body(EdgeBody::empty()) + .expect("should build fixture ingress"); + let outcome = callback + .run(app.dispatch_ingress( + incoming, + Instant::now(), + IngressHeadAccounting::HostManaged, + IngressFraming::NoBody, + )) + .await + .expect("should dispatch managed ingress"); + let IngressDispatchOutcome::Response(envelope) = outcome else { + panic!("should prepare managed egress"); + }; + observed(&mut source).await; + observed(&mut probe).await; + assert_scope_open(true); + assert!( + !probe.is_closed(), + "should retain producer work after publisher handler return" + ); + if matches!(terminal, Terminal::DropEnvelope) { + drop(envelope); + assert_scope_open(false); + closed(&mut probe).await; + assert_eq!( + count.load(Ordering::SeqCst), + 0, + "should not invent a terminal attempt before egress begins" + ); + drop(callback); + return; + } + let (prepared, _, mut attempt, clock) = envelope + .begin(ResponseEgressFramingMode::HostAutomatic) + .map_err(|_| ()) + .expect("should accept the original body owner"); + drop(callback); + assert_scope_open(true); + assert!( + attempt.begin_writing(), + "should begin exactly one egress attempt" + ); + let body = prepared + .into_response() + .into_body() + .into_bytes_bounded(1024 * 1024) + .await + .expect("should consume the entire publisher output"); + assert!(!body.is_empty(), "should emit publisher bytes"); + assert_scope_open(true); + assert!( + !probe.is_closed(), + "should not cancel unrelated response-owned work at origin or output EOF" + ); + assert_eq!( + count.load(Ordering::SeqCst), + 0, + "should not report completion at body EOF" + ); + match terminal { + Terminal::Complete => { + assert!(attempt.complete(clock.now()), "should complete egress once"); + assert!( + !attempt.complete(clock.now()), + "should reject a second terminal transition" + ); + } + Terminal::DropAttempt => drop(attempt), + Terminal::DropEnvelope => unreachable!("should handle envelope drop before begin"), + } + assert_scope_open(false); + closed(&mut probe).await; + assert_eq!( + count.load(Ordering::SeqCst), + 1, + "should notify exactly once after delivery termination" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn publisher_finalizers_preserve_the_original_owner_until_managed_completion() { + LocalSet::new() + .run_until(async { + for shape in [ + Shape::Buffered, + Shape::PassThrough, + Shape::Processed, + Shape::Template, + ] { + for buffered in [false, true] { + exercise(shape, buffered, Terminal::Complete).await; + } + } + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn dropping_egress_attempt_after_eof_cancels_the_original_owner_once() { + LocalSet::new() + .run_until(async { + for buffered in [false, true] { + exercise(Shape::Processed, buffered, Terminal::DropAttempt).await; + } + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn dropping_an_unbegun_envelope_cancels_original_work_without_parent_revocation() { + LocalSet::new() + .run_until(async { + for buffered in [false, true] { + exercise(Shape::Processed, buffered, Terminal::DropEnvelope).await; + } + }) + .await; +} diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index d35e51a151..5722edfa47 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -98,8 +98,10 @@ pub struct VerifySignatureResponse { pub error: Option, } -const VERIFY_MAX_BODY_BYTES: usize = 4096; -const ADMIN_MAX_BODY_BYTES: usize = 4096; +/// Maximum buffered signature-verification input. +pub const VERIFY_MAX_BODY_BYTES: usize = 4096; +/// Maximum buffered key-management input. +pub const ADMIN_MAX_BODY_BYTES: usize = 4096; /// Will verify a signature given a payload and kid /// Useful for testing integration with signatures diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 37d54614c0..31aa919995 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -1,5 +1,12 @@ #[cfg(test)] pub(crate) mod logging; +pub mod named_route_inputs; +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) mod native_outbound; +#[cfg(test)] +mod pre_dispatch; +#[cfg(test)] +pub(crate) use pre_dispatch::PreDispatchHookTestExt; #[cfg(test)] pub mod tests { diff --git a/crates/trusted-server-core/src/test_support/named_route_inputs.rs b/crates/trusted-server-core/src/test_support/named_route_inputs.rs new file mode 100644 index 0000000000..18c879fb6f --- /dev/null +++ b/crates/trusted-server-core/src/test_support/named_route_inputs.rs @@ -0,0 +1,112 @@ +//! Shared route-input regressions for adapter test suites. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use bytes::Bytes; +use edgezero_core::body::Body; +use edgezero_core::http::{StatusCode, request_builder}; +use edgezero_core::router::RouterService; +use futures::StreamExt as _; + +use crate::proxy::SIGN_MAX_BODY_BYTES; +use crate::settings::Settings; + +/// Returns fictional settings for the local first-party signing route. +/// +/// # Panics +/// Panics if the fixed test configuration becomes invalid. +/// +/// # Examples +/// ```ignore +/// let settings = sign_settings(); +/// ``` +#[must_use] +pub fn sign_settings() -> Settings { + Settings::from_toml( + r#" + [[handlers]] + path = "^/_ts/admin" + username = "example-user" + password = "example-password" + [publisher] + domain = "publisher.example.com" + cookie_domain = ".publisher.example.com" + origin_url = "https://origin.example.com" + proxy_secret = "fictional-proxy-signing-secret-0123456789" + [ec] + passphrase = "fictional-ec-passphrase-32-bytes-minimum" + [rewrite] + include_domains = ["asset.example.com"] + [proxy] + allowed_domains = ["asset.example.com"] + [integrations.nextjs] + enabled = false + "#, + ) + .expect("should parse fictional signing settings") +} + +/// Asserts streamed signing input is buffered once under its existing byte cap. +/// +/// # Panics +/// Panics if routing fails, valid JSON is rejected, oversized JSON is accepted, +/// or an overflowing frame causes another input frame to be read. +/// +/// # Examples +/// ```ignore +/// assert_sign_inputs(&router).await; +/// ``` +pub async fn assert_sign_inputs(router: &RouterService) { + let valid = Body::stream(futures::stream::iter([ + Bytes::from_static(b"{\"url\":\"https://asset."), + Bytes::from_static(b"example.com/image.png\"}"), + ])); + let request = request_builder() + .method("POST") + .uri("https://publisher.example.com/first-party/sign") + .header("content-type", "application/json") + .body(valid) + .expect("should construct a streamed signing request"); + let response = router + .oneshot(request) + .await + .expect("should route streamed signing input"); + assert_eq!( + response.status(), + StatusCode::OK, + "should parse valid JSON from the admitted guarded input" + ); + + let oversized = Bytes::from(format!( + "{{\"url\":\"https://asset.example.com/{}\"}}", + "a".repeat(SIGN_MAX_BODY_BYTES) + )); + let count = Arc::new(AtomicUsize::new(0)); + let reads = Arc::clone(&count); + let body = Body::stream( + futures::stream::iter([oversized, Bytes::from_static(b" ")]).inspect(move |_| { + reads.fetch_add(1, Ordering::SeqCst); + }), + ); + let request = request_builder() + .method("POST") + .uri("https://publisher.example.com/first-party/sign") + .header("content-type", "application/json") + .body(body) + .expect("should construct oversized streamed JSON"); + let response = router + .oneshot(request) + .await + .expect("should route the bounded rejection"); + assert_eq!( + response.status(), + StatusCode::PAYLOAD_TOO_LARGE, + "should enforce the existing signing cap before JSON handling" + ); + assert_eq!( + count.load(Ordering::SeqCst), + 1, + "should not read another frame after crossing the cap" + ); +} diff --git a/crates/trusted-server-core/src/test_support/native_outbound.rs b/crates/trusted-server-core/src/test_support/native_outbound.rs new file mode 100644 index 0000000000..4d8e006ee8 --- /dev/null +++ b/crates/trusted-server-core/src/test_support/native_outbound.rs @@ -0,0 +1,179 @@ +//! Real native provider and disposable loopback origins for crate tests. + +use std::io::Write as _; +use std::time::Duration; + +use edgezero_adapter_axum::outbound::AxumOutboundClient; +use edgezero_core::body::Body; +use edgezero_core::outbound::HttpClient; +use flate2::{Compression, write::GzEncoder}; +use http::Request; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; + +use crate::platform::{AuctionTargetId, EdgeZeroHttpClient, PlatformHttpRequest}; + +#[derive(Clone, Copy)] +pub(crate) enum Reply { + Complete, + Html, + Gzip, + TrickleBody, + Redirect, + HeldHeaders, + HeldBody, + Failed, +} + +pub(crate) struct Origin { + pub(crate) url: String, + seen: oneshot::Receiver, + closed: oneshot::Receiver<()>, + task: JoinHandle<()>, +} + +impl Origin { + pub(crate) fn is_closed(&mut self) -> bool { + match self.closed.try_recv() { + Ok(()) => true, + Err(oneshot::error::TryRecvError::Empty) => false, + Err(oneshot::error::TryRecvError::Closed) => { + panic!("should retain the connection observer") + } + } + } +} + +impl Drop for Origin { + fn drop(&mut self) { + self.task.abort(); + } +} + +pub(crate) async fn origin(reply: Reply) -> Origin { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("should bind a fixture origin"); + let address = listener + .local_addr() + .expect("should read the fixture address"); + let (seen, incoming) = oneshot::channel(); + let (closed, closure) = oneshot::channel(); + let task = tokio::spawn(async move { + let (mut socket, _) = listener + .accept() + .await + .expect("should accept the fixture request"); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + let count = socket + .read(&mut byte) + .await + .expect("should read a request byte"); + assert_ne!(count, 0, "should receive the complete request head"); + head.push(byte[0]); + } + seen.send(String::from_utf8(head).expect("should read the fixture head as UTF8")) + .expect("should retain the request observer"); + match reply { + Reply::Complete => { + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close, x-private-hop\r\nX-Private-Hop: remove\r\nSet-Cookie: a=one\r\nSet-Cookie: b=two\r\n\r\nhello").await.expect("should write the complete fixture response"); + } + Reply::Html => { + let body = "fixture"; + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.expect("should write the publisher origin"); + } + Reply::Gzip => { + let body = gzip_hello(); + socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()).await.expect("should write the coded response head"); + socket + .write_all(&body) + .await + .expect("should write the original coded bytes"); + } + Reply::Redirect => { + socket.write_all(b"HTTP/1.1 302 Found\r\nContent-Length: 0\r\nConnection: close\r\nLocation: /followed\r\n\r\n").await.expect("should write a redirect without following it"); + } + Reply::TrickleBody => { + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100000\r\n\r\n") + .await + .expect("should write a trickling response head"); + loop { + tokio::time::sleep(Duration::from_millis(10)).await; + if socket.write_all(b"a").await.is_err() { + break; + } + } + } + Reply::HeldBody => { + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\na") + .await + .expect("should write an incomplete response body"); + let mut byte = [0]; + while socket.read(&mut byte).await.unwrap_or_default() != 0 {} + } + Reply::HeldHeaders => { + let mut byte = [0]; + while socket.read(&mut byte).await.unwrap_or_default() != 0 {} + } + Reply::Failed => {} + } + drop(socket); + let _ = closed.send(()); + }); + Origin { + url: format!("http://{address}/"), + seen: incoming, + closed: closure, + task, + } +} + +pub(crate) fn clients() -> (HttpClient, EdgeZeroHttpClient) { + let provider = AxumOutboundClient::try_new().expect("should build the real native provider"); + let sdk = HttpClient::with_client(provider); + let client = EdgeZeroHttpClient::new(sdk.clone(), AuctionTargetId::Axum); + (sdk, client) +} + +pub(crate) fn client() -> EdgeZeroHttpClient { + clients().1 +} + +pub(crate) fn gzip_hello() -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(b"hello") + .expect("should encode the fixture body"); + encoder.finish().expect("should finish the fixture coding") +} + +pub(crate) fn request(url: &str, backend: &str) -> PlatformHttpRequest { + PlatformHttpRequest::new( + Request::builder() + .uri(url) + .body(Body::empty()) + .expect("should build the fixture request"), + backend, + ) + .with_cache_bypass() +} + +pub(crate) async fn observed(origin: &mut Origin) -> String { + tokio::time::timeout(Duration::from_secs(3), &mut origin.seen) + .await + .expect("should reach the real origin") + .expect("should observe the request") +} + +pub(crate) async fn closed(origin: &mut Origin) { + tokio::time::timeout(Duration::from_secs(3), &mut origin.closed) + .await + .expect("should close canceled native work") + .expect("should observe connection closure"); +} diff --git a/crates/trusted-server-core/src/test_support/pre_dispatch.rs b/crates/trusted-server-core/src/test_support/pre_dispatch.rs new file mode 100644 index 0000000000..34a846b347 --- /dev/null +++ b/crates/trusted-server-core/src/test_support/pre_dispatch.rs @@ -0,0 +1,83 @@ +//! Exercises head-only pre-dispatch hooks through their real router boundary. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use edgezero_core::PreDispatchRequest; +use edgezero_core::body::Body; +use edgezero_core::context::RequestContext; +use edgezero_core::error::EdgeError; +use edgezero_core::http::{Method, Request, RequestParts, Response}; +use edgezero_core::router::{PreDispatchHook, RouterService}; + +#[derive(Clone)] +struct OrdinaryResponse; + +struct ObservedHook { + hook: Arc, + head: Arc>>, +} + +#[async_trait(?Send)] +impl PreDispatchHook for ObservedHook { + async fn handle( + &self, + request: &mut PreDispatchRequest, + ) -> Result, EdgeError> { + let response = self.hook.handle(request).await; + *self.head.lock().expect("should lock the observed head") = Some(request.parts().clone()); + response + } +} + +async fn ordinary_request(context: RequestContext) -> Result { + let mut response = Response::new(context.into_request()?.into_body()); + response.extensions_mut().insert(OrdinaryResponse); + Ok(response) +} + +/// Runs a hook against protected input while retaining ordinary request bodies. +#[async_trait(?Send)] +pub(crate) trait PreDispatchHookTestExt { + async fn handle_request(&self, request: &mut Request) -> Result, EdgeError>; +} + +#[async_trait(?Send)] +impl PreDispatchHookTestExt for T +where + T: PreDispatchHook + Clone + 'static, +{ + async fn handle_request(&self, request: &mut Request) -> Result, EdgeError> { + let head = Arc::new(Mutex::new(None)); + let mut router = RouterService::builder().pre_dispatch_hook_arc(Arc::new(ObservedHook { + hook: Arc::new(self.clone()), + head: Arc::clone(&head), + })); + for method in [ + Method::GET, + Method::HEAD, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ] { + router = router.route("/", method.clone(), ordinary_request); + router = router.route("/{*rest}", method, ordinary_request); + } + let incoming = std::mem::replace(request, Request::new(Body::empty())); + let response = router.build().oneshot(incoming).await?; + let parts = head + .lock() + .expect("should lock the observed head") + .take() + .expect("should observe the protected pre-dispatch head"); + if response.extensions().get::().is_some() { + *request = Request::from_parts(parts, response.into_body()); + Ok(None) + } else { + *request = Request::from_parts(parts, Body::empty()); + Ok(Some(response)) + } + } +} diff --git a/crates/trusted-server-core/src/trace/actions.rs b/crates/trusted-server-core/src/trace/actions.rs index 2343e6def1..e7605a4f7a 100644 --- a/crates/trusted-server-core/src/trace/actions.rs +++ b/crates/trusted-server-core/src/trace/actions.rs @@ -2,11 +2,10 @@ use edgezero_core::body::Body as EdgeBody; use edgezero_core::ingress::RequestIngress; -use futures::StreamExt as _; -use http::{HeaderMap, HeaderName, HeaderValue, Request, Response, StatusCode, header}; +use http::{HeaderMap, HeaderName, HeaderValue, Response, StatusCode, header}; use url::Url; -use crate::forwarder::{PublicOrigin, public_origin}; +use crate::forwarder::{PublicOrigin, public_origin_parts}; use crate::integrations::gpt_diagnostics::GptDiagnosticsCookieAction; use super::routes::TraceRoute; @@ -25,7 +24,7 @@ const FETCH_SITE_HEADER: HeaderName = HeaderName::from_static("sec-fetch-site"); /// skips empty chunks and stops on the first nonempty chunk or read error; /// it does not allocate a body buffer or introduce a transport deadline. pub(crate) async fn action_response( - request: &mut Request, + request: &edgezero_core::PreDispatchRequest, route: TraceRoute, ) -> Response { let (action, cookie_action) = match route { @@ -38,15 +37,19 @@ pub(crate) async fn action_response( != Some(action) || single_header(request.headers(), FETCH_SITE_HEADER).map(HeaderValue::as_bytes) != Some(b"same-origin".as_slice()) - || !matches_trusted_origin(request) + || !matches_trusted_origin(request.parts()) { return failure(StatusCode::FORBIDDEN); } if !accepts_empty_framing(request.headers()) { return failure(StatusCode::PAYLOAD_TOO_LARGE); } - let body = std::mem::replace(request.body_mut(), EdgeBody::empty()); - if let Some(status) = empty_body_rejection(body).await { + if let Err(error) = request.body_bytes(0).await { + let status = match error.status() { + StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE, + StatusCode::REQUEST_TIMEOUT => StatusCode::REQUEST_TIMEOUT, + _ => StatusCode::BAD_REQUEST, + }; return failure(status); } let Some(cookie) = cookie_action.set_cookie_header() else { @@ -75,9 +78,9 @@ fn single_header(headers: &HeaderMap, name: HeaderName) -> Option<&HeaderValue> fields.next().is_none().then_some(value) } -fn matches_trusted_origin(request: &Request) -> bool { +fn matches_trusted_origin(request: &edgezero_core::http::RequestParts) -> bool { let Some(trusted) = request - .extensions() + .extensions .get::() .and_then(RequestIngress::origin) else { @@ -86,31 +89,31 @@ fn matches_trusted_origin(request: &Request) -> bool { let Some(expected) = canonical_authority_origin(trusted.scheme(), trusted.authority()) else { return false; }; - let Some(origin) = single_header(request.headers(), header::ORIGIN) + let Some(origin) = single_header(&request.headers, header::ORIGIN) .and_then(|origin| core::str::from_utf8(origin.as_bytes()).ok()) .and_then(canonical_origin) else { return false; }; let public_expected = - public_origin(request).map_or_else(|| expected.clone(), PublicOrigin::serialized); + public_origin_parts(request).map_or_else(|| expected.clone(), PublicOrigin::serialized); // Public origin authorizes the browser. The received URI and Host must // still agree with immutable transport ingress below. if origin != public_expected || request - .uri() + .uri .scheme_str() .is_some_and(|scheme| !scheme.eq_ignore_ascii_case(trusted.scheme())) { return false; } - let authority = request.uri().authority(); + let authority = request.uri.authority(); if authority.is_some_and(|authority| { canonical_authority_origin(trusted.scheme(), authority.as_str()).as_ref() != Some(&expected) }) { return false; } - match single_header(request.headers(), header::HOST) { + match single_header(&request.headers, header::HOST) { Some(host) => { core::str::from_utf8(host.as_bytes()) .ok() @@ -118,8 +121,8 @@ fn matches_trusted_origin(request: &Request) -> bool { .as_ref() == Some(&expected) } - None if request.headers().contains_key(header::HOST) => false, - None => request.uri().scheme().is_some() && authority.is_some(), + None if request.headers.contains_key(header::HOST) => false, + None => request.uri.scheme().is_some() && authority.is_some(), } } @@ -183,28 +186,12 @@ fn accepts_empty_framing(headers: &HeaderMap) -> bool { && length.as_bytes().iter().all(|byte| *byte == b'0') } -async fn empty_body_rejection(body: EdgeBody) -> Option { - match body { - EdgeBody::Once(bytes) if bytes.is_empty() => None, - EdgeBody::Once(_) => Some(StatusCode::PAYLOAD_TOO_LARGE), - EdgeBody::Stream(mut stream) => { - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) if bytes.is_empty() => {} - Ok(_) => return Some(StatusCode::PAYLOAD_TOO_LARGE), - Err(_) => return Some(StatusCode::BAD_REQUEST), - } - } - None - } - } -} - #[cfg(test)] mod tests { use std::cell::Cell; use std::io; use std::rc::Rc; + use std::sync::Arc; use std::task::Poll; use base64::{Engine as _, engine::general_purpose::STANDARD}; @@ -220,7 +207,9 @@ mod tests { use crate::settings::{Handler, Settings}; use crate::test_support::tests::create_test_settings; - use crate::trace::{TracePreflight, TraceTerminalResponse, inspect_cookies, preflight}; + use crate::trace::{ + TraceMetadata, TracePreDispatchHook, TraceTerminalResponse, inspect_cookies, + }; fn settings(enabled: bool, auth: bool) -> Settings { let mut settings = create_test_settings(); @@ -265,15 +254,18 @@ mod tests { request } - fn respond(settings: &Settings, mut request: Request) -> Response { - match preflight(settings, &mut request) { - TracePreflight::Response(response) => response, - TracePreflight::Ready(context) => { - let response = block_on(super::super::action_response(&mut request, context.route)); - context.respond(response) - } - TracePreflight::NotTrace => panic!("should preflight every exact action route locally"), - } + fn respond(settings: &Settings, request: Request) -> Response { + let hook = TracePreDispatchHook::new( + Arc::new(settings.clone()), + Arc::new(|_| TraceMetadata { + client_info: crate::platform::ClientInfo::default(), + geo: None, + }), + ); + let router = edgezero_core::router::RouterService::builder() + .pre_dispatch_hook_arc(Arc::new(hook)) + .build(); + block_on(router.oneshot(request)).expect("should dispatch the protected trace action") } fn private_error(response: Response, expected: StatusCode) { @@ -937,7 +929,7 @@ mod tests { let polls = Rc::new(Cell::new(0)); let captured = Rc::clone(&polls); let mut request = request("end"); - *request.body_mut() = EdgeBody::from_stream(stream::poll_fn( + *request.body_mut() = EdgeBody::from_external_stream(stream::poll_fn( move |_| -> Poll>> { captured.set(captured.get() + 1); Poll::Ready(Some(match captured.get() { diff --git a/crates/trusted-server-core/src/trace/dispatch.rs b/crates/trusted-server-core/src/trace/dispatch.rs index 183704ac29..2dd1ee20f7 100644 --- a/crates/trusted-server-core/src/trace/dispatch.rs +++ b/crates/trusted-server-core/src/trace/dispatch.rs @@ -3,18 +3,19 @@ use std::sync::Arc; use async_trait::async_trait; use chrono::{DateTime, Utc}; +use edgezero_core::PreDispatchRequest; use edgezero_core::body::Body; use edgezero_core::error::EdgeError; use edgezero_core::ingress::RequestIngress; use edgezero_core::router::PreDispatchHook; -use http::{HeaderMap, Method, Request, Response, StatusCode}; +use http::{HeaderMap, Method, Response, StatusCode}; use trusted_server_js::trace_assets::trace_asset; use super::actions::action_response; use super::routes::{TraceRoute, state_response}; use super::shell::render_setup_shell; use super::{TraceCookies, TracePreflight, inspect_cookies, preflight, project_request_context}; -use crate::forwarder::prepare_trusted_forwarder; +use crate::forwarder::prepare_trusted_forwarder_parts; use crate::integrations::gpt_diagnostics::{ GPT_DIAGNOSTICS_INTEGRATION_ID, GptDiagnosticsConfig, GptDiagnosticsRequestDecision, }; @@ -87,9 +88,10 @@ pub struct TraceMetadata { pub geo: Option, } -type MetadataSupplier = dyn Fn(&Request) -> TraceMetadata + Send + Sync; +type MetadataSupplier = dyn Fn(&edgezero_core::http::RequestParts) -> TraceMetadata + Send + Sync; /// Intercept the trace namespace before ordinary router lookup and middleware. +#[derive(Clone)] pub struct TracePreDispatchHook { settings: Arc, metadata: Arc, @@ -117,7 +119,7 @@ impl TracePreDispatchHook { impl PreDispatchHook for TracePreDispatchHook { async fn handle( &self, - request: &mut Request, + request: &mut PreDispatchRequest, ) -> Result>, EdgeError> { self.handle_with_clock(request, Utc::now).await } @@ -126,15 +128,15 @@ impl PreDispatchHook for TracePreDispatchHook { impl TracePreDispatchHook { async fn handle_with_clock( &self, - request: &mut Request, + request: &mut PreDispatchRequest, clock: impl FnOnce() -> DateTime, ) -> Result>, EdgeError> { // Resolve before origin preparation removes headers that may also be // configured as client-IP credentials. Retain only the authenticated IP. let forwarded_client_ip = authenticated_client_ip(request.headers(), self.settings.trusted_client_ip.as_ref()); - prepare_trusted_forwarder(request, &self.settings); - let dispatch = match preflight(&self.settings, request) { + prepare_trusted_forwarder_parts(request.parts_mut(), &self.settings); + let dispatch = match preflight(&self.settings, request.parts_mut()) { TracePreflight::NotTrace => { let enabled = self .settings @@ -187,7 +189,7 @@ impl TracePreDispatchHook { geo: None, } } else { - let mut metadata = (self.metadata)(request); + let mut metadata = (self.metadata)(request.parts()); if let Some(client_ip) = forwarded_client_ip { if metadata.client_info.client_ip != Some(client_ip) { // Runtime geolocation describes the forwarder when @@ -253,7 +255,7 @@ mod tests { use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::router::RouterService; use futures::executor::block_on; - use http::{HeaderValue, Method, StatusCode, header}; + use http::{HeaderValue, Method, Request, StatusCode, header}; use serde_json::json; use crate::redacted::Redacted; @@ -262,6 +264,57 @@ mod tests { use super::*; + #[derive(Clone)] + struct OrdinaryResponse; + + struct ObservedHook { + hook: TracePreDispatchHook, + clock: DateTime, + observed: Arc>>, + } + + #[async_trait(?Send)] + impl PreDispatchHook for ObservedHook { + async fn handle( + &self, + request: &mut PreDispatchRequest, + ) -> Result>, EdgeError> { + let response = self.hook.handle_with_clock(request, || self.clock).await; + *self.observed.lock().expect("should lock observed head") = + Some(request.parts().clone()); + response + } + } + + fn run_hook( + hook: &TracePreDispatchHook, + request: &mut Request, + clock: Option>, + ) -> Result>, EdgeError> { + let observed = Arc::new(std::sync::Mutex::new(None)); + let router = RouterService::builder() + .pre_dispatch_hook_arc(Arc::new(ObservedHook { + hook: hook.clone(), + clock: clock.unwrap_or_else(Utc::now), + observed: Arc::clone(&observed), + })) + .route("/{*rest}", Method::GET, |_context: RequestContext| async { + let mut response = Response::new(Body::empty()); + response.extensions_mut().insert(OrdinaryResponse); + Ok::<_, EdgeError>(response) + }) + .build(); + let incoming = std::mem::replace(request, Request::new(Body::empty())); + let response = block_on(router.oneshot(incoming))?; + let parts = observed + .lock() + .expect("should lock observed head") + .take() + .expect("should observe the protected pre-dispatch head"); + *request = Request::from_parts(parts, Body::empty()); + Ok((response.extensions().get::().is_none()).then_some(response)) + } + fn hook(enabled: bool, calls: Arc) -> TracePreDispatchHook { let mut settings = create_test_settings(); settings @@ -357,7 +410,7 @@ mod tests { .header("x-ts-forwarder-auth", "fictional-client-secret-0123456789") .body(Body::empty()) .expect("should build setup request"); - let response = block_on(hook.handle(&mut request)) + let response = run_hook(&hook, &mut request, None) .expect("should dispatch trace") .expect("should intercept setup"); let bytes = response @@ -400,7 +453,7 @@ mod tests { .body(Body::empty()) .expect("should construct a preflight request"); let _response = - block_on(hook.handle(&mut request)).expect("should complete local preflight"); + run_hook(&hook, &mut request, None).expect("should complete local preflight"); assert!( !request.headers().contains_key("x-ts-forwarder-auth"), "should sanitize credentials before any preflight return" @@ -425,7 +478,7 @@ mod tests { .uri("/_ts/trace") .body(Body::empty()) .expect("should construct a shell request"); - let response = block_on(hook.handle_with_clock(&mut request, || clock)) + let response = run_hook(&hook, &mut request, Some(clock)) .expect("should dispatch the trace shell") .expect("should intercept the shell"); assert_eq!( @@ -481,7 +534,7 @@ mod tests { .uri(path) .body(Body::empty()) .expect("should build trace request"); - let response = block_on(hook.handle(&mut request)) + let response = run_hook(&hook, &mut request, None) .expect("should return local policy response") .expect("should intercept namespace before lookup"); assert_eq!( @@ -527,7 +580,7 @@ mod tests { .uri("/_ts/trace") .body(Body::empty()) .expect("should build disabled request"); - let response = block_on(hook.handle(&mut request)) + let response = run_hook(&hook, &mut request, None) .expect("should produce local response") .expect("should reserve disabled namespace"); assert_eq!( @@ -563,7 +616,7 @@ mod tests { .body(Body::empty()) .expect("should build ordinary request"); assert!( - block_on(hook.handle(&mut request)) + run_hook(&hook, &mut request, None) .expect("should continue ordinary dispatch") .is_none(), "should continue ordinary requests" @@ -686,7 +739,7 @@ mod tests { ); } let _ = - block_on(hook.handle(&mut request)).expect("should continue ordinary navigation"); + run_hook(&hook, &mut request, None).expect("should continue ordinary navigation"); let gate = *request .extensions() .get::() diff --git a/crates/trusted-server-core/src/trace/mod.rs b/crates/trusted-server-core/src/trace/mod.rs index ac3ca75902..a6e9dd3377 100644 --- a/crates/trusted-server-core/src/trace/mod.rs +++ b/crates/trusted-server-core/src/trace/mod.rs @@ -13,8 +13,6 @@ mod routes; mod shell; mod types; -#[cfg(test)] -pub(crate) use actions::action_response; pub use auction::{ DiagnosticAuctionId, TraceAuctionEvidenceV1, TraceAuctionSource, TraceAuctionTerminalReason, TraceAuctionTerminalStatus, TraceAuctionTransportV1, TraceProviderRole, TraceProviderStatus, diff --git a/crates/trusted-server-core/src/trace/routes.rs b/crates/trusted-server-core/src/trace/routes.rs index 147d2755a2..719f3c3271 100644 --- a/crates/trusted-server-core/src/trace/routes.rs +++ b/crates/trusted-server-core/src/trace/routes.rs @@ -1,10 +1,10 @@ use std::borrow::Cow; use edgezero_core::body::Body as EdgeBody; -use http::{HeaderValue, Method, Request, Response, StatusCode, header}; +use http::{HeaderValue, Method, Response, StatusCode, header}; use sha2::{Digest as _, Sha256}; -use crate::auth::{TraceAuthLogPolicy, enforce_basic_auth}; +use crate::auth::{TraceAuthLogPolicy, enforce_basic_auth_parts}; use crate::integrations::gpt_diagnostics::{GPT_DIAGNOSTICS_INTEGRATION_ID, GptDiagnosticsConfig}; use crate::response_privacy::enforce_terminal_private_cache_privacy; use crate::settings::Settings; @@ -239,14 +239,17 @@ pub fn is_trace_path(path: &str) -> bool { /// } /// ``` #[must_use] -pub fn preflight(settings: &Settings, request: &mut Request) -> TracePreflight { - let classification = classify_path(request.uri().path()); +pub fn preflight( + settings: &Settings, + request: &mut edgezero_core::http::RequestParts, +) -> TracePreflight { + let classification = classify_path(request.uri.path()); if classification == Classification::NotTrace { return TracePreflight::NotTrace; } - let head = request.method() == Method::HEAD; - request.extensions_mut().insert(TraceAuthLogPolicy); - match enforce_basic_auth(settings, request) { + let head = request.method == Method::HEAD; + request.extensions.insert(TraceAuthLogPolicy); + match enforce_basic_auth_parts(settings, request) { Ok(Some(mut response)) => { *response.body_mut() = error_response(StatusCode::UNAUTHORIZED).into_body(); return reject(response, head); @@ -268,14 +271,14 @@ pub fn preflight(settings: &Settings, request: &mut Request) -> TraceP Classification::Rejected(status) => return reject(error_response(status), head), Classification::NotTrace => return TracePreflight::NotTrace, }; - if !route.allows(request.method()) { + if !route.allows(&request.method) { let mut response = error_response(StatusCode::METHOD_NOT_ALLOWED); response .headers_mut() .insert(header::ALLOW, HeaderValue::from_static(route.allow())); return reject(response, head); } - let protected = match settings.handler_for_path(request.uri().path()) { + let protected = match settings.handler_for_path(request.uri.path()) { Ok(handler) => handler.is_some(), Err(_) => return reject(error_response(StatusCode::INTERNAL_SERVER_ERROR), head), }; @@ -404,16 +407,24 @@ mod tests { use super::*; use base64::{Engine as _, engine::general_purpose::STANDARD}; - use http::{HeaderValue, header}; + use http::{HeaderValue, Request, header}; use serde_json::json; - use crate::auth::EdgeTerminatedAuthorization; + use crate::auth::{EdgeTerminatedAuthorization, enforce_basic_auth}; use crate::cache_policy::EDGE_CACHE_HEADER_NAMES; use crate::settings::Handler; use crate::test_support::logging::WarningCapture; use crate::test_support::tests::create_test_settings; use crate::trace::TraceTerminalResponse; + fn preflight(settings: &Settings, request: &mut Request) -> TracePreflight { + let original = std::mem::replace(request, Request::new(EdgeBody::empty())); + let (mut parts, body) = original.into_parts(); + let outcome = super::preflight(settings, &mut parts); + *request = Request::from_parts(parts, body); + outcome + } + fn handler(pattern: &str, username: &str, password: &str) -> Handler { serde_json::from_value(json!({"path": pattern, "username": username, "password": password})) .expect("should deserialize the example authentication handler") diff --git a/scripts/test-worker-auction-deadlines.mjs b/scripts/test-worker-auction-deadlines.mjs index f3cbad36ab..39c49a8291 100644 --- a/scripts/test-worker-auction-deadlines.mjs +++ b/scripts/test-worker-auction-deadlines.mjs @@ -10,6 +10,7 @@ import path from 'node:path' import { setTimeout as delay } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { parseArgs, promisify } from 'node:util' +import { gzipSync } from 'node:zlib' const { values, positionals } = parseArgs({ allowPositionals: true, @@ -116,6 +117,12 @@ const origin = http.createServer(async (request, response) => { if (request.url === '/headers') { await delay(700, undefined, { signal: cancellation.signal }) } + if (request.url === '/gzip') { + const body = gzipSync(Buffer.from('okay')) + response.writeHead(200, { 'Content-Length': String(body.length), 'Content-Encoding': 'gzip' }) + response.end(body) + return + } if (request.url === '/large') { const body = Buffer.alloc(8 * 1024 * 1024 + 1) response.writeHead(200, { 'Content-Length': String(body.length) }) @@ -253,11 +260,12 @@ allowed_outbound_hosts = ["http://127.0.0.1:*"] ['/headers', 80, 0, 'timeout', 350], ['/body', 80, 0, 'timeout', 350], ['/headers', 80, 200, 'timeout', 500], + ['/gzip', 1000, 0, 'complete', 1500], ] if (adapter === 'spin') cases.push(['/large', 2000, 0, 'oversized', 2500]) assert.equal( cases.length, - adapter === 'spin' ? 5 : 4, + adapter === 'spin' ? 6 : 5, 'should exercise every runtime case' ) for (const [endpoint, budget, originDelay, expected, maximum] of cases) {