From 180a0e4877e9a54cc06283e57308c6805d93fb7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 12 Oct 2026 11:22:42 +0200 Subject: [PATCH] v0.31.8 --- AGENTS.md | 6 +- CHANGELOG.md | 10 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 16 +- ROADMAP.md | 11 + SHA256SUMS | 128 +- dev.sh | 3 + docs/ARCHITECTURE.md | 7 +- docs/DOCKER_DISCOVERY.md | 2 + docs/FILE_LOGS.md | 6 + docs/TEST_REPORT.md | 19 +- langs/en_US.json | 7 +- langs/pl_pl.json | 7 +- scripts/resolve_dependencies_0312.sh | 4 +- src/admin/file_logs.rs | 2 + src/admin/file_logs_query.rs | 99 +- src/admin/openapi.json | 34 +- src/admin/preferences.rs | 14 + src/admin/tests.rs | 2672 +++++++++++++++++- src/admin/web/app/core.js | 4 + src/admin/web/docker.js | 14 +- src/admin/web/error_400.html | 37 + src/admin/web/error_408.html | 37 + src/admin/web/error_410.html | 37 + src/admin/web/error_413.html | 37 + src/admin/web/error_500.html | 37 + src/admin/web/error_502.html | 37 + src/admin/web/file-logs.js | 30 +- src/admin/web/status_200.html | 37 + src/admin/web/style.css | 19 + src/balance/tests.rs | 643 ++++- src/cache/fill.rs | 59 +- src/cache/lookup/tiered.rs | 12 +- src/cache/tests/cold_tier_tests.rs | 746 ++++- src/cache/tests/tests.rs | 1484 +++++++++- src/cache/tests/v022_cache_tests.rs | 950 ++++++- src/config/tests.rs | 908 +++++- src/geoip.rs | 22 +- src/geoip/tests.rs | 18 + src/pages.rs | 40 +- src/pages/tests.rs | 16 +- src/web_server/tests.rs | 1400 ++++++++- tools/checks/check_source.py | 23 +- tools/checks/dependency_upgrade_0312.py | 6 +- tools/checks/docker_startup_0318_ui_test.mjs | 167 ++ tools/checks/docs_check.py | 4 +- tools/checks/h3_navigation_0316.py | 2 +- tools/checks/memory_contracts.py | 2 +- tools/checks/preflight.py | 6 +- tools/checks/release_0318.py | 46 + tools/checks/release_0318_ui_test.mjs | 63 + tools/checks/release_smoke.py | 20 +- tools/checks/rust_module_contracts.py | 47 +- tools/checks/security_logs_0315_ui_test.mjs | 4 +- tools/checks/system_info_0301_ui_test.mjs | 6 +- 56 files changed, 9866 insertions(+), 205 deletions(-) create mode 100644 src/admin/web/error_400.html create mode 100644 src/admin/web/error_408.html create mode 100644 src/admin/web/error_410.html create mode 100644 src/admin/web/error_413.html create mode 100644 src/admin/web/error_500.html create mode 100644 src/admin/web/error_502.html create mode 100644 src/admin/web/status_200.html create mode 100644 tools/checks/docker_startup_0318_ui_test.mjs create mode 100755 tools/checks/release_0318.py create mode 100644 tools/checks/release_0318_ui_test.mjs diff --git a/AGENTS.md b/AGENTS.md index 839059d..8b11c30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes. The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for -compatibility. Current source version: 0.31.7. +compatibility. Current source version: 0.31.8. ## Delivery @@ -36,6 +36,10 @@ SQLite write. Invalid updates leave the old snapshot active. ## Verification +Keep production Rust source files within 500 lines. Rust unit and integration +**test files are exempt** from this limit: do not create numbered `part_XX.rs` +fragments to satisfy source-size checks. Consolidate coherent test suites in +`tests.rs` and preserve the existing test cases. Run `./dev.sh check` and `./dev.sh test` when Rust is available. Run the SQL and upgrade scripts even when it is not. UI tests use a simulated API; the Python protocol-peer self-test is not a test of RustProxy. Record these distinctions accurately. diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e704a..719400b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ > Operator-facing release history. Historical engineering notes are kept in > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.31.8 + +- Consolidate 28 numbered Rust unit-test fragments into seven existing `tests.rs` suites (Admin, Balance, Config, Web Server and three Cache suites). Preserve the tests, remove obsolete fragment directories, and exempt Rust test sources (not production modules) from the 500-line check. Fix the test fixture's relative `login-keys.json` include path. No runtime functionality changes. +- Fix Docker VHosts startup status: a missing first observation is WAITING, not a connection failure. Read cached agent status when opening the page (including with Live view off) and briefly retry while the first background check is pending. Display an error only after a failed agent check; omit the configured certificate SHA-256 from generic failure details because it is not a diagnosed failure cause. A configured pin alone does not mark the agent connected. Keep agent polling, certificate validation, manual refresh and persisted settings unchanged. +- Add server-side Stored Logs sorting by visible columns, including numeric and discovered nested fields; preserve sorting over the complete bounded scan before pagination. Column and direction persist with account preferences, with no SQLite schema change. +- Add matching dark HTML built-in pages for HTTP 200, 400, 408, 410, 413, 500 and 502. Align the remaining generic HTML status template with the established RustProxy appearance. Preserve administrator-configured responses, status codes, protocol headers and bodyless responses. +- Reduce long-lived GeoIP CSV allocations by storing exactly sized boxed range slices and 34-byte network-order IPv6 records instead of 48-byte u128-aligned records; retain equivalent ordered range search. +- Avoid duplicating a single uniquely-owned transformed response frame when filling HOT cache, provided its allocation capacity is known and tightly bounded; preserve the copying fallback for stream/fragmented or shared bodies, HOT accounting and HTTP content unchanged. +- Keep existing cache/transport limits and persisted configuration format. Build, Rust tests, live deployment and memory comparisons still require verification with the supported toolchain. + ## 0.31.7 - Allow intentionally pruned historical release audits and implementation notes in the 0.31.7 source archive. Update preflight, documentation/source contract checks, and the embedded Reader registry to require actual runtime/compiled documents instead. Remove stale documentation links, update Reader integration assertions, and add a regression check that still rejects missing embedded documents or runtime modules. No runtime configuration, storage format or UI behavior changed. diff --git a/Cargo.lock b/Cargo.lock index d217268..fd3220a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.31.7" +version = "0.31.8" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 5a599e5..23787bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.31.7" +version = "0.31.8" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index c1ca460..ef0d173 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# RustProxy 0.31.7 +# RustProxy 0.31.8 **0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md). Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration, streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools. -**0.31.7 per-vhost User-Agent limits, Settings grouping, revision diff, and safe update retention (Rust/Cargo and live testing required).** +**0.31.8 Stored Logs sorting, consistent built-in status pages and scoped memory optimizations (Rust/Cargo and live testing required).** ## Layer-4 streams @@ -13,7 +13,17 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners. -## What changed in 0.31.7 +## What changed in 0.31.8 + +- **Test organization:** merge 28 mechanically numbered Rust test fragments into seven coherent `tests.rs` suites. Test-only files are exempt from the 500-line production-module limit; no test cases or runtime behavior are intentionally changed. See [test report](docs/TEST_REPORT.md). +- **Stored Logs:** click a desktop column heading to sort ascending/descending (including discovered nested JSON columns). Sorting applies to the entire bounded server-side matching scan **before pagination**, with missing values last. Direction and selected sort column persist per administrator. Existing filters, mobile cards, and repeated-value analysis are unchanged. +- **Built-in HTML responses:** add missing styled pages for 200, 400, 408, 410, 413, 500 and 502. Any other built-in HTTP status uses the same compact dark appearance as the established 401/403/404/429/503/504 templates. Administrators' custom HTML remains authoritative, and 204/205/304 remain bodyless. +- **GeoIP:** freeze loaded CSV arrays to exactly sized boxed slices; pack IPv6 range addresses in network byte order (34 bytes versus 48 bytes per record) without changing lookup semantics. Expected decrease in retained GeoIP allocations is workload-dependent, approximately 17 MiB for the reported 293,569 IPv4 and 284,256 IPv6 records; verify actual RSS separately. +- **Cache fills:** avoid a duplicate body allocation when a transformed response supplies a single uniquely owned frame with known, tightly sized capacity. Preserve the existing bounded Vec-based streaming/fallback path and charge the shared backing capacity to HOT. This targets temporary peak allocations, not a guaranteed reduction of steady-state RSS. +- **Docker VHosts:** display a neutral waiting state while the first agent observation is pending after restart, then the result from background discovery. Opening Docker VHosts reads the latest cached status even with per-page Live view off; first-result synchronization stops afterward. A configured TLS fingerprint no longer falsely signals a verified connection. Agent polling and security remain unchanged. +- No settings/SQLite schema migration or change to the HOT limit. **Runtime acceptance gate remains open** until Cargo build/tests, browser, H1/H2/H3 and real-process resource tests pass. + +## Previously in 0.31.7 - **Source-package cleanup:** old per-version audits and implementation reports may be omitted; preflight still requires active sources and docs embedded in the panel Reader. This fixes the removed `docs/V0.25.5_AUDIT.md` preflight error without restoring obsolete files. - **Settings → Rules & headers:** the global User-Agent blocklist, global custom header editor and client response settings now share a Cache-style tabbed page (three persistent sub-tabs). Settings → Security keeps sessions/password and host access policies. diff --git a/ROADMAP.md b/ROADMAP.md index 6a9ccf8..0c295a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,16 @@ # RustProxy roadmap +## 0.31.8 - Stored Logs sorting, built-in pages and allocation reduction (runtime acceptance pending) + +- [x] Consolidate 28 numbered Rust test fragments into seven large `tests.rs` files; exempt test sources from the 500-line production limit and remove obsolete fragment paths. +- [x] Sort Stored Logs across the bounded server-side scan before pagination, including dynamic columns and durable account preferences. +- [x] Complete the built-in status response pages in the existing visual style, including 200 OK. +- [x] Pack GeoIP IPv6 records and remove unused CSV Vec capacity without changing lookup results. +- [x] Add bounded zero-copy transformed-cache fill where exclusive ownership and exact allocation capacity are available. +- [x] Correct Docker VHosts post-restart pending/failed status, first-read synchronization and TLS pin indicator, without changing poll/TLS/SQLite protocols. +- [ ] Compile, format, run Rust tests and check performance with Rust 1.99+, resolve/verify dependencies and run real H1/H2/H3 + browser acceptance. +- [ ] Compare stable RSS at identical HOT usage and traffic; do not claim the estimated allocation savings are measured RSS. + ## 0.31.7 - UA-specific throttling and managed update housekeeping (runtime acceptance pending) - [x] Group global UA blocking, headers and response controls in a tabbed Settings page. diff --git a/SHA256SUMS b/SHA256SUMS index dc54ae9..85960d7 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -685f856d31bf33f724aaa4afc23a6b3d9d9ca9e8e99338792db4f3563621f449 AGENTS.md -5910c76f4aacab46365bb9a549d90a5b47b1e67986efeb36dbbc6b2fbb510229 CHANGELOG.md -b8e0b191c849a1c4eac46b0091c228a0c0cc52de4c708f9e9ca23a621b870e3a Cargo.lock -3f8c093e5a499d526df5f81ee246df70b3512a8fa1cbb1e071210b6eae0af706 Cargo.toml +6ea926f3894f548532eaec87bbe4ef03cacbb9bc49d81331af11d14d49f8b3fa AGENTS.md +763041714a5be537d3d1bcd4cf393600228807706924855ccfd9663a8af5d403 CHANGELOG.md +427b7b7bffadc0440a8d3400565b528153800825c0cfc3951f6c67c33a64ae1d Cargo.lock +83a13d441c530b0a120beb45a78381b53153568f3b6c1289e5922a3a0a7383ad Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -4c58d8ad6cdbcc643616a25e5d6e3711124e0164aaaedfc1a9efa74715fdab77 README.md -db3235387db9e11f8193dfeeb853767f27487107a903cfbb0b5873b1b5124be8 ROADMAP.md +a1a145015876773f43efef010a6d1e48819e7d1ac5a61b9ef6b466ab1221ec8c README.md +9e38c8a5b4f011fcd26f467ebcbdc59658b45fa9da1622ebf7704fdb335d11ba ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service -c1addab11162ba4a763ac40548ac8ae29db5a6909940309e4bba4c0abc334e7a dev.sh +1a8fe0ebb42fe780831edbda10b6e122b70ee546598dc80a13ec4cde5d516e03 dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example @@ -29,15 +29,15 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md e25ce97c6d1d75f0cab162eee28c1cb77561b210ad36ba7189d81328c3bb4d6c docs/API.md b0c6fae1d538f33fb468a00307cbb745a601d9fc341c0a4f859d60c7e8d48865 docs/API_ACCESS.md -4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md +6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md 9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md 3822d72e12f82c37a9e70ab40c7e89e89145ad37f29914e4702db4bf024a5501 docs/CHANGELOG_TECHNICAL_ARCHIVE.md c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md 833ddb12f23dcac796223521388b19c6ec3ea04c5550cf1c5f4ea4eb7c58f3a0 docs/CUSTOM_LOCATIONS.md -16d8f59908c66e595d6003495cbf5c068c68b10aa9495eb8612c38b2de0e4a7c docs/DOCKER_DISCOVERY.md +4d70797bda8d227787fcff897951e702e2458fc6c6f5352aa0b5577595bd834b docs/DOCKER_DISCOVERY.md 5324b215075214a5bb9b388bc82d6491eacfc507df95856d1aecedd184e25b80 docs/ERROR_LOGGING.md -74dfbc0d67241d3ec47f17850e69cb8348c10e55e53bb26176b715f36d01883e docs/FILE_LOGS.md +f749f7bd2eb4ae9c484f77a3cd8cd5ff21445319a3938e0384a3414134fe713a docs/FILE_LOGS.md 36599e0b59164e662442f3e8203c16b3123d09b7684d8ecdbffce42d22cc221b docs/HTTP3.md a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md 3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md @@ -63,7 +63,7 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY. 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 7f27d782534455eebce538f10c902346f7d194acd75b6106c7f8dec3c44857e9 docs/SETTINGS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md -cb440d5f2ce8fb77e00e75f4bff9a768044b19bad3188888b6221759975c099f docs/TEST_REPORT.md +e97895472f909bd6dcbb0643c9f5d3b7c3eb229c81b53487807265faef6fff62 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md @@ -95,8 +95,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md -1e746962a3db371948b1f820bcca02ecf6852319868f6e77b421d4da277845f1 langs/en_US.json -a45134738a1cea89f0c18bf352e1ff7e18089769abf49b2a043f1d61d5f50f03 langs/pl_pl.json +4106dd653c2968b4a20b18ce629a976b7e58cf9dcd5d6d9938855ec6ef005efc langs/en_US.json +a2fa657001729c02a073fa962f37d5025be05b206e098e08a3f177453fc2ab0b langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -122,7 +122,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py -c904ae3acee8b4faf56fe64d8bd430542a44fbd923c65bb2bab310bf23def6ab scripts/resolve_dependencies_0312.sh +f4bc093128ab0d8091f9e4e47d61bfaeacbebb0cf6e3b523cf7ddfdf5996aed7 scripts/resolve_dependencies_0312.sh 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py @@ -143,16 +143,16 @@ b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/acce 958546c43c05bf4bed8121b24db3b3096f9243f3c56a71e1762592b755684013 src/admin/documentation/documents.rs 67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs 1ce779b84aa8f47bdb3c34f83593c1f2f06d2235f0319847096cfcd035434168 src/admin/file_log_search.rs -b3aad0db47d930ee177253cb5d5eb772272452bab4385a34566b111c239a6302 src/admin/file_logs.rs +ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs -d5eb4019a4aa6a4fdcad9310dce9076dcb85ca4640540c64e924367ba2276234 src/admin/file_logs_query.rs +c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs 7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs 58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs -8d9db966f3c4f092c29bd17a4c16fbab2cfd3c6bb2c5f0063da3bb3035e91b67 src/admin/openapi.json +6e5c18e8d9db9a52f3f0c3989efc41552251f238a6eaee87a19908495da2eb87 src/admin/openapi.json 0d1a5324e8ad32f5ab0861b722663f50f066e256bc5fd5dcced6a63f2bd399d1 src/admin/operations.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs -7c113cd9d8642a808c73ebee24e695f7a318995e39dc2b81e747d2b2246e277f src/admin/preferences.rs +d8bad220bf42663be3355d0a4b8ea9cede1c425ba6c6e0d961c59cd16ff7f55a src/admin/preferences.rs d9e90166d2199152585a5208feedfc1dd1395dc32ddbd2a094ae4a1aee45e115 src/admin/preferences_columns.rs 055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs @@ -163,21 +163,10 @@ e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/stat e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -684307b74155ac7833acfb5e9e8eaccc92575d49b760928c30fc22e805a42a4c src/admin/tests.rs -18aa617a0bf60a3d8d2ad7dd059a369e7549ef46683d8f912e07f8fb091c26d1 src/admin/tests/part_01.rs -20abde2c1fa72d0712c2962479d66ae0131fb67ed12850c541306fe5ed1d2b84 src/admin/tests/part_02.rs -34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs -2137a53991c9337988545c4472b4ffcb9a111cac9c460ebbec7e51c0be8f5630 src/admin/tests/part_04.rs -334b1409f2c7f934b613c6613a0e0de7d2b65b8282b96255e072431373cd93b4 src/admin/tests/part_05.rs -0274038c2b18f1c9ab0d990c0d636cf6d4255454018463f49984898086f74bdb src/admin/tests/part_06.rs -402a219dbd70d6654bd1afc3d2ede2f5d3add96ba3f9867f7f51fba870efa404 src/admin/tests/part_07.rs -54ed2130e85239ffa4576417dd68fbb9349e396210b75f5da862897137d7e876 src/admin/tests/part_08.rs -225a29e2e560e42e06e19c3d41c0196c4a42e1e84caa7c83446cab62e3ac30dc src/admin/tests/part_09.rs -bffa6611f3776e97d0065b510b63f083aabf7b3429fcc5cb26b1883fdb7c47dd src/admin/tests/part_10.rs -578f93cc5771b5858813945e5608b15c572bd3d7fd15bae291d292e2994ece25 src/admin/tests/part_11.rs +f2d56dfa0e03ca54077124842e66874a510415b95206f91a655eba4430b2c03e src/admin/tests.rs 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js -a42640573b29bf0d13f87b3336d3c86e50cd7f8af2c453836edfda23628fba97 src/admin/web/app/core.js +d8a06511406d6ab974a6bf6cb5da35a1eeb4031144ef462c5390f9d0954fa059 src/admin/web/app/core.js 23721bfbac850c4f77b116cc32bb9d2db6361ac4dd7e310a8c8070ab906997f6 src/admin/web/app/preferences.js 729ed152eb8471c67fb0e55e44fe0669c2708802ce83228df9cb4e53c14cee02 src/admin/web/app/refresh.js 984d092961bfaaa19f982e54c7d4be474e8417b208d814ac88bda63f1ab19f4c src/admin/web/app/runtime.js @@ -185,19 +174,25 @@ db56370db60e514a2241d231cd949bac2c7aa2f413a2c14035a877029a8d864f src/admin/web/ 86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js 4fec1344be3cd540d1e6d0b87fb0aa94e1bef2968c4f86366a62c56d4085f452 src/admin/web/cache-panel.js 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html -40cbf3a19827adbff179009b9189263a58a469319db25cf4adf0e4dc441c6532 src/admin/web/docker.js +d01a2e48f344fe074c1e63ee0a9bbf7151dfe26140690418535197e13a2a16d7 src/admin/web/docker.js d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css aeffd403fa1823df7d9fa4c48280b4033b2d15e4cb9f66608caff89290de0a52 src/admin/web/docs.html 6adb291fcc98d516fd9745d9673db16e408854a32cbf41335ae956bda11d46de src/admin/web/docs.js c52c430b9a6e584012e6014709e1e16ae7e775b2a5aedff8eb767fa867c1cbdd src/admin/web/drafts.js +007c24189ad7b497d4da024fc52e6c51393f91ee135f525a356e1f14882b7632 src/admin/web/error_400.html 232b766f278ae4424705af351910677fcd7ac9a20bb8320d9a12b763c91219a2 src/admin/web/error_401.html 16293363c7848471d866092fec5e83023df087aa824989e87450cee7b7ca1263 src/admin/web/error_403.html f7d0955ab18c15e04369204e789f341d2e832f927c1ff20a3b2b9e35e7898f00 src/admin/web/error_404.html +64126ffcb39dd9bac7772fe737267f8d9391ee6b750d8b84f024f87a6123db3a src/admin/web/error_408.html +4ba01f7a53e9ca914e7b2100c1d439bc56e46be698a9c020dae20159759120ac src/admin/web/error_410.html +f2cb26eec86b6c03151072f847153df8aea612d840ca8b76367f41b9605bce64 src/admin/web/error_413.html 7dc3634e3d508176545936f77db9deb3dc51f0abb71985e1950aa91b81b6f44f src/admin/web/error_429.html +6fdb616c086b2769e3b484133415a6f8e9274a74d03ad95657eec480b3a28b9f src/admin/web/error_500.html +ff72fc4c6a11907a16234c4b3c7d7f7bcf82eb72868dc3fbf654b04aff806fc8 src/admin/web/error_502.html 386b9dc3aecebe7727f7cf7bbd2a02ab036a3abf01315975111a51559128df73 src/admin/web/error_503.html e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/error_504.html 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg -bc9e11724536de89bced1f0a752974068a0229ee14c3488592a27fc58261aaed src/admin/web/file-logs.js +a065af0a331a953dc51c72f0f589023cf8a917cda5baa5308dd0d8c105ec3d91 src/admin/web/file-logs.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js 4740b0fe963604528db10f0beffca5c43f52a8fab923c71f31c4cdfdea446e9a src/admin/web/index.html 03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js @@ -207,8 +202,9 @@ cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/ 136c093a907825e0d942c06186fffc834936b60dd6408bcafe81092cff7e3c64 src/admin/web/management.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js 2936ef4ea7d56edb878758ddcaeed7f55fcfe57044c82eacab7d7126c0ba1b44 src/admin/web/settings.js +8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js -303d3b4d426c521392981c872b3fb685843b8e965ee510ab7a5bab014b21ebc8 src/admin/web/style.css +a616a18259ae03b9161d3e672f24023e6470ed207ac81b0734cff8060322b800 src/admin/web/style.css d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js 00f59a6f656e44c364ed3c8eed33ce1df878a488908b30a9523a87b64f50f9ef src/admin/web/vhosts/core.js @@ -236,9 +232,7 @@ a1e5d43f5417d71f3974812cb539c0932f206e2290090917f067494f3587fc72 src/balance/le 41c3c020a60c372dbf965bd277c330dd9cf2ab079f6608b472adf72a656f4cb3 src/balance/pool_core.rs 477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs 519cb798040ac9a65d9ebe43ae11ef729bd78c5c4a5c756490458add4b6fdc24 src/balance/pool_view.rs -7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/balance/tests.rs -81141aedaec495183b88a4f6362a71f56ee14cbfa83d71fce51d8abed8df3f5d src/balance/tests/part_01.rs -490b38a8b1472dbd9fcb823ec0ed0e92498fb9528b982bcee340c8579e89ae36 src/balance/tests/part_02.rs +656a6ab3c9855f92b7e3e0dd6de1bcc408da003db8bcfcda52b71c5234c56693 src/balance/tests.rs 5049d2bd01a9c866f3bad2288d8d594df96bba6045dfe15cd22b4173562b7388 src/basic_auth.rs 9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs 7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs @@ -248,7 +242,7 @@ e022003be144598100389cf38d76f509b5dbcc035d5bae556bb9b94cc8bd30ba src/cache/cold ea319b6e200b0c6ca93da5e2af7a3f86e3667cef918716f54f82b8012111b9bd src/cache/cold/worker.rs d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs 27a35689fef639f1aea00d9f5a86adc981c0c304dc3e28e6da40494dffbb6bfa src/cache/dashboard.rs -bbd6de8568fea79cc4a11d33b905af75713e609c8f37ec943c82f0d180e66dcc src/cache/fill.rs +780c2654c67dbe176d8310678e6deec8022bcebfd85b95df7262069ed41f57b1 src/cache/fill.rs 675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs 0cc31d8e70fb3774c4ce3713b94ed79c184a8d8f0d38ce66a97724410733e17e src/cache/invalidation/invalidate.rs @@ -256,7 +250,7 @@ b74945c7460b76ed7afcb02a45ede09d76f08e0703c47920b9b9d794d4b74980 src/cache/life bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs 88f6210c03d7a8e7a35be1f123c9c15675af6e6a599c50db927d99866851b634 src/cache/lookup/hot.rs 1779abcee77600c13050a2882f896a5b35625b1ae53737c2554179492ce0e40d src/cache/lookup/insert.rs -f3e026a3c0e953dca3e5c9def43d864a7706afcd7cbeeaf0f21c059a335b30cc src/cache/lookup/tiered.rs +55a39fbcb8c034a98eaad44d9efc092902cd591ef167b8d8e14a56fd1a9f1794 src/cache/lookup/tiered.rs 58c03fde09e5eab40968679793b364f4f1cf5fd832bd79ebcdc92bb23871ad82 src/cache/mod.rs 0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs ba94226addfd6cdab67d666dc15b8412c4d117111e44639e503e89a95175d0a2 src/cache/model/cache.rs @@ -271,20 +265,12 @@ e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/poli d426d341a7678f8ec70406d86052c97d0f49804bbcd8402951103d61a735b9da src/cache/policy/response.rs 7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs 7ad96b3baae36ec49c90d03b380e749944fb3d54abb045c90db3a35f6b89b1c1 src/cache/tests.rs -13dab7b7a587cfc146826a92ab94ec9e04f7e500b7a84e678d77226829844923 src/cache/tests/cold_tier_tests.rs -9cd6a8407888c8f9cbf7652774186d1ca588d2d78308750e8aa1316b4e3e5cb9 src/cache/tests/cold_tier_tests/part_01.rs -f43aa6c231f2c85ee3390dddab74aab2eea2a3f874acb35f145c23866266cb52 src/cache/tests/cold_tier_tests/part_02.rs +981c33d8321c0d5482a00dc3cf0911e95fa69a29c68a2f5b6cd4cff27cec1cbd src/cache/tests/cold_tier_tests.rs 22cb9bf20a0d25bee30c1e2213dfe8278ad7d0d5d294275d47d4a6ff9ac646ab src/cache/tests/dashboard_tests.rs d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/tests/http_invalidation_tests.rs 1db70ea5f3cc1f1543504a68498ba23cafce5f9bb48c4616a4bf70d9a536790e src/cache/tests/scope_tests.rs -578b18cac918ba51fad775eca188f645b228500e59eb1790f783a0b8d0245b7b src/cache/tests/tests.rs -c33013089bf9e75a07a5b43f1030acaba8aba4098796e393ed4bad784f4fff96 src/cache/tests/tests/part_01.rs -2f579a67e73817907525651f32c65db9f4175d55c9ea34773e988a7764a7fc8b src/cache/tests/tests/part_02.rs -f4c56db79cf939a58c25253ce9abb18faeb90fe1454bfafec7c1f2e3385f78ce src/cache/tests/tests/part_03.rs -cdc41a7241739b89297494323f29e0230fa06abbd74e219fedfda11df1db4334 src/cache/tests/tests/part_04.rs -541a62b749440aa1099cf0fc8a2bd31eac0c082ccef35df22c1e049e7928e216 src/cache/tests/v022_cache_tests.rs -15e85357c3ddf4fc2283d621f5f4cd8ca309275e06677020299d4105f0475949 src/cache/tests/v022_cache_tests/part_01.rs -f262c4e946421b06198ab73ff6e36e6826848f53ce8396eada666c64935c2e70 src/cache/tests/v022_cache_tests/part_02.rs +9aa6bfdb88b5ea4ef0ecd72e8e3c6e82b5ab9b37051ad300b6a5dc12f8579ddd src/cache/tests/tests.rs +444d9a364339248bdb86f2285de6d7e598b880502e25a70be0ee877ff3e52ea7 src/cache/tests/v022_cache_tests.rs a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tickets.rs 28f904cf4827294f2f759e6d3624608ed90059b64e42864e55dc9213824eb655 src/compression.rs e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs @@ -295,10 +281,7 @@ a3d6a9d01dd9b3b602ea2f3dd5559503ea82129544c8944c26ad39445aaa3ad3 src/config/com d5b851aa5823af638fd2b2ba8ef4eeba12c55296c0a01f8ca7dad0674d124acc src/config/model.rs f66c2388f31b19142c12469df23d43451e0f7c48b158d4996920282c06d32076 src/config/routing.rs 818545ad36af3ae8d56976f318349dd1f5264ef5665fbf94681a81b79003f8cb src/config/snapshot.rs -b9f56cc156deaf1a59daf4da96233bba12a31a4fde3f6fe6cc08909fb6c1823e src/config/tests.rs -f712db55af9ac463bc905558d29d068a58ac75269075055724ad187d7ea06fa9 src/config/tests/part_01.rs -bb754925f525374eb968a6c326672f6d565f7346a055b4cd8e7f0d546666648d src/config/tests/part_02.rs -bc5816805b6e70244c339b4e680b447b3d00d545b59923e242629dc3805907e0 src/config/tests/part_03.rs +b20dea6e67919379ac56f64c852e05082974300c183e7b5884e70db68f89ce5f src/config/tests.rs c6db84438878accb8a0c96be13fa01a83da824af39c929dc3f6df498f393b7e0 src/db.rs 7fadb86f6a61dd0127dd68c115be36865f0b2af535510f4c8f9aff6c9b8cdbfe src/db/certificate_api.rs 2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs @@ -312,10 +295,10 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs 2a02ea2e010cde1b1fe99b2e6baa80ea344ea6b332dbb3c4be795b71541f64a7 src/forwarding.rs 057e652f216a1478638cbb53b1c64ac88c7f413a65825caacbf893c5af004a63 src/forwarding/tests.rs -6823040f72c8de387050a17cdca1acbba39b27858b150c1df71644ec11db3070 src/geoip.rs +af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs 80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs 06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs -3e673d15ee8640b272026a0618323f666dc79e1ca7fb15552928e913c8b69ec5 src/geoip/tests.rs +d3dc58628683472ef4d205fa5c42f0b7ef83cf90846146d96941cd3b36a40766 src/geoip/tests.rs 70091bdf6f38f03b6df8c8c4407c300677e57f293130122503f936500963d37c src/geoip/update.rs c1b49bef28d87712e4c46138e70b52c4bcfb1a00196579031fac043d0b4aaa7b src/geoip/update_http.rs 9a912dad0b8d1e10410fd95d0da3684656092ef85cf8b23eda4ffe7fb92921d8 src/geoip/update_tests.rs @@ -347,8 +330,8 @@ e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks a54b5b34ea65f29723b89b81bf7968a238e8247b9c9760f3430d3504cb1017c9 src/metrics/vhost/tests.rs a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs 668840e7bd70590e2e13f59b6d5b9e43ca7c877f3d07c71907baea91b5bba6cb src/not_found/tests.rs -202b4b5fe837f6a3f53f350d4c6368ef70ef7070b4094011d64b481717ecea3c src/pages.rs -e70bb6aa6794d30e561ae19d7e2fce34d9adbcc7db05bbdf6f3cf8751e3d2509 src/pages/tests.rs +d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs +da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs 0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs 5ed3d03f9b805413544b932d0c266b0e32b830c783d01900af7988bca6bc48a3 src/prometheus/docker_misc.rs @@ -477,11 +460,7 @@ c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server 9b58d6bb3ff1520060fc5aa86a3594f99192b1a8f8183255be1fb3ff35b4bb35 src/web_server/routing.rs 400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs 518856a221161430cb0ab3e411fa81cab07e1a6e52edd470f026d25c5307943e src/web_server/serve.rs -63d71a336187779e9cbc1cc6afa065c5fa9472835a8c2cc15dbf4527279d0c90 src/web_server/tests.rs -9426ad19fd6234e944d6e122a995d2cf8ebe4177d27e42fd157f64d05abd1e23 src/web_server/tests/part_01.rs -5591a0b994d4392265799f881790d15abd8b6263b5475a84766455d66572bfcf src/web_server/tests/part_02.rs -8f9a0e2b0a69a1f2b8cf7505a654606150da4a53bc8c09d1d6c4b8751185b134 src/web_server/tests/part_03.rs -b25b6e0082b5153272119c7faba9d6f2ccdd288ce5c066526431c56954c66c4c src/web_server/tests/part_04.rs +2283548f7a6a1f5e0e4025f498f14f3402b4f741ae4a9ac61c60e3debf5ca39a src/web_server/tests.rs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json 1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs 347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs @@ -495,32 +474,35 @@ e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/a fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py 412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs 2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs -a1e0e4466d0d0acbdcc9db611a1a4183cdc148bae195dedabb18a2892f4faff5 tools/checks/check_source.py +5afffaa2b7f96130565e11e1405d97787086438ec1a57647d8757fcf5cd1fa15 tools/checks/check_source.py dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs 259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs -03d399d7f75b3f6aa8b6f68fddd0e791ab0071d657b9ba7255988a1d8a812836 tools/checks/dependency_upgrade_0312.py -e7f4519116fcd5edfe3251a4ac10f6e3afcbabcc303f9613fddfb566f7f93e86 tools/checks/docs_check.py +c75a529827c2bc3e39bfe0bd0e7e9f6e8a6f4f08982edadc05e1a10494f33ace tools/checks/dependency_upgrade_0312.py +6c1ea212b9a60215739a5f2cbeb75487aaf51bbd90cc58714ce287ed5bc21267 tools/checks/docker_startup_0318_ui_test.mjs +9037865daab1bf206ff0f06f6a98bb385691c1b7e87293b6d2f4bd4fc014454f tools/checks/docs_check.py 6ca66c0998d1da852ad66976c91e4d7d3628195aa49ddb44cea95f531eb50033 tools/checks/file_logs_tabs_0315_ui_test.mjs 83c74f0027a6d2f51f637d552593f9904e60ccd69665f6460136c48b159afb60 tools/checks/h3_cache_mobile_0315_test.mjs -157c3d630fe0846dda55c57d9e0640cbff2831988ccee8757854774b05e8b8e7 tools/checks/h3_navigation_0316.py +c3128f6b2e668c92b3026bddd4590d8df01f555f011d0dcfe19d46829b6e8753 tools/checks/h3_navigation_0316.py e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py -f8f87ba891338b565249d02c3cf5f02cec44e606b4205de99fcbea1eb2c24d57 tools/checks/memory_contracts.py +a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs 1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs -0e8375cf190440091362c62a1e11865504b281fb03b8dce74e522f171c033033 tools/checks/preflight.py +8c6bf085d39da0a27aa4ddab7322aa19e19966dd1e18732e04f54d676d6109e4 tools/checks/preflight.py 021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs -17b28a819bc8cc175fa1fb45b9aa14fa1dc8426b28f65969e7fb786dea8c2306 tools/checks/release_smoke.py -f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py +683e70df42c5c9577c0db5c7c4e94b25ee4654265525c3d4d9d1e471a914e5df tools/checks/release_0318.py +1d2f2f3adaaa1f8576fcf97126335c4dcfbf6f4c30dede5f7e0c187241e9ea0e tools/checks/release_0318_ui_test.mjs +5825b872b7000daae2c345bb95d755d3beaca5fc3dada07595cff73d447c7dc7 tools/checks/release_smoke.py +31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py 23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py -ff69e1381a759e07effdc25f8401ba92730be6116fe15c7470539e50ddf4f3e5 tools/checks/security_logs_0315_ui_test.mjs +325ff424200a908395a2f77c87bda3cbc4ed57509c4927af590911b3ce639ac3 tools/checks/security_logs_0315_ui_test.mjs 70e5632f36e2387be8638c33f371aacca57bf264ef67846a6e0bd7f2dd19a5e0 tools/checks/source_module_contracts.py 37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py -e4b6f095ade55881d1d7fe0b8b821e9c5c7312e13ee062db44804dd2b6f727f9 tools/checks/system_info_0301_ui_test.mjs +3e86bbe244fad73adc653918f2386815bdddbfc8084a5a19ea17f271681e92eb tools/checks/system_info_0301_ui_test.mjs 543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py 161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs diff --git a/dev.sh b/dev.sh index 5e2a125..7dc3ff3 100755 --- a/dev.sh +++ b/dev.sh @@ -92,6 +92,7 @@ if [[ "$mode" == "test" ]]; then python3 tools/checks/rust_module_contracts.py python3 tools/checks/h3_navigation_0316.py python3 tools/checks/release_0317.py + python3 tools/checks/release_0318.py python3 tools/checks/source_pruning_0317.py python3 tools/checks/system_info_no_subprocess_contracts.py python3 tools/checks/source_module_contracts.py @@ -119,6 +120,8 @@ if [[ "$mode" == "test" ]]; then node tools/checks/admin_panel_config_0314_ui_test.mjs node tools/checks/security_logs_0315_ui_test.mjs node tools/checks/release_0317_ui_test.mjs + node tools/checks/release_0318_ui_test.mjs + node tools/checks/docker_startup_0318_ui_test.mjs node tools/checks/h3_cache_mobile_0315_test.mjs node tools/checks/panel_cache_http_0310_ui_test.mjs node tools/checks/vhost_origin_errors_0297_ui_test.mjs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9018fd5..0f3e342 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,9 +41,10 @@ committed document at startup; this is not one cross-memory/SQLite transaction. ### Rust source organization -Rust modules are kept as small responsibility-oriented files. Every checked-in `.rs` file -is limited to **500 lines**; `tools/checks/rust_module_contracts.py` enforces the limit across -the repository. Large logical modules use a thin facade (`config.rs`, `docker.rs`, +Production Rust modules are kept as small responsibility-oriented files, with a +**500-line limit** enforced by `tools/checks/rust_module_contracts.py`. Unit and +integration test files are explicitly exempt: related tests may live together in +large `tests.rs` files rather than arbitrary numbered fragments. Large logical modules use a thin facade (`config.rs`, `docker.rs`, `server.rs`, `runtime.rs`, `balance.rs`, `prometheus.rs`, and similar) plus files in a matching directory. `web_server` uses the standard `web_server/mod.rs` facade with submodules for configuration, filesystem confinement, responses, routing, FastCGI and diff --git a/docs/DOCKER_DISCOVERY.md b/docs/DOCKER_DISCOVERY.md index 17b5872..b2ff103 100644 --- a/docs/DOCKER_DISCOVERY.md +++ b/docs/DOCKER_DISCOVERY.md @@ -13,6 +13,8 @@ RustProxy 0.22.3 includes an optional standalone Docker discovery agent. The Rus The agent returns only running containers with Docker-published TCP ports. Bindings limited to loopback (`127.0.0.0/8` or `::1`) are excluded. Multiple bindings for the same container port are retained instead of being collapsed to one address/port pair. +In the dashboard, an enabled Docker agent is **WAITING** until its first background observation arrives after RustProxy starts. This is not a connection failure. Opening **Docker vhosts** fetches the current *cached* status immediately, even if the per-page **Live view** option is off. While no enabled agent has been observed, the page briefly checks cached status every five seconds (when global automatic refresh is enabled), and stops once all agents have a first result. A genuine failed poll is reported with the returned error, and the configured TLS certificate pin is marked as verified only after a successful observation. **Refresh agents** explicitly triggers an immediate agent poll. No TLS pin, token or routing verification is bypassed. + ## Install an agent on a Docker host The agent is written in Go and uses only the standard library. diff --git a/docs/FILE_LOGS.md b/docs/FILE_LOGS.md index 692967a..0d045e0 100644 --- a/docs/FILE_LOGS.md +++ b/docs/FILE_LOGS.md @@ -45,6 +45,12 @@ At least one column must remain visible. Stored-log column selection is saved pe Selecting a row highlights it and expands parsed field/value pairs directly below the row. A nested **Raw JSON** section shows the normalized complete record. +## Sorting columns (0.31.8) + +Click a Stored Logs column header (desktop/table layout) to sort by that column; click again to reverse direction. The selected field and direction are saved with the administrator's column preferences. Numeric fields sort numerically, text case-insensitively, and absent values come last. Both standard columns (`time`, `record`, `source`) and discovered nested JSON fields are supported. + +Sorting takes place **on the server across the entire bounded matching scan, before page offset/limit is applied**. Thus page two continues the same sorted result instead of sorting only the current 100 displayed rows. Server bounds and truncation notices still apply; this is not an unbounded full-log historical index. The default remains newest first. The mobile card layout retains the selected sort order, set via the desktop table view. + ## Narrow-screen navigation (0.31.5) At viewport widths up to 700px, `/logs` uses a vertically scrolling list of diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 1eec468..b2c1f81 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,4 +1,17 @@ -# Verification report - RustProxy 0.31.7 +# Verification report - RustProxy 0.31.8 + +## 0.31.8 — implementation pending runtime acceptance + +- **Test-source consolidation:** merge all 28 numbered `part_XX.rs` fragments across seven Rust test suites into existing `tests.rs` files, preserving test bodies and scopes. Update relative `login-keys.json` fixture path, compiler-independent source checks and archive smoke to exempt test-only Rust files from the 500-line production limit. Delete the seven now-empty fragment directories. +- **PASS — retained tests:** all 28 fragments were concatenated in their original order into seven `tests.rs` suites. Exact body comparison (apart from the required `login-keys.json` relative path) confirms all 251 `#[test]`/`#[tokio::test]` markers are retained. All seven obsolete directories are absent; source include references resolve. +- **PASS — Rust-independent 0.31.8 release suite:** `./dev.sh test` passed its pre-Cargo Python/SQL/schema/memory/source, JavaScript DOM/API simulation, documentation and translation checks, including Docker VHosts startup regression. The command exited **127** only at the missing Cargo guard. `GOPROXY=off go test ./...` passed in `docker-agent`. +- **Unverified:** Rust/Cargo compilation, formatting and unit-test execution are not available in this environment; no claim is made about compiled behavior or live runtime acceptance. +- **Docker startup-state follow-up:** fixed the client rendering where a missing first Docker observation incorrectly displayed a connection failure; now shows WAITING until a server-side poll result exists. A visit to Docker VHosts reads GET `/api/docker` regardless of per-page Live view; cached-status retries stop after the first result unless Live view is enabled. A TLS certificate pin is not marked connected without a successful poll; genuine poll errors are still visible. No Docker agent, TLS verification, proxy route, SQLite schema or poll interval changes. +- **Docker follow-up verification:** `node tools/checks/docker_startup_0318_ui_test.mjs` passed with the real dashboard JS in a simulated DOM/API: pending/failure/success transitions, TLS pin indicator, per-page Live-off one-shot/initial status reads, re-entry and disabled-agent handling. No live Docker agent or Rust binary was exercised. +- **Scope:** Stored Logs server-side sorting + UI, built-in status templates, GeoIP packed/boxed ranges, transformed response single-frame cache allocation sharing, and version metadata. +- **Compatibility:** API sorting query parameters and administrator preference fields are optional and default to descending chronological order. No SQLite schema or cold cache format changes. +- **PASS — source/static checks (2026-10-12):** `release_0318.py`, `release_0318_ui_test.mjs`, `check_source.py`, `version_contracts.py`, `web_server_contracts.py`, `admin_surface_audit_0310.py`, `security_dependency_contracts.py`, `docs_check.py`, `langs_check.py`, `dependency_upgrade_0312.py`, `memory_contracts.py` and available focused UI tests; Node syntax check, JSON schema parsing and Bash syntax checks. These are not Rust compiler or runtime tests. +- **Unverified:** `cargo fmt`, `cargo check`, `cargo clippy`, `cargo test`, resolved lockfile verification, H1/H2/H3, browser behavior, and before/after RSS under matched traffic and HOT load on a Rust 1.99+ build host. The source-only checks cannot validate compilation or the zero-copy runtime optimization. ## 0.31.7 — pruned source package correction (2026-10-11) @@ -82,7 +95,7 @@ - **PASS — focused simulated regressions:** `admin_public_origin_0314_config_sim.mjs`: **61/61 local Node HTTP guard-simulation assertions**, comprising 37 cases and 24 repeated session GETs. Invalid forwarded-proto on 3/12 unpinned GETs produced 403; with a pinned HTTPS public URL and **no duplicate DNS allowlist entry**, the same sequence produced 0/12. This simulation is not compiled RustProxy and cannot prove actual mobile/proxy headers. - **PASS — real frontend helper/UI checks:** `admin_panel_config_0314_ui_test.mjs`: **9/9 settings-normalization scenarios** plus form/HTML/locale/OpenAPI consistency; `panel_cache_http_0310_ui_test.mjs` also executes the updated real administration form callback with a duplicated legacy hostname and verifies it is omitted on save. - **PASS — other standalone checks:** all 14 bundled Node UI/simulation scripts, memory model (300,000 simulated PURGEs), 2,000 HTTP requests to a **Python mock peer** (not RustProxy), and `cd docker-agent && go test ./...`. -- **Harness and compiler limitations:** an initial `./dev.sh test` run exposed a legacy UI-test harness that lacked the new form helper; the harness was corrected and passed separately. A subsequent monolithic run exceeded the execution time limit after the memory/load-model stage, so no complete `./dev.sh test` success is claimed. `./dev.sh check` exited **127** because `cargo`/`rustc` are not installed. New Rust tests in `src/settings/tests.rs` and `src/admin/tests/part_11.rs` were **not compiled/executed**. The inherited Cargo.lock still needs resolution on Rust 1.99+; live mobile and real reverse-proxy acceptance remain OPEN. +- **Harness and compiler limitations:** an initial `./dev.sh test` run exposed a legacy UI-test harness that lacked the new form helper; the harness was corrected and passed separately. A subsequent monolithic run exceeded the execution time limit after the memory/load-model stage, so no complete `./dev.sh test` success is claimed. `./dev.sh check` exited **127** because `cargo`/`rustc` are not installed. New Rust tests in `src/settings/tests.rs` and `src/admin/tests.rs` were **not compiled/executed**. The inherited Cargo.lock still needs resolution on Rust 1.99+; live mobile and real reverse-proxy acceptance remain OPEN. ### Earlier 0.31.4 validation baseline (2026-10-11) @@ -92,7 +105,7 @@ - **Node HTTP guard simulation (not compiled RustProxy): PASS, 53/53 assertions**, including 29 individual scenarios and 24 repeated session GETs: without the pin and with intentionally malformed XFP every fourth request, **3/12 GETs returned 403**; with an explicit pin and otherwise identical simulated headers, **0/12 returned 403**. `tools/checks/admin_public_origin_0314_sim.mjs` checks relevant code anchors and operates a local Node HTTP test peer. This does not confirm production headers or backend runtime behavior. - **PASS (2026-10-11):** `./dev.sh test` passed every available Rust-independent stage: source-module boundaries, version/contracts, SQLite schema-9 migrations and upgrade, security source/dependency checks, 300,000 PURGE-model iterations, 2,000 real HTTP requests to a **Python test peer**, all bundled Node/browser UI regressions (including BFCache/resume), 0.31.4 HTTP simulation, documentation and both language packs. Exit **127** at the missing Rust/Cargo toolchain guard only. The simulated peer is **not** a running RustProxy binary. `./dev.sh check` likewise exits 127 due to missing Cargo. - **PASS:** `(cd docker-agent && go test ./...)`, `node --check tools/checks/admin_public_origin_0314_sim.mjs`; source-package size/integrity checks are performed separately. -- **Rust regression tests added, NOT EXECUTED:** `src/settings/tests.rs` and `src/admin/tests/part_11.rs` cover pin validation, restart, strict opt-out, session Secure cookie, CSRF, foreign Origin, cross-site, host spoofing and untrusted peers. +- **Rust regression tests added, NOT EXECUTED:** `src/settings/tests.rs` and `src/admin/tests.rs` cover pin validation, restart, strict opt-out, session Secure cookie, CSRF, foreign Origin, cross-site, host spoofing and untrusted peers. - **Remaining release gate OPEN:** the sandbox has no Rust/Cargo/rustfmt and the inherited lockfile remains unresolved against 0.31.2+ dependencies. Before production use: resolve on Rust 1.99+, format, clippy, test/build, then capture actual mobile Safari/Chrome and reverse-proxy traffic and run the acceptance matrix in [SETTINGS.md](SETTINGS.md). No claim is made that the original field `auth.invalid_origin` was directly observed or eliminated. ## 0.31.3 - invalid-Origin resume and mobile panel recovery (2026-10-11) diff --git a/langs/en_US.json b/langs/en_US.json index 3d9fc84..efef0c8 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -6,6 +6,8 @@ "direction": "ltr" }, "translations": { + "fileLogsSortAscending": "Sort ascending", + "fileLogsSortDescending": "Sort descending", "policyRulesTitle": "Rules & headers", "vhUaRateTitle": "User-Agent rate limit overrides", "vhUaRatePattern": "User-Agent pattern", @@ -1387,7 +1389,7 @@ "dockerError": "ERROR", "dockerWaiting": "WAITING", "dockerAgentDisabled": "This Docker agent is disabled.", - "dockerNoObservation": "No observation yet. Refresh the agents.", + "dockerNoObservation": "Waiting for the first agent check. RustProxy will update this after background discovery.", "dockerHost": "Docker host", "dockerDetectedIp": "Detected IP", "dockerEffectiveIp": "Routing IP", @@ -1413,7 +1415,8 @@ "dockerTlsPlain": "PLAIN HTTP", "dockerTlsPlainHelp": "The agent token and inventory are not protected by TLS on this endpoint.", "dockerTlsPublicCaHelp": "Certificate chain and hostname are verified against the system/public CA roots.", - "dockerAgentConnectionFailed": "Agent connection failed", + "dockerAgentConnectionFailed": "Last Docker agent check failed", + "dockerUnknownPollError": "The most recent agent check failed.", "dockerAgentVersion": "Agent version", "dockerLastChecked": "Last checked", "dockerNeverChecked": "never", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 9b527f5..4f473bf 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -6,6 +6,8 @@ "direction": "ltr" }, "translations": { + "fileLogsSortAscending": "Sortuj rosnąco", + "fileLogsSortDescending": "Sortuj malejąco", "policyRulesTitle": "Reguły i nagłówki", "vhUaRateTitle": "Limity dla User-Agent", "vhUaRatePattern": "Wzorzec User-Agent", @@ -1387,7 +1389,7 @@ "dockerError": "BŁĄD", "dockerWaiting": "OCZEKIWANIE", "dockerAgentDisabled": "Ten agent Dockera jest wyłączony.", - "dockerNoObservation": "Brak odczytu. Odśwież agentów.", + "dockerNoObservation": "Oczekiwanie na pierwszy odczyt agenta. RustProxy zaktualizuje status po sprawdzeniu w tle.", "dockerHost": "Host Dockera", "dockerDetectedIp": "Wykryte IP", "dockerEffectiveIp": "IP routingu", @@ -1413,7 +1415,8 @@ "dockerTlsPlain": "ZWYKŁY HTTP", "dockerTlsPlainHelp": "Token agenta i dane inventory nie są chronione przez TLS na tym endpointcie.", "dockerTlsPublicCaHelp": "Łańcuch certyfikatu i nazwa hosta są weryfikowane względem publicznych CA.", - "dockerAgentConnectionFailed": "Nie udało się połączyć z agentem", + "dockerAgentConnectionFailed": "Ostatni odczyt agenta nie powiódł się", + "dockerUnknownPollError": "Ostatni odczyt agenta nie powiódł się.", "dockerAgentVersion": "Wersja agenta", "dockerLastChecked": "Ostatnie sprawdzenie", "dockerNeverChecked": "nigdy", diff --git a/scripts/resolve_dependencies_0312.sh b/scripts/resolve_dependencies_0312.sh index 2c4e92b..e9028a6 100755 --- a/scripts/resolve_dependencies_0312.sh +++ b/scripts/resolve_dependencies_0312.sh @@ -8,8 +8,8 @@ if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; the exit 127 fi python3 tools/checks/preflight.py -if [[ "$(python3 scripts/version.py)" != '0.31.7' ]]; then - printf 'Expected RustProxy 0.31.7 sources.\n' >&2 +if [[ "$(python3 scripts/version.py)" != '0.31.8' ]]; then + printf 'Expected RustProxy 0.31.8 sources.\n' >&2 exit 1 fi printf 'Toolchain: '; rustc --version diff --git a/src/admin/file_logs.rs b/src/admin/file_logs.rs index aa8072c..da50067 100644 --- a/src/admin/file_logs.rs +++ b/src/admin/file_logs.rs @@ -35,6 +35,8 @@ struct FileLogQuery { aggregate_field: Option, aggregate_limit: Option, aggregate_min_count: Option, + sort_by: Option, + sort_descending: Option, offset: Option, limit: Option, } diff --git a/src/admin/file_logs_query.rs b/src/admin/file_logs_query.rs index 69753fa..7e7cd3d 100644 --- a/src/admin/file_logs_query.rs +++ b/src/admin/file_logs_query.rs @@ -50,6 +50,91 @@ fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchCl true } +// Order the entire bounded matching scan before applying offset/limit. Sorting +// only the current browser page would give misleading navigation results. +#[derive(Debug)] +enum FileLogSortKey { + Number(serde_json::Number), + Text(String), +} + +fn stored_log_summary(value: &Value) -> String { + let field = |name| value.get(name).and_then(Value::as_str).filter(|text| !text.is_empty()); + if let Some(reason) = field("reason").or_else(|| field("protocol_error")) { + return reason.to_owned(); + } + if field("method").is_some() || field("path").is_some() { + return format!("{} {}{}", field("method").unwrap_or(""), + field("host").or_else(|| field("authority")).unwrap_or(""), + field("path").unwrap_or("")).trim().to_owned(); + } + for key in ["error", "error_detail", "message", "raw", "outcome"] { + if let Some(text) = field(key) { return text.to_owned(); } + } + String::new() +} + +fn stored_log_sort_key(record: &Value, field: &str) -> Option { + if field == "record" { + return Some(FileLogSortKey::Text(stored_log_summary(record).to_lowercase())); + } + // Prefer millisecond timestamps for numeric chronological sorting. RAW + // file timestamps use the existing ISO/text fallback. + let value = if field == "time" { + record.get("time_ms").filter(|value| value.is_number()) + .or_else(|| record.get("timestamp")) + } else { + file_log_field(record, field) + }?; + match value { + Value::Null => None, + Value::Number(number) => Some(FileLogSortKey::Number(number.clone())), + Value::String(text) if !text.is_empty() => Some(FileLogSortKey::Text(text.to_lowercase())), + Value::Bool(boolean) => Some(FileLogSortKey::Text(boolean.to_string())), + Value::Array(_) | Value::Object(_) => Some(FileLogSortKey::Text(value.to_string().to_lowercase())), + _ => None, + } +} + +fn file_log_timestamp_cmp_desc(left: &Value, right: &Value) -> std::cmp::Ordering { + match (left.get("time_ms").and_then(Value::as_u64), right.get("time_ms").and_then(Value::as_u64)) { + (Some(a), Some(b)) => b.cmp(&a), + _ => right.get("timestamp").and_then(Value::as_str).unwrap_or("") + .cmp(left.get("timestamp").and_then(Value::as_str).unwrap_or("")), + } +} + +fn sort_file_log_records(records: &mut Vec, field: &str, descending: bool) { + use std::cmp::Ordering; + // Build one key per record; do not repeatedly allocate/parse strings in + // the O(n log n) comparison loop. The scan itself is limited to 20k lines. + let mut keyed = std::mem::take(records).into_iter().enumerate() + .map(|(ordinal, record)| (stored_log_sort_key(&record, field), ordinal, record)) + .collect::>(); + keyed.sort_by(|left, right| { + let order = match (&left.0, &right.0) { + (None, None) => Ordering::Equal, + (None, Some(_)) => return Ordering::Greater, // missing values always last + (Some(_), None) => return Ordering::Less, + (Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) => + match (a.as_i64(), b.as_i64()) { + (Some(left), Some(right)) => left.cmp(&right), + _ => match (a.as_u64(), b.as_u64()) { + (Some(left), Some(right)) => left.cmp(&right), + _ => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(Ordering::Equal), + }, + }, + (Some(FileLogSortKey::Text(a)), Some(FileLogSortKey::Text(b))) => a.cmp(b), + (Some(FileLogSortKey::Number(_)), Some(FileLogSortKey::Text(_))) => Ordering::Less, + (Some(FileLogSortKey::Text(_)), Some(FileLogSortKey::Number(_))) => Ordering::Greater, + }; + (if descending { order.reverse() } else { order }) + .then_with(|| file_log_timestamp_cmp_desc(&left.2, &right.2)) + .then_with(|| left.1.cmp(&right.1)) + }); + records.extend(keyed.into_iter().map(|(_, _, record)| record)); +} + fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> { let text_ok = |value: &Option, max: usize| { value @@ -61,6 +146,7 @@ fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> { || !text_ok(&query.client_ip, 128) || !text_ok(&query.vhost, 256) || query.aggregate_field.as_ref().is_some_and(|field| !valid_raw_field_key(field)) + || query.sort_by.as_ref().is_some_and(|field| !valid_raw_field_key(field)) || query.aggregate_limit.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS) || query.aggregate_min_count.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES) || query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET @@ -171,17 +257,8 @@ async fn file_logs_query( Err(_) => truncated = true, } } - records.sort_by(|a, b| { - let numeric = |value: &Value| value.get("time_ms").and_then(Value::as_u64); - match (numeric(a), numeric(b)) { - (Some(left), Some(right)) => right.cmp(&left), - _ => b - .get("timestamp") - .and_then(Value::as_str) - .unwrap_or("") - .cmp(a.get("timestamp").and_then(Value::as_str).unwrap_or("")), - } - }); + sort_file_log_records(&mut records, query.sort_by.as_deref().unwrap_or("time"), + query.sort_descending.unwrap_or(true)); let mut aggregation = aggregate_counts.into_iter() .filter(|(_, count)| *count >= aggregate_min_count) .map(|(value, count)| json!({"value": value, "count": count})) diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 8a742fb..a61f138 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.31.7", + "version": "0.31.8", "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust." }, "servers": [ @@ -3625,6 +3625,17 @@ "default": 60 } } + }, + "file_log_sort": { + "type": "string", + "maxLength": 128, + "default": "time", + "description": "Account-scoped Stored Logs sort column; a fixed or discovered parsed JSON field, including nested dotted fields." + }, + "file_log_descending": { + "type": "boolean", + "default": true, + "description": "Account-scoped Stored Logs sort direction." } }, "required": [ @@ -11009,6 +11020,27 @@ "maximum": 500, "default": 100 } + }, + { + "name": "sort_by", + "in": "query", + "required": false, + "description": "Sort field applied across the entire bounded matching scan before pagination. Includes time, record summary, source and parsed nested field names.", + "schema": { + "type": "string", + "maxLength": 128, + "default": "time" + } + }, + { + "name": "sort_descending", + "in": "query", + "required": false, + "description": "Descending sort direction; null/missing field values are always last.", + "schema": { + "type": "boolean", + "default": true + } } ], "responses": { diff --git a/src/admin/preferences.rs b/src/admin/preferences.rs index 0f047ed..569f809 100644 --- a/src/admin/preferences.rs +++ b/src/admin/preferences.rs @@ -30,6 +30,8 @@ fn default_admin_dashboard_widgets() -> Vec { .map(|value| (*value).to_owned()) .collect() } +fn default_file_log_sort() -> String { "time".to_owned() } +fn default_file_log_descending() -> bool { true } fn default_file_log_columns() -> Vec { [ "time", @@ -99,6 +101,10 @@ struct AdminPreferences { dashboard_hidden_widgets: Vec, #[serde(default = "default_file_log_columns")] file_log_columns: Vec, + #[serde(default = "default_file_log_sort")] + file_log_sort: String, + #[serde(default = "default_file_log_descending")] + file_log_descending: bool, #[serde(default = "default_backend_table_columns")] backend_table_columns: Vec, #[serde(default = "default_backend_table_sort")] @@ -146,6 +152,8 @@ impl Default for AdminPreferences { dashboard_widgets: default_admin_dashboard_widgets(), dashboard_hidden_widgets: Vec::new(), file_log_columns: default_file_log_columns(), + file_log_sort: default_file_log_sort(), + file_log_descending: default_file_log_descending(), backend_table_columns: default_backend_table_columns(), backend_table_sort: default_backend_table_sort(), backend_table_descending: false, @@ -203,6 +211,12 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE )); } } + if !valid_raw_field_key(&preferences.file_log_sort) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid stored-log sort column preference".into(), + )); + } if !matches!(preferences.theme.as_str(), "dark" | "light") { return Err(ApiError( StatusCode::UNPROCESSABLE_ENTITY, diff --git a/src/admin/tests.rs b/src/admin/tests.rs index 39f26fa..1b153fa 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -1,13 +1,2663 @@ mod tests { - include!("tests/part_01.rs"); - include!("tests/part_02.rs"); - include!("tests/part_03.rs"); - include!("tests/part_04.rs"); - include!("tests/part_05.rs"); - include!("tests/part_06.rs"); - include!("tests/part_07.rs"); - include!("tests/part_08.rs"); - include!("tests/part_09.rs"); - include!("tests/part_10.rs"); - include!("tests/part_11.rs"); + use super::*; + use crate::{ + config::{self, Config, ValidationPolicy}, + db::Store, + }; + use axum::body::Body; + use http::Request; + use tower::ServiceExt; + async fn fixture() -> (Router, Store, tempfile::TempDir, String, String) { + fixture_with_config(Config::default()).await + } + async fn fixture_with_config( + config: Config, + ) -> (Router, Store, tempfile::TempDir, String, String) { + let dir = tempfile::tempdir().unwrap(); + let policy = ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + }; + let store = Store::open(&dir.path().join("db"), &config).await.unwrap(); + store + .initialize_admin("owner", "unused-test-hash", "test") + .await + .unwrap(); + let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) + .await + .unwrap(); + let issued = store + .create_session(1, "browser", None) + .await + .unwrap() + .unwrap(); + let snapshot = config::compile(config, 0, &policy).unwrap(); + let state = App::new( + store.clone(), + snapshot, + policy, + auth, + 10, + Duration::from_secs(30), + Duration::from_secs(30), + Duration::from_secs(300), + ); + ( + router(state), + store, + dir, + issued.secret, + issued.session.csrf, + ) + } + async fn fixture_with_certificate_storage( + config: Config, + ) -> (Router, Store, tempfile::TempDir, String, String) { + let dir = tempfile::tempdir().unwrap(); + let certificate_directory = dir.path().join("certs"); + crate::tls::initialize_default(&certificate_directory).unwrap(); + let policy = ValidationPolicy { + tls_addr: None, + cert_dir: Some(certificate_directory), + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + }; + let store = Store::open(&dir.path().join("db"), &config).await.unwrap(); + store + .initialize_admin("owner", "unused-test-hash", "test") + .await + .unwrap(); + let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) + .await + .unwrap(); + let issued = store + .create_session(1, "browser", None) + .await + .unwrap() + .unwrap(); + let snapshot = config::compile(config, 0, &policy).unwrap(); + let state = App::new( + store.clone(), + snapshot, + policy, + auth, + 10, + Duration::from_secs(30), + Duration::from_secs(30), + Duration::from_secs(300), + ); + ( + router(state), + store, + dir, + issued.secret, + issued.session.csrf, + ) + } + fn request(path: &str) -> http::request::Builder { + Request::builder() + .uri(path) + .header("host", "127.0.0.1:9090") + } + #[test] + fn administration_authority_uses_http2_pseudo_authority_without_legacy_host() { + let request = Request::builder() + .version(Version::HTTP_2) + .uri("https://192.0.2.10:9090/healthz") + .body(Body::empty()) + .unwrap(); + assert_eq!( + admin_request_authority(&request, true).unwrap(), + "192.0.2.10:9090" + ); + + let conflicting = Request::builder() + .version(Version::HTTP_2) + .uri("https://192.0.2.10:9090/healthz") + .header(HOST, "panel.example.test:9090") + .body(Body::empty()) + .unwrap(); + assert!(admin_request_authority(&conflicting, true).is_err()); + + let h1_absolute = Request::builder() + .uri("https://192.0.2.10:9090/healthz") + .header(HOST, "192.0.2.10:9090") + .body(Body::empty()) + .unwrap(); + assert!(admin_request_authority(&h1_absolute, true).is_err()); + } + #[tokio::test] + async fn administration_https_health_accepts_http2_authority_without_host_header() { + let (router, _store, _dir, _secret, _csrf) = fixture().await; + let request = Request::builder() + .version(Version::HTTP_2) + .uri("https://127.0.0.1:9090/healthz") + .extension(Transport { tls: true }) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.oneshot(request).await.unwrap().status(), + StatusCode::OK + ); + } + #[test] + fn tls_admin_accepts_dns_without_allowlist_but_keeps_explicit_lists_strict() { + let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap(); + assert!(admin_host_allowed("127.0.0.1:9090", wildcard, true, &[])); + assert!(admin_host_allowed("192.0.2.10:9090", wildcard, true, &[])); + assert!(admin_host_allowed( + "example.test:9090", + wildcard, + true, + &["example.test".into()] + )); + assert!(admin_host_allowed("example.test:9090", wildcard, true, &[])); + assert!(!admin_host_allowed( + "example.test:9090", + wildcard, + false, + &[] + )); + assert!(!admin_host_allowed("0.0.0.0:9091", wildcard, true, &[])); + assert!(!admin_host_allowed("0.0.0.0:9090", wildcard, true, &[])); + assert!(admin_host_allowed("localhost:9090", wildcard, true, &[])); + + let concrete: std::net::SocketAddr = "192.0.2.10:9090".parse().unwrap(); + assert!(admin_host_allowed("localhost:9090", concrete, true, &[])); + assert!(admin_host_allowed( + "localhost:9090", + concrete, + true, + &["localhost".into()] + )); + assert!(!admin_host_allowed( + "other.test:9090", + concrete, + true, + &["localhost".into()] + )); + } + + #[test] + fn reverse_proxy_dns_allowlist_uses_external_port_and_keeps_direct_port_available() { + let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap(); + let allowed = vec!["panel.example.test".to_owned()]; + assert!(admin_host_allowed( + "panel.example.test", + wildcard, + false, + &allowed + )); + assert!(admin_host_allowed( + "panel.example.test:443", + wildcard, + true, + &allowed + )); + assert!(admin_host_allowed( + "panel.example.test:8443", + wildcard, + true, + &allowed + )); + assert!(admin_host_allowed( + "192.0.2.10:9090", + wildcard, + false, + &allowed + )); + assert!(admin_host_allowed( + "localhost:9090", + wildcard, + false, + &allowed + )); + assert!(!admin_host_allowed( + "192.0.2.10:443", + wildcard, + true, + &allowed + )); + assert!(!admin_host_allowed( + "other.example.test:443", + wildcard, + true, + &allowed + )); + } + + #[test] + fn origin_matching_normalizes_default_ports_without_weakening_host_checks() { + assert!(admin_origin_matches( + "https://panel.example.test", + "https", + "panel.example.test:443" + )); + assert!(admin_origin_matches( + "https://PANEL.EXAMPLE.TEST:443/", + "https", + "panel.example.test" + )); + assert!(!admin_origin_matches( + "https://panel.example.test:8443", + "https", + "panel.example.test" + )); + assert!(!admin_origin_matches( + "http://panel.example.test", + "https", + "panel.example.test" + )); + } + + #[test] + fn admin_origin_diagnostics_redact_non_origin_values() { + let mut headers = HeaderMap::new(); + assert_eq!(diagnostic_admin_origin(&headers), "(absent)"); + headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test")); + assert_eq!(diagnostic_admin_origin(&headers), "https://panel.example.test"); + headers.insert(ORIGIN, HeaderValue::from_static("https://user:password@panel.example.test")); + assert_eq!(diagnostic_admin_origin(&headers), "(invalid)"); + headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test/?token=secret")); + assert_eq!(diagnostic_admin_origin(&headers), "(invalid)"); + headers.insert("x-forwarded-host", HeaderValue::from_static("other.example.test")); + assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", false), "(untrusted)"); + assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", true), "other.example.test"); + } + + #[tokio::test] + async fn unauthorized_browser_origins_remain_rejected_for_session_checks() { + let (router, store, _dir, secret, _) = fixture().await; + for (origin, fetch_site) in [ + (Some("https://attacker.example"), None), + (Some("null"), None), + (None, Some("cross-site")), + ] { + let mut request = Request::builder() + .uri("/api/auth/session") + .header("host", "127.0.0.1:9090") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)); + if let Some(origin) = origin { request = request.header(ORIGIN, origin); } + if let Some(site) = fetch_site { request = request.header("sec-fetch-site", site); } + let response = router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + // Origin on an ordinary same-origin GET may be absent, or may match. + for origin in [None, Some("http://127.0.0.1:9090")] { + let mut request = Request::builder() + .uri("/api/auth/session") + .header("host", "127.0.0.1:9090") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)); + if let Some(origin) = origin { request = request.header(ORIGIN, origin); } + assert_eq!(router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap().status(), StatusCode::OK); + } + store.close().await; + } + + #[tokio::test] + async fn invalid_origin_on_document_is_visible_but_still_forbidden() { + let (router, store, _dir, _secret, _) = fixture().await; + let response = router.oneshot( + Request::builder() + .uri("/overview") + .header("host", "127.0.0.1:9090") + .header("origin", "null") + .header("accept", "text/html,application/xhtml+xml") + .body(Body::empty()) + .unwrap(), + ).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(response.headers().get(http::header::CONTENT_TYPE).unwrap().to_str().unwrap().starts_with("text/html")); + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); + assert!(String::from_utf8_lossy(&body).contains("Could not open the panel")); + store.close().await; + } + + #[tokio::test] + async fn trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie() { + let mut config = Config::default(); + config.administration.allowed_hosts = vec!["panel.example.test".into()]; + let (router, store, _dir, secret, _) = fixture_with_config(config).await; + let response = router + .oneshot( + Request::builder() + .uri("/api/status") + .header("host", "127.0.0.1:9090") + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https") + .header("origin", "https://panel.example.test") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let refreshed = response + .headers() + .get_all(SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|value| value.starts_with(auth::COOKIE_NAME)) + .expect("session cookie refreshed"); + assert!(refreshed.contains("; Secure")); + store.close().await; + } + + #[tokio::test] + async fn https_redirect_router_preserves_authority_path_and_query() { + let request = Request::builder() + .uri("/api/status?view=compact") + .header("host", "panel.example.test:9090") + .body(Body::empty()) + .unwrap(); + let response = https_redirect_router().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); + assert_eq!( + response.headers().get(http::header::LOCATION).unwrap(), + "https://panel.example.test:9090/api/status?view=compact" + ); + assert_eq!( + response.headers().get(http::header::CACHE_CONTROL).unwrap(), + "no-store" + ); + } + + #[tokio::test] + async fn status_exposes_runtime_memory_accounting_and_cold_limits() { + let (router, store, _dir, secret, _) = fixture().await; + let response = router + .oneshot( + request("/api/status") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + assert_eq!(body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024); + assert_eq!(body["transform_resources"]["max_inflight"], 32); + assert_eq!(body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024); + assert_eq!(body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024); + assert_eq!(body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024); + assert_eq!(body["cache"]["cold_limit_entries"], 50_000); + assert_eq!(body["memory_accounting"]["non_cache_rss_includes_transform"], true); + store.close().await; + } + + #[tokio::test] + async fn rejects_legacy_token_rebinding_and_cross_origin() { + let (router, store, _dir, secret, _) = fixture().await; + let old = request("/api/status") + .header("authorization", format!("Bearer {}", "a".repeat(64))) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(old).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + let evil = Request::builder() + .uri("/healthz") + .header("host", "evil.test:9090") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(evil).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let req = request("/api/status") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .header("origin", "http://evil.test") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let req = request("/api/status") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .body(Body::empty()) + .unwrap(); + assert_eq!(router.oneshot(req).await.unwrap().status(), StatusCode::OK); + store.close().await; + } + + #[tokio::test] + async fn login_bundle_is_public_but_cache_mutations_require_session_and_csrf() { + let (router, store, _dir, secret, csrf) = fixture().await; + let public = request("/assets/login-langs.json") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(public).await.unwrap().status(), + StatusCode::OK + ); + let anonymous = request("/api/cache").body(Body::empty()).unwrap(); + assert_eq!( + router.clone().oneshot(anonymous).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + let anonymous = request("/api/traffic").body(Body::empty()).unwrap(); + assert_eq!( + router.clone().oneshot(anonymous).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + let anonymous = request("/api/transform").body(Body::empty()).unwrap(); + assert_eq!( + router.clone().oneshot(anonymous).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for valid in [false, true] { + let req = request("/api/cache/purge") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header( + "x-csrf-token", + if valid { csrf.as_str() } else { "invalid" }, + ) + .body(Body::empty()) + .unwrap(); + let expected = if valid { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }; + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + expected + ); + } + let cleanup = request("/api/cache/cold/cleanup") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", "invalid") + .header("content-type", "application/json") + .body(Body::from(r#"{"oldest":1,"hits_zero":false}"#)) + .unwrap(); + assert_eq!( + router.clone().oneshot(cleanup).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let cleanup_status = request("/api/cache/cold/cleanup/1") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(cleanup_status).await.unwrap().status(), + StatusCode::NOT_FOUND + ); + store.close().await; + } + #[tokio::test] + async fn selective_cache_invalidation_api_manages_bans_and_rejects_wide_rules_without_confirmation() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let mutate = |path: &str, method: &str, body: Body| { + request(path) + .method(method) + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(body) + .unwrap() + }; + + let create = router + .clone() + .oneshot(mutate( + "/api/cache/invalidate", + "POST", + Body::from( + r#"{"operation":"ban","selector":"exact","value":"/asset.js?v=1","host":"example.test","ttl_seconds":60}"#, + ), + )) + .await + .unwrap(); + assert_eq!(create.status(), StatusCode::OK); + let created = json_body(create).await; + let ban_id = created["result"]["ban_id"].as_u64().expect("ban id"); + assert_eq!(created["result"]["status"], "active"); + assert_eq!(created["bans"].as_array().unwrap().len(), 1); + + let list = router + .clone() + .oneshot( + request("/api/cache/bans") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(list.status(), StatusCode::OK); + assert_eq!(json_body(list).await["bans"].as_array().unwrap().len(), 1); + + let wide = router + .clone() + .oneshot(mutate( + "/api/cache/invalidate", + "POST", + Body::from(r#"{"operation":"purge","selector":"regex","value":".*"}"#), + )) + .await + .unwrap(); + assert_eq!(wide.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let remove = router + .clone() + .oneshot(mutate( + &format!("/api/cache/bans/{ban_id}"), + "DELETE", + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(remove.status(), StatusCode::OK); + assert!(json_body(remove).await["bans"].as_array().unwrap().is_empty()); + + let missing = router + .clone() + .oneshot(mutate( + "/api/cache/bans/999999", + "DELETE", + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + store.close().await; + } + + #[tokio::test] + async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let logging = Config::default().logging; + let body = serde_json::to_vec(&json!({ + "expected_revision": 0, + "value": logging, + })) + .unwrap(); + let response = router + .clone() + .oneshot( + request("/api/config/logging") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["revision"], 1); + + let http2 = crate::settings::Http2Connections { + idle_timeout_seconds: 120, + max_connection_lifetime_seconds: 7_200, + drain_timeout_seconds: 15, + }; + let response = router + .clone() + .oneshot( + request("/api/config/http2") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + serde_json::to_vec(&json!({"expected_revision":1,"value":http2})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let updated = json_body(response).await; + assert_eq!(updated["revision"], 2); + assert_eq!(updated["config"]["http2"]["max_connection_lifetime_seconds"], 7_200); + + let stale = router + .clone() + .oneshot( + request("/api/config/logging") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + serde_json::to_vec( + &json!({"expected_revision":0,"value":Config::default().logging}), + ) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale.status(), StatusCode::CONFLICT); + + let unknown = router + .clone() + .oneshot( + request("/api/config/not-a-section") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from(r#"{"expected_revision":2,"value":{}}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + store.close().await; + } + + #[tokio::test] + async fn logging_section_persists_error_log_settings_atomically() { + let (router, store, dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let logging = Config::default().logging; + let mut error_log = Config::default().error_log; + error_log.enabled = true; + error_log.path = "/tmp/rustproxy-error.log".into(); + + let response = router + .clone() + .oneshot( + request("/api/config/logging") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + serde_json::to_vec(&json!({ + "expected_revision": 0, + "value": { + "logging": logging, + "error_log": error_log, + }, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let updated = json_body(response).await; + assert_eq!(updated["revision"], 1); + assert_eq!(updated["config"]["error_log"]["enabled"], true); + assert_eq!(updated["config"]["error_log"]["path"], "/tmp/rustproxy-error.log"); + + let configuration = router + .clone() + .oneshot( + request("/api/config") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let persisted = json_body(configuration).await; + assert_eq!(persisted["config"]["error_log"]["enabled"], true); + assert_eq!(persisted["config"]["error_log"]["path"], "/tmp/rustproxy-error.log"); + drop(router); + store.close().await; + let reopened = Store::open(&dir.path().join("db"), &Config::default()) + .await + .unwrap(); + let persisted = reopened.load().await.unwrap(); + assert_eq!(persisted.revision, 1); + assert!(persisted.config.error_log.enabled); + assert_eq!(persisted.config.error_log.path, "/tmp/rustproxy-error.log"); + reopened.close().await; + } + + #[tokio::test] + async fn error_log_is_not_a_standalone_configuration_section() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let response = router + .oneshot( + request("/api/config/error_log") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from(r#"{"expected_revision":0,"value":{}}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + store.close().await; + } + + #[tokio::test] + async fn default_pages_section_accepts_the_standard_value_payload() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let pages = Config::default().pages; + let response = router + .clone() + .oneshot( + request("/api/config/pages") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + serde_json::to_vec(&json!({ + "expected_revision": 0, + "value": pages, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["revision"], 1); + store.close().await; + } + + #[tokio::test] + async fn panel_subpage_routes_survive_browser_refresh() { + let (router, store, _dir, secret, _) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for path in [ + "/overview", + "/vhosts", + "/web-server", + "/docker-vhosts", + "/redirects", + "/404-hosts", + "/streams", + "/acl-profiles", + "/configuration", + "/tls", + "/traffic", + "/cache", + "/transform", + "/logging", + "/logs", + "/settings", + ] { + let public = router + .clone() + .oneshot(request(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(public.status(), StatusCode::OK, "anonymous {path}"); + let public_body = axum::body::to_bytes(public.into_body(), 100_000) + .await + .unwrap(); + assert!(std::str::from_utf8(&public_body) + .unwrap() + .contains(&format!("/assets/login_{}.js", super::asset_build_id()))); + + let private = router + .clone() + .oneshot( + request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(private.status(), StatusCode::OK, "authenticated {path}"); + let private_body = axum::body::to_bytes(private.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + assert!(std::str::from_utf8(&private_body) + .unwrap() + .contains(&format!("/assets/app_{}.js", super::asset_build_id()))); + } + store.close().await; + } + + #[tokio::test] + async fn csrf_required_and_logout_revokes_cookie() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let req = request("/api/auth/logout") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let req = request("/api/auth/logout") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", csrf) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::OK + ); + let req = request("/api/status") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.oneshot(req).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + store.close().await; + } + #[tokio::test] + async fn upload_and_scoped_queries_keep_control_plane_guards() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for (path, body) in [ + ( + "/api/certificates/upload", + r#"{"cert_pem":"test","key_pem":"test","hosts":["app.localhost"]}"#, + ), + ( + "/api/certificates/deploy", + r#"{"certificate_id":"shared","cert_pem":"test","key_pem":"test"}"#, + ), + ] { + for (authenticated, valid_csrf, expected) in + [(false, false, 401), (true, false, 403), (true, true, 409)] + { + let mut req = request(path) + .method("POST") + .header("content-type", "application/json") + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1"); + if authenticated { + req = req.header("cookie", &cookie); + } + if valid_csrf { + req = req.header("x-csrf-token", &csrf); + } + assert_eq!( + router + .clone() + .oneshot(req.body(Body::from(body)).unwrap()) + .await + .unwrap() + .status() + .as_u16(), + expected + ); + } + } + for (path, expected) in [ + ("/api/logs?vhost=demo&limit=8", 200), + ("/api/logs?cache=BAD", 400), + ("/api/file-logs", 200), + ("/api/file-logs/query?source=all&limit=8", 200), + ("/api/file-logs/query?limit=0", 400), + ("/api/traffic", 200), + ("/api/cache", 200), + ("/api/transform", 200), + ] { + let req = request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status().as_u16(), + expected + ); + } + let req = request("/api/cache/purge?vhost=missing") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.oneshot(req).await.unwrap().status(), + StatusCode::NOT_FOUND + ); + store.close().await; + } + + #[tokio::test] + async fn certificate_reload_keeps_configuration_revision_stable() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let response = router + .clone() + .oneshot( + request("/api/certificates/reload") + .method("POST") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let payload = json_body(response).await; + assert_eq!(payload["reloaded"], true); + assert_eq!(payload["revision"], 0); + + let configuration = router + .oneshot( + request("/api/config") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(json_body(configuration).await["revision"], 0); + store.close().await; + } + #[tokio::test] + async fn manual_certificate_can_be_reloaded_by_id_without_revision_change() { + let mut config = Config::default(); + config.tls.certificates.push(crate::tls::Certificate { + issuer: None, + id: "manual-file".into(), + covered_hosts: Vec::new(), + hosts: Vec::new(), + cert_file: "default.pem".into(), + key_file: "default.key".into(), + }); + let (router, store, _dir, secret, csrf) = fixture_with_certificate_storage(config).await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let response = router + .clone() + .oneshot( + request("/api/certificates/manual-file/reload") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let payload = json_body(response).await; + assert_eq!(payload["certificate_id"], "manual-file"); + assert_eq!(payload["revision"], 0); + assert_eq!(payload["reloaded"], true); + + let api_token = "c".repeat(64); + store + .upsert_certificate_api_credential( + "file-renewer", + &crate::auth::secret_hash(&api_token), + "external file renewal hook", + false, + &["manual-file".to_owned()], + "owner", + ) + .await + .unwrap(); + let external = router + .clone() + .oneshot( + request("/api/certificates/manual-file/reload") + .method("POST") + .header("authorization", format!("Bearer {api_token}")) + .header("x-rustproxy-certificate-key", "file-renewer") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(external.status(), StatusCode::OK); + assert_eq!(json_body(external).await["certificate_id"], "manual-file"); + store.close().await; + } + + #[tokio::test] + async fn certificate_api_tokens_are_scoped_commented_and_individually_revocable() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let token = "a".repeat(64); + let token_two = "b".repeat(64); + let invalid_key = router + .clone() + .oneshot( + request("/api/certificate-api") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(json!({"key_id":"x","token":token,"certificate_ids":["shared-cert"]}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid_key.status(), StatusCode::UNPROCESSABLE_ENTITY); + + for (key_id, value, comment) in [ + ("certbot-main", token.as_str(), "production certbot"), + ("deploy-hook", token_two.as_str(), "secondary deploy hook"), + ] { + let rotated = router + .clone() + .oneshot( + request("/api/certificate-api") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "key_id":key_id, + "token":value, + "comment":comment, + "certificate_ids":[if key_id == "certbot-main" { "shared-cert" } else { "deploy-cert" }], + "allow_deploy": key_id == "deploy-hook" + }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rotated.status(), StatusCode::OK); + let rotated = json_body(rotated).await; + assert_eq!(rotated["configured"], true); + assert_eq!(rotated["credential"]["key_id"], key_id); + assert_eq!(rotated["credential"]["comment"], comment); + assert_eq!(rotated["credential"]["allow_deploy"], key_id == "deploy-hook"); + assert_eq!(rotated["credential"]["certificate_ids"].as_array().unwrap().len(), 1); + assert_eq!(rotated["token"], value); + } + + let status = router + .clone() + .oneshot( + request("/api/certificate-api") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = json_body(status).await; + assert_eq!(status["configured"], true); + assert_eq!(status["credentials"].as_array().unwrap().len(), 2); + assert!(status.get("token").is_none()); + + let external = |path: &'static str, key_id: &'static str, value: String| { + request(path) + .method("POST") + .header("authorization", format!("Bearer {value}")) + .header("x-rustproxy-certificate-key", key_id) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap() + }; + // Certificate API credentials cannot trigger an all-certificate reload. + let response = router + .clone() + .oneshot(external("/api/certificates/reload", "certbot-main", token.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + // The allowlisted ID passes the auth gate and reaches the handler; this + // fixture intentionally has no such configured certificate, so it is 404. + let response = router + .clone() + .oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + // A reload-only token cannot use the deploy endpoint at all. + let response = router + .clone() + .oneshot( + request("/api/certificates/deploy") + .method("POST") + .header("authorization", format!("Bearer {token}")) + .header("x-rustproxy-certificate-key", "certbot-main") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + // A different ID is rejected by the credential grant before the handler. + let response = router + .clone() + .oneshot(external("/api/certificates/deploy-cert/reload", "certbot-main", token.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = router + .clone() + .oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let response = router + .clone() + .oneshot( + request("/api/certificates/deploy") + .method("POST") + .header("authorization", format!("Bearer {token_two}")) + .header("x-rustproxy-certificate-key", "deploy-hook") + .header("content-type", "application/json") + .body(Body::from( + json!({"certificate_id":"shared-cert","cert_pem":"bad","key_pem":"bad"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let missing_key = router + .clone() + .oneshot( + request("/api/certificates/reload") + .method("POST") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing_key.status(), StatusCode::UNAUTHORIZED); + + let forbidden_scope = router + .clone() + .oneshot( + request("/api/config") + .header("authorization", format!("Bearer {token}")) + .header("x-rustproxy-certificate-key", "certbot-main") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(forbidden_scope.status(), StatusCode::UNAUTHORIZED); + + let revoked = router + .clone() + .oneshot( + request("/api/certificate-api/certbot-main") + .method("DELETE") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(revoked.status(), StatusCode::OK); + let response = router + .clone() + .oneshot(external("/api/certificates/shared-cert/reload", "certbot-main", token.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let response = router + .clone() + .oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let revoked_all = router + .clone() + .oneshot( + request("/api/certificate-api") + .method("DELETE") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(revoked_all.status(), StatusCode::OK); + let response = router + .oneshot(external("/api/certificates/deploy-cert/reload", "deploy-hook", token_two)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + store.close().await; + } + + #[tokio::test] + async fn admin_preferences_are_account_scoped_and_csrf_guarded() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + + let response = router + .clone() + .oneshot( + request("/api/preferences") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let initial = json_body(response).await; + assert_eq!(initial["live_log_columns"].as_array().unwrap().len(), LIVE_LOG_COLUMNS.len()); + assert_eq!(initial["refresh"], json!({ + "live_enabled":true, "overview_seconds":2, "traffic_seconds":5, + "cache_seconds":5, "system_info_seconds":60 + })); + assert_eq!(initial["theme"], "dark"); + assert_eq!(initial["language"], "auto"); + assert_eq!(initial["ui_scale"], 100); + assert_eq!(initial["time_format"], "24h"); + assert_eq!(initial["chart_style"], "default"); + assert_eq!(initial["button_style"], "normal"); + assert_eq!( + initial["file_log_columns"].as_array().unwrap().len(), + default_file_log_columns().len() + ); + assert_eq!( + initial["backend_table_columns"].as_array().unwrap().len(), + BACKEND_TABLE_COLUMNS.len() + ); + assert_eq!(initial["backend_table_sort"], "vhost"); + assert_eq!(initial["backend_table_descending"], false); + + assert!(store + .save_admin_preferences("owner", r#"{"live_log_columns":["time","status"]}"#) + .await + .unwrap()); + let response = router + .clone() + .oneshot( + request("/api/preferences") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let legacy = json_body(response).await; + assert_eq!(legacy["live_log_columns"], json!(["time", "status"])); + assert_eq!(legacy["refresh"], initial["refresh"]); + assert_eq!(legacy["theme"], "dark"); + assert_eq!(legacy["language"], "auto"); + assert_eq!(legacy["ui_scale"], 100); + assert_eq!(legacy["time_format"], "24h"); + assert_eq!(legacy["chart_style"], "default"); + assert_eq!(legacy["button_style"], "normal"); + assert_eq!(legacy["file_log_columns"], json!(default_file_log_columns())); + assert_eq!( + legacy["backend_table_columns"].as_array().unwrap().len(), + BACKEND_TABLE_COLUMNS.len() + ); + assert_eq!(legacy["backend_table_sort"], "vhost"); + assert_eq!(legacy["backend_table_descending"], false); + + let selected = json!({ + "refresh":{"live_enabled":false,"overview_seconds":10,"traffic_seconds":0,"cache_seconds":3,"system_info_seconds":300}, + "live_log_columns":["time","request","status","upstream_error"], + "theme":"light", + "language":"pl_pl", + "ui_scale":70, + "time_format":"12h", + "chart_style":"neon", + "button_style":"compact", + "table_density":"ultra", + "dashboard_density":"ultra", + "dashboard_widgets":["errors","requests","active","bandwidth","cache","memory","websocket","http2","http3","tls","http_protocol","websocket_protocol"], + "dashboard_hidden_widgets":["http3","tls"], + "file_log_columns":["time","event","client_ip","record"], + "backend_table_columns":["vhost","backend","status","requests","latency"], + "backend_table_sort":"requests", + "backend_table_descending":true + }); + let response = router + .clone() + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(selected.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await, selected); + + let saved: Value = + serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap()) + .unwrap(); + assert_eq!(saved, selected); + // Preferences must also survive a subsequent authenticated read. + let persisted = router.clone().oneshot(request("/api/preferences") + .header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(json_body(persisted).await, selected); + // Wrong allowlist value is rejected even if the rest is valid. + let mut invalid = selected.clone(); + invalid["refresh"]["traffic_seconds"] = json!(4); + let rejected = router.clone().oneshot(request("/api/preferences") + .method("PUT").header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1").header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(invalid.to_string())).unwrap()).await.unwrap(); + assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let response = router + .clone() + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("content-type", "application/json") + .body(Body::from(selected.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = router + .clone() + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(r#"{"live_log_columns":[]}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let response = router + .clone() + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from( + r#"{"live_log_columns":["time"],"theme":"sepia"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let response = router + .clone() + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from( + r#"{"live_log_columns":["time"],"ui_scale":150}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + + let response = router + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from( + r#"{"live_log_columns":["time"],"backend_table_columns":["unknown"]}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + store.close().await; + } + + #[tokio::test] + async fn acme_and_cache_inspection_require_existing_admin_guards() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for path in ["/api/acme", "/api/cache?offset=0&limit=2"] { + let req = request(path).body(Body::empty()).unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + let req = request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::OK + ); + } + for (authenticated, valid_csrf, expected) in + [(false, false, 401), (true, false, 403), (true, true, 409)] + { + let mut req = request("/api/acme/issue") + .method("POST") + .header("content-type", "application/json") + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1"); + if authenticated { + req = req.header("cookie", &cookie); + } + if valid_csrf { + req = req.header("x-csrf-token", &csrf); + } + // This fixture has no certificate directory: valid auth reaches the + // 409 precondition, never a child process, DNS update or CA request. + let response = router + .clone() + .oneshot(req.body(Body::from("{}")).unwrap()) + .await + .unwrap(); + assert_eq!(response.status().as_u16(), expected); + } + let req = request("/api/cache?vhost=missing") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.oneshot(req).await.unwrap().status(), + StatusCode::NOT_FOUND + ); + store.close().await; + } + + async fn json_body(response: Response) -> Value { + let bytes = axum::body::to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + #[tokio::test] + async fn external_certificate_deploy_creates_updates_and_noops() { + let (router, store, dir, secret, csrf) = + fixture_with_certificate_storage(Config::default()).await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let first = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap(); + let first_body = json!({ + "certificate_id": "opnsense-cert", + "cert_pem": first.cert.pem(), + "key_pem": first.signing_key.serialize_pem(), + }) + .to_string(); + + let deploy = |body: String| { + request("/api/certificates/deploy") + .method("POST") + .header("content-type", "application/json") + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("cookie", &cookie) + .header("x-csrf-token", &csrf) + .body(Body::from(body)) + .unwrap() + }; + + let response = router.clone().oneshot(deploy(first_body.clone())).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let created = json_body(response).await; + assert_eq!(created["operation"], "created"); + assert_eq!(created["created"], true); + assert_eq!(created["changed"], true); + let first_revision = created["revision"].as_u64().unwrap(); + let first_cert_file = created["cert_file"].as_str().unwrap().to_owned(); + let first_key_file = created["key_file"].as_str().unwrap().to_owned(); + assert!(dir.path().join("certs").join(&first_cert_file).is_file()); + assert!(dir.path().join("certs").join(&first_key_file).is_file()); + + let response = router.clone().oneshot(deploy(first_body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let unchanged = json_body(response).await; + assert_eq!(unchanged["operation"], "unchanged"); + assert_eq!(unchanged["created"], false); + assert_eq!(unchanged["changed"], false); + assert_eq!(unchanged["revision"].as_u64(), Some(first_revision)); + assert_eq!(unchanged["cert_file"].as_str(), Some(first_cert_file.as_str())); + assert_eq!(unchanged["key_file"].as_str(), Some(first_key_file.as_str())); + + let second = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap(); + let second_body = json!({ + "certificate_id": "opnsense-cert", + "cert_pem": second.cert.pem(), + "key_pem": second.signing_key.serialize_pem(), + }) + .to_string(); + let response = router.oneshot(deploy(second_body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let updated = json_body(response).await; + assert_eq!(updated["operation"], "updated"); + assert_eq!(updated["created"], false); + assert_eq!(updated["changed"], true); + assert!(updated["revision"].as_u64().unwrap() > first_revision); + assert_ne!(updated["cert_file"].as_str(), Some(first_cert_file.as_str())); + assert_ne!(updated["key_file"].as_str(), Some(first_key_file.as_str())); + assert!(!dir.path().join("certs").join(&first_cert_file).exists()); + assert!(!dir.path().join("certs").join(&first_key_file).exists()); + store.close().await; + } + #[tokio::test] + async fn anonymous_operational_paths_fail_closed_before_extractors() { + let (router, store, _dir, _, _) = fixture().await; + for path in [ + "/api/status", + "/api/system-info", + "/api/config", + "/api/config/history", + "/api/backends", + "/api/docker", + "/api/traffic", + "/api/cache?limit=invalid", + "/api/logs?limit=invalid", + "/api/live-logs", + "/api/file-logs", + "/api/file-logs/query?limit=invalid", + "/api/certificate-api", + "/api/preferences", + "/api/audit", + "/api/events", + "/api/certificates", + "/api/acme", + "/api/auth/session", + "/api/metrics", + "/metrics", + "/docs/openapi.json", + "/docs/content.json", + "/docs/init.js", + "/docs/style.css", + "/docs/swagger-ui-bundle.js", + "/docs/swagger-ui.css", + "/docs/licenses.txt", + "/assets/langs.json", + "/assets/app.js", + "/assets/management.js", + "/assets/drafts.js", + "/assets/integrations.js", + "/assets/traffic-panel.js", + "/assets/cache-panel.js", + "/assets/transform-panel.js", + "/assets/vhosts.js", + "/assets/docker.js", + "/assets/streams.js", + "/assets/redirects.js", + "/assets/host404.js", + "/assets/settings.js", + "/assets/file-logs.js", + "/assets/acme.js", + "/api/not-a-route", + "/not-a-route", + ] { + let response = router + .clone() + .oneshot(request(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response + .headers() + .contains_key("access-control-allow-origin")); + assert!(!response.headers().contains_key("server")); + assert_eq!( + json_body(response).await, + json!({"error":"auth.required"}), + "{path}" + ); + } + for (path, method) in [ + ("/api/config", "PUT"), + ("/api/config/cache", "PUT"), + ("/api/config/history/restore", "POST"), + ("/api/acme/issue", "POST"), + ("/api/acme/renew", "POST"), + ("/api/docker/refresh", "POST"), + ("/api/geoip/update", "POST"), + ("/api/cache/purge", "POST"), + ("/api/cache/invalidate", "POST"), + ("/api/cache/bans", "GET"), + ("/api/cache/bans/1", "DELETE"), + ("/api/tunnels", "GET"), + ("/api/tunnels/1/kill", "POST"), + ("/api/cache/cold/cleanup", "POST"), + ("/api/cache/cold/cleanup/1", "GET"), + ("/api/certificates/upload", "POST"), + ("/api/certificates/deploy", "POST"), + ("/api/certificates/reload", "POST"), + ("/api/certificates/manual-file/reload", "POST"), + ("/api/certificate-api", "PUT"), + ("/api/certificate-api/certbot-main", "DELETE"), + ("/api/certificate-api", "DELETE"), + ("/api/basic-auth/hash", "POST"), + ("/api/auth/password", "POST"), + ("/api/auth/logout", "POST"), + ("/api/auth/logout-all", "POST"), + ("/api/config", "OPTIONS"), + ("/api/config", "DELETE"), + ("/api/auth/login", "GET"), + ("/api/auth/setup", "GET"), + ("/healthz", "POST"), + ("/docs/openapi.json", "HEAD"), + ("/docs/content.json", "HEAD"), + ] { + let response = router + .clone() + .oneshot( + request(path) + .method(method) + .header("content-type", "application/json") + .body(Body::from("malformed secret payload")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{method} {path}" + ); + if method != "HEAD" { + assert_eq!(json_body(response).await, json!({"error":"auth.required"})); + } + } + store.close().await; + } + + + + + + #[tokio::test] + async fn docs_require_the_same_session_and_do_not_redirect_schema_fetches() { + let (router, store, _dir, secret, _) = fixture().await; + for path in ["/docs", "/docs/"] { + let response = router + .clone() + .oneshot(request(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers()["location"], "/?next=docs"); + } + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for path in [ + "/", + "/docs", + "/docs/", + "/docs/licenses.txt", + "/docs/content.json", + &format!("/assets/app_{}.js", super::asset_build_id()), + ] { + let response = router + .clone() + .oneshot( + request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + assert_eq!(response.headers()["x-frame-options"], "DENY"); + assert_eq!(response.headers()["cache-control"], "no-store"); + } + let response = router + .clone() + .oneshot( + request("/docs/openapi.json") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let spec = json_body(response).await; + assert_eq!(spec["openapi"], "3.0.3"); + assert_eq!(spec["info"]["version"], env!("CARGO_PKG_VERSION")); + assert_eq!( + spec["components"]["securitySchemes"]["AdminSession"]["in"], + "cookie" + ); + assert!(spec["paths"].get("/api/config").is_some()); + assert!(spec["paths"].get("/api/config/history/restore").is_some()); + let response = router + .clone() + .oneshot( + request("/docs/content.json") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let docs = json_body(response).await; + let documents = docs["documents"].as_array().expect("documentation bundle"); + assert!(documents.iter().any(|document| document["path"] == "docs/README.md")); + assert!(documents.iter().any(|document| document["path"] == "docs/WEB_SERVER.md")); + assert!(documents.iter().any(|document| document["path"] == "docs/PHP_FPM.md")); + assert!(documents.iter().any(|document| document["path"] == "docs/SECURITY_AUDIT_0.31.0.md")); + assert!(documents.iter().any(|document| document["path"] == "docs/TEST_REPORT.md")); + store.close().await; + } + #[tokio::test] + async fn credentials_in_query_or_duplicate_cookies_cannot_authorize() { + let (router, store, _dir, secret, _) = fixture().await; + for cookie in [ + String::new(), + format!("{}=invalid", auth::COOKIE_NAME), + format!( + "{}={secret}; {}={secret}", + auth::COOKIE_NAME, + auth::COOKIE_NAME + ), + ] { + let path = format!("/api/config?token={secret}&session={secret}"); + let response = router + .clone() + .oneshot( + request(&path) + .header("cookie", cookie) + .header("authorization", format!("Bearer {secret}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + store.close().await; + } + #[tokio::test] + async fn duplicate_csrf_or_marker_is_rejected_and_session_survives() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for duplicate in ["x-csrf-token", "x-rustproxy-request"] { + let response = router + .clone() + .oneshot( + request("/api/auth/logout") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header( + duplicate, + if duplicate == "x-csrf-token" { + csrf.as_str() + } else { + "1" + }, + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + let response = router + .oneshot( + request("/api/auth/session") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + store.close().await; + } + #[tokio::test] + async fn malformed_anonymous_json_never_reflects_submitted_values() { + let (router, store, _dir, _, _) = fixture().await; + for path in ["/api/auth/login", "/api/auth/setup"] { + let response = router + .clone() + .oneshot( + request(path) + .method("POST") + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("content-type", "application/json") + .body(Body::from( + r#"{"username":{"untrusted":"DO_NOT_REFLECT_ME"}}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(response).await, + json!({"error":"auth.invalid_request"}) + ); + } + store.close().await; + } + + #[tokio::test] + async fn retained_configuration_revision_can_be_restored_as_a_new_revision() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + + let mut first = Config::default(); + first.server_header.value = "first".into(); + let response = router + .clone() + .oneshot( + request("/api/config") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + json!({"expected_revision":0,"config":first}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["revision"], 1); + + let mut second = Config::default(); + second.server_header.value = "second".into(); + let response = router + .clone() + .oneshot( + request("/api/config") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + json!({"expected_revision":1,"config":second}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["revision"], 2); + + let response = router + .clone() + .oneshot( + request("/api/config/history/restore") + .method("POST") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + json!({"revision":1,"expected_revision":2}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let restored = json_body(response).await; + assert_eq!(restored["revision"], 3); + assert_eq!(restored["config"]["server_header"]["value"], "first"); + + let audit = store.recent_audit().await.unwrap(); + assert_eq!(audit[0].actor, "owner"); + assert_eq!(audit[0].action, "config.restore"); + assert_eq!(audit[0].detail, "source_revision=1 revision=3"); + let detail = store.configuration_revision(3).await.unwrap().unwrap(); + assert_eq!(detail.config.server_header.value, "first"); + assert_eq!(detail.previous_config.unwrap().server_header.value, "second"); + store.close().await; + } + +#[test] +fn stored_log_inventory_keeps_existing_files_visible_when_writer_is_disabled() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("access.jsonl"); + let mut file = std::fs::File::create(&path).unwrap(); + writeln!(file, r#"{{"event":"request","status":200}}"#).unwrap(); + + let mut config = Config::default(); + config.logging.file.enabled = false; + config.logging.file.per_vhost = false; + config.logging.file.path = path.to_string_lossy().into_owned(); + let sources = configured_file_log_sources(&config); + let access = sources.iter().find(|source| source.id == "access").unwrap(); + assert!(!access.enabled); + assert!(access.exists); + assert!(access.size > 0); + assert_eq!(access.path, path); +} + +#[test] +fn stored_log_tail_enforces_line_budget() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("access.jsonl"); + let mut file = std::fs::File::create(&path).unwrap(); + for id in 0..10 { + writeln!(file, r#"{{"time_ms":{id},"event":"request"}}"#).unwrap(); + } + drop(file); + + let source = source_from_path( + "access".into(), + "Access log".into(), + "access".into(), + "access", + path, + false, + "JSONL", + ); + let (lines, _bytes, truncated) = read_tail(&source, 1024 * 1024, 3).unwrap(); + assert_eq!(lines.len(), 3); + assert!(lines[0].contains(r#""time_ms":9"#)); + assert!(lines[2].contains(r#""time_ms":7"#)); + assert!(truncated); +} + +#[test] +fn stored_log_query_rejects_oversized_source_identifier() { + let query = FileLogQuery { + source: Some("x".repeat(193)), + ..FileLogQuery::default() + }; + assert!(validate_file_log_query(&query).is_err()); +} + +#[test] +fn stored_log_normalizes_mapped_client_ip_and_searches_diagnostics() { + let dir = tempfile::tempdir().unwrap(); + let source = source_from_path( + "vhost:app".into(), + "app.jsonl".into(), + "vhost:app".into(), + "access", + dir.path().join("app.jsonl"), + true, + "JSONL", + ); + let mut format = crate::logging::JsonFormat::default(); + format.fields.remove("client_ip"); + format + .fields + .insert("remote_addr".into(), "client_ip".into()); + let record = normalized_record( + &source, + r#"{"event":"protocol_error","remote_addr":"192.0.2.10","reason":"tls_handshake_error","error":"HTTP parser error","sni":"app.example.test","matched_by":"sni","vhost":"app"}"#, + &format, + ); + assert_eq!(record["client_ip"], "192.0.2.10"); + assert_eq!(record["sni"], "app.example.test"); + + let query = FileLogQuery::default(); + assert!(record_matches( + &record, + &query, + &parse_file_log_search("error").unwrap() + )); + assert!(record_matches( + &record, + &query, + &parse_file_log_search(r#"error:"HTTP parser""#).unwrap() + )); + assert!(record_matches( + &record, + &query, + &parse_file_log_search("sni:app.example.test AND !country:Poland").unwrap() + )); + assert!(!record_matches( + &record, + &query, + &parse_file_log_search("sni:missing").unwrap() + )); +} + +#[test] +fn stored_log_search_supports_field_aliases_negation_and_numeric_filters() { + let record = json!({ + "method":"GET", + "country":"Germany", + "status":503, + "path":"/api/users" + }); + assert!(record_matches_search( + &record, + &parse_file_log_search("req: GET AND !country: Poland AND status:>=500").unwrap() + )); + assert!(record_matches_search( + &record, + &parse_file_log_search("status:5xx path:/api/*").unwrap() + )); + assert!(!record_matches_search( + &record, + &parse_file_log_search("country:Poland").unwrap() + )); +} + +#[test] +fn stored_log_raw_error_parser_extracts_basic_fields() { + let record = parse_raw_error_record( + "2026-10-05T19:00:00.000Z ERROR rust_proxy::http upstream failed client_ip=192.0.2.10 status=502 error=connection reset", + ); + assert_eq!(record["timestamp"], "2026-10-05T19:00:00.000Z"); + assert_eq!(record["level"], "ERROR"); + assert_eq!(record["target"], "rust_proxy::http"); + assert_eq!(record["message"], "upstream failed"); + assert_eq!(record["client_ip"], "192.0.2.10"); + assert_eq!(record["status"], "502"); + assert_eq!(record["error"], "connection reset"); +} + +#[cfg(unix)] +#[test] +fn stored_log_reader_refuses_symlink_targets() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("real.jsonl"); + let link = dir.path().join("link.jsonl"); + std::fs::write(&real, b"{}\n").unwrap(); + symlink(&real, &link).unwrap(); + assert!(open_file_log(&link).is_err()); +} + +#[test] +fn stored_log_tail_reads_rotated_gzip_and_inventory_discovers_it() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let active = dir.path().join("access.jsonl"); + std::fs::write(&active, b"{\"event\":\"request\",\"time_ms\":3}\n").unwrap(); + let rotated = dir.path().join("access.jsonl.1.gz"); + let file = std::fs::File::create(&rotated).unwrap(); + let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + writeln!(encoder, r#"{{"event":"request","time_ms":1}}"#).unwrap(); + writeln!(encoder, r#"{{"event":"request","time_ms":2}}"#).unwrap(); + encoder.finish().unwrap(); + + let source = source_from_path( + "access:gz:access.jsonl.1.gz".into(), + "Access log · access.jsonl.1.gz".into(), + "access".into(), + "access", + rotated.clone(), + true, + "JSONL", + ); + assert!(source.compressed); + let (lines, bytes, truncated) = read_tail(&source, FILE_LOG_SCAN_BYTES_TOTAL, 10).unwrap(); + assert_eq!(bytes, std::fs::metadata(&rotated).unwrap().len()); + assert!(!truncated); + assert_eq!(lines.len(), 2); + assert!(lines[0].contains(r#""time_ms":2"#)); + + let mut config = Config::default(); + config.logging.file.per_vhost = false; + config.logging.file.path = active.to_string_lossy().into_owned(); + let sources = configured_file_log_sources(&config); + assert!(sources.iter().any(|item| item.path == rotated && item.compressed)); +} + +#[test] +fn stored_log_per_vhost_inventory_groups_active_and_rotated_files() { + use std::io::Write; + + let dir = tempfile::tempdir().unwrap(); + let active = dir.path().join("app.jsonl"); + std::fs::write(&active, b"{\"event\":\"request\"}\n").unwrap(); + let rotated = dir.path().join("app.jsonl.1.gz"); + let file = std::fs::File::create(&rotated).unwrap(); + let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + writeln!(encoder, r#"{{"event":"request"}}"#).unwrap(); + encoder.finish().unwrap(); + + let mut config = Config::default(); + config.logging.file.per_vhost = true; + config.logging.file.directory = dir.path().to_string_lossy().into_owned(); + let sources = configured_file_log_sources(&config); + let app = sources.iter().filter(|source| source.group_id == "vhost:app").collect::>(); + assert_eq!(app.len(), 2); + assert!(app.iter().any(|source| source.id == "vhost:app")); + assert!(app.iter().any(|source| source.compressed)); +} + +#[test] +fn stored_log_aggregate_values_support_scalars_and_bounded_arrays() { + let value = json!(["BadBot/1", "BadBot/1", 503, true, {"ignored":"object"}]); + let mut values = Vec::new(); + file_log_aggregate_values(&value, &mut values); + assert_eq!(values, vec!["BadBot/1".to_string(), "BadBot/1".to_string(), "503".to_string(), "true".to_string()]); +} + +#[test] +fn stored_log_query_validates_aggregation_controls() { + assert!(validate_file_log_query(&FileLogQuery { + aggregate_field: Some("user_agent".into()), + aggregate_min_count: Some(2), + aggregate_limit: Some(50), + ..FileLogQuery::default() + }).is_ok()); + assert!(validate_file_log_query(&FileLogQuery { + aggregate_field: Some("bad field".into()), + ..FileLogQuery::default() + }).is_err()); +} + +#[test] +fn stored_logs_sort_globally_before_pagination_and_keep_missing_last() { + let original = vec![ + json!({"time_ms": 200, "status": 500, "nested": {"count": 9}, "method": "POST", "path": "/z"}), + json!({"time_ms": 300, "status": 200, "nested": {"count": 2}, "method": "GET", "path": "/a"}), + json!({"time_ms": 100, "status": 404, "nested": {"count": 5}, "method": "GET", "path": "/b"}), + json!({"time_ms": 400, "nested": {}, "message": "missing status"}), + ]; + let mut records = original.clone(); + sort_file_log_records(&mut records, "status", false); + assert_eq!(records.iter().map(|v| v["status"].as_u64()).collect::>(), + vec![Some(200), Some(404), Some(500), None]); + // Page two must reflect the sorted complete scan, not a local page sort. + assert_eq!(records[1]["status"], 404); + sort_file_log_records(&mut records, "status", true); + assert_eq!(records.iter().map(|v| v["status"].as_u64()).collect::>(), + vec![Some(500), Some(404), Some(200), None]); + sort_file_log_records(&mut records, "nested.count", false); + assert_eq!(records.iter().map(|v| v["nested"]["count"].as_u64()).collect::>(), + vec![Some(2), Some(5), Some(9), None]); + sort_file_log_records(&mut records, "time", true); + assert_eq!(records.iter().map(|v| v["time_ms"].as_u64()).collect::>(), + vec![Some(400), Some(300), Some(200), Some(100)]); + sort_file_log_records(&mut records, "record", false); + assert_eq!(records[0]["path"], "/a"); + assert_eq!(records[3]["path"], "/z"); + assert!(validate_file_log_query(&FileLogQuery {sort_by: Some("bad field".into()), ..FileLogQuery::default()}).is_err()); + assert!(validate_file_log_query(&FileLogQuery {sort_by: Some("nested.count".into()), ..FileLogQuery::default()}).is_ok()); +} + + #[tokio::test] + async fn only_current_build_fingerprinted_javascript_and_css_are_served() { + let (router, store, _dir, secret, _) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let id = super::asset_build_id(); + for (name, suffix) in [ + ("/assets/app", "js"), + ("/assets/host404", "js"), + ("/assets/style", "css"), + ("/assets/login", "js"), + ("/docs/init", "js"), + ("/docs/style", "css"), + ("/docs/swagger-ui-bundle", "js"), + ("/docs/swagger-ui", "css"), + ] { + let legacy = format!("{name}.{suffix}"); + let wrong = format!("{name}_0000000000000000.{suffix}"); + let current = format!("{name}_{id}.{suffix}"); + for path in [legacy, wrong] { + let response = router.clone().oneshot(request(&path) + .header("cookie", &cookie).body(Body::empty()).unwrap()) + .await.unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); + } + let response = router.clone().oneshot(request(¤t) + .header("cookie", &cookie).body(Body::empty()).unwrap()) + .await.unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{current}"); + } + store.close().await; + } + #[tokio::test] + async fn public_document_and_discovery_contain_only_login_information() { + let (router, store, _dir, _, _) = fixture().await; + for (path, expected) in [ + ("/healthz", json!({"status":"ok"})), + ("/api/auth/status", json!({"setup_required":false})), + ] { + let response = router + .clone() + .oneshot(request(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await, expected); + } + let response = router + .clone() + .oneshot(request("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let bytes = axum::body::to_bytes(response.into_body(), 100_000) + .await + .unwrap(); + let text = std::str::from_utf8(&bytes).unwrap(); + assert!(text.contains(&format!("/assets/login_{}.js", super::asset_build_id()))); + for absent in [ + "/assets/app.js", + "id=\"workspace\"", + "loginLead", + "config-editor", + "version", + ] { + assert!(!text.contains(absent), "unexpected public content {absent}"); + } + let response = router + .clone() + .oneshot( + request("/assets/login-langs.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let bundle = json_body(response).await; + let keys: Vec = serde_json::from_str(include_str!("web/login-keys.json")).unwrap(); + for language in bundle["languages"].as_array().unwrap() { + assert!(language["translations"] + .as_object() + .unwrap() + .keys() + .all(|key| keys.contains(key))); + assert!(language["translations"].get("cacheTitle").is_none()); + } + store.close().await; + } + + #[test] + fn directory_inventory_retains_newest_with_deterministic_ties() { + let candidates = (0..10_000).map(|index| { + let name = format!("{index:05}.jsonl.gz"); + (name.clone(), std::path::PathBuf::from(name), (index / 10) as u64) + }); + let files = newest_log_files(candidates, 5); + assert_eq!(files.len(), 5); + let names: Vec<_> = files.iter().map(|entry| entry.0.as_str()).collect(); + assert_eq!(names, ["09990.jsonl.gz", "09991.jsonl.gz", "09992.jsonl.gz", "09993.jsonl.gz", "09994.jsonl.gz"]); + assert!(newest_log_files(std::iter::once(("x".into(), "x".into(), 1)), 0).is_empty()); + } + + #[tokio::test] + async fn system_info_and_compact_live_status_reuse_existing_admin_session() { + let (router, store, _dir, secret, _) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let info = router.clone().oneshot(request("/api/system-info") + .header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(info.status(), StatusCode::OK); + let info = json_body(info).await; + assert!(info.get("system").is_some()); + assert!(info.get("runtime").is_some()); + assert!(info.get("memory_accounting").is_some()); + assert!(info.get("config").is_none()); + let live = router.clone().oneshot(request("/api/status?view=live") + .header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(live.status(), StatusCode::OK); + let live = json_body(live).await; + assert!(live.get("metrics").is_some()); + assert!(live.get("backend_pools").is_some()); + assert!(live["listeners"].get("active_endpoints").is_some()); + assert!(live.get("system").is_none()); + assert!(live.get("memory_accounting").is_none()); + assert!(live.get("administration").is_none()); + let complete = router.clone().oneshot(request("/api/status") + .header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(complete.status(), StatusCode::OK); + assert!(json_body(complete).await.get("system").is_some()); + let invalid = router.clone().oneshot(request("/api/status?view=unknown") + .header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + let bad_sse = router.oneshot(request("/api/events?interval=4") + .header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY); + store.close().await; + } + + + #[test] + fn defaults_and_partial_legacy_refresh_documents() { + let previous = serde_json::to_value(AdminPreferences::default()).unwrap(); + let mut legacy = previous.as_object().unwrap().clone(); + legacy.remove("refresh"); + let loaded: AdminPreferences = serde_json::from_value(Value::Object(legacy)).unwrap(); + assert_eq!(loaded.refresh, RefreshPreferences::default()); + let partial: RefreshPreferences = serde_json::from_str(r#"{"traffic_seconds":0}"#).unwrap(); + assert_eq!(partial.traffic_seconds, 0); + assert_eq!(partial.system_info_seconds, 60); + assert!(validate_admin_preferences(&loaded).is_ok()); + } + + #[test] + fn refresh_values_are_allowlisted() { + let mut value = RefreshPreferences::default(); + for seconds in [0, 4, 11, 65000] { + value.overview_seconds = seconds; + assert!(validate_refresh_preferences(&value).is_err()); + } + value.overview_seconds = 1; + for seconds in [1, 2, 4, 31] { + value.traffic_seconds = seconds; + assert!(validate_refresh_preferences(&value).is_err()); + } + value.traffic_seconds = 0; + value.cache_seconds = 0; + value.system_info_seconds = 0; + assert!(validate_refresh_preferences(&value).is_ok()); + value.system_info_seconds = 16; + assert!(validate_refresh_preferences(&value).is_err()); + assert!(serde_json::from_str::(r#"{"unexpected":1}"#).is_err()); + } + + #[tokio::test] + async fn cache_system_events_and_static_private_assets_reject_anonymous_requests() { + let (router, store, _dir, secret, csrf) = fixture().await; + for path in [ + "/api/status", "/api/status?view=live", "/api/system-info", "/api/config", + "/api/preferences", "/api/cache", "/api/cache/bans", "/api/audit", + "/api/logs", "/api/traffic", "/api/events", "/api/live-logs", + "/api/file-logs", "/api/certificate-api", "/docs/openapi.json", + "/assets/app.js", "/assets/cache-panel.js", "/assets/langs.json", + ] { + let response = router.clone().oneshot(request(path).body(Body::empty()).unwrap()) + .await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "anonymous GET {path}"); + assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY"); + assert!(response.headers().get("content-security-policy").is_some()); + } + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for (method, path) in [ + ("POST", "/api/cache/purge"), + ("POST", "/api/cache/invalidate"), + ("DELETE", "/api/cache/bans/1"), + ("PUT", "/api/config/cache"), + ("PUT", "/api/preferences"), + ("POST", "/api/auth/logout-all"), + ] { + let no_session = request(path).method(method).body(Body::empty()).unwrap(); + let response = router.clone().oneshot(no_session).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "anonymous {method} {path}"); + let without_csrf = request(path).method(method) + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .body(Body::empty()).unwrap(); + let response = router.clone().oneshot(without_csrf).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "CSRF {method} {path}"); + } + // The valid session and CSRF continue to work for a benign mutation, + // proving the failed requests above did not revoke the cookie. + let response = router.oneshot(request("/api/auth/logout") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + store.close().await; + } + + #[tokio::test] + async fn admin_origin_host_and_fetch_metadata_are_checked_even_with_valid_cookie() { + let (router, store, _dir, secret, _csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let evil_origin = request("/api/status").header("cookie", &cookie) + .header("origin", "https://another.example") + .body(Body::empty()).unwrap(); + let response = router.clone().oneshot(evil_origin).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let mut repeated_host = request("/api/status").header("cookie", &cookie) + .body(Body::empty()).unwrap(); + repeated_host.headers_mut().append("host", "other.example".parse().unwrap()); + let response = router.clone().oneshot(repeated_host).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let cross_site = request("/api/status").header("cookie", &cookie) + .header("sec-fetch-site", "cross-site").body(Body::empty()).unwrap(); + let response = router.clone().oneshot(cross_site).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + store.close().await; + } + + #[test] + fn pinned_admin_proxy_proto_is_bounded_and_requires_external_https() { + let mut headers = HeaderMap::new(); + assert!(pinned_admin_proto_compatible(&headers)); + headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); + assert!(pinned_admin_proto_compatible(&headers)); + headers.insert("x-forwarded-proto", HeaderValue::from_static("https, http")); + assert!(pinned_admin_proto_compatible(&headers)); + headers.append("x-forwarded-proto", HeaderValue::from_static("http")); + assert!(pinned_admin_proto_compatible(&headers)); + for bad in ["http", "http, https", "https, ftp", "https,", ",https", "HTTPS", "https, http, http, http, http, http, http, http, http"] { + headers.remove("x-forwarded-proto"); + headers.insert("x-forwarded-proto", HeaderValue::from_str(bad).unwrap()); + assert!(!pinned_admin_proto_compatible(&headers), "must reject {bad}"); + } + } + + #[tokio::test] + async fn pinned_panel_origin_survives_multi_hop_proxy_without_relaxing_csrf() { + let mut config = Config::default(); + config.administration.public_origin = Some("https://panel.example.test".into()); + let (router, store, _dir, secret, csrf) = fixture_with_config(config).await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + // Without an explicit origin pin, 0.31.3 rejected this session GET as + // external_scheme although the browser address remained correct. + let result = router.clone().oneshot(request("/api/auth/session") + .header("cookie", &cookie) + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https, http") + .header("sec-fetch-site", "same-origin") + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(result.status(), StatusCode::OK); + let session_cookie = result.headers().get_all(SET_COOKIE).iter() + .filter_map(|v| v.to_str().ok()).find(|v| v.starts_with(auth::COOKIE_NAME)).unwrap(); + assert!(session_cookie.contains("; Secure")); + + // The incoming admin Host may itself be the pinned public DNS name. + // There is no separate allowed_hosts entry in the saved configuration. + let pinned_host = Request::builder().uri("/api/auth/session") + .header("host", "panel.example.test") + .header("cookie", &cookie) + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https, http") + .extension(PeerAddr("127.0.0.1:45003".parse().unwrap())) + .body(Body::empty()).unwrap(); + assert_eq!(router.clone().oneshot(pinned_host).await.unwrap().status(), StatusCode::OK); + + // Explicit Origin, CSRF token and app request marker remain mandatory + // for a state-changing request even when XFP contains multiple hops. + let unauthorized = router.clone().oneshot(request("/api/auth/logout") + .method("POST").header("cookie", &cookie) + .header("origin", "https://panel.example.test") + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https, http") + .extension(PeerAddr("127.0.0.1:45001".parse().unwrap())) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + let authorized = router.oneshot(request("/api/auth/logout") + .method("POST").header("cookie", &cookie) + .header("origin", "https://panel.example.test") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", csrf) + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https, http") + .extension(PeerAddr("127.0.0.1:45002".parse().unwrap())) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(authorized.status(), StatusCode::OK); + store.close().await; + } + + #[tokio::test] + async fn pinned_panel_origin_rejects_untrusted_proxy_host_and_origin() { + let mut config = Config::default(); + config.administration.public_origin = Some("https://panel.example.test".into()); + let (router, store, _dir, secret, _) = fixture_with_config(config).await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for (xfh, xfp, origin, fetch_site) in [ + ("evil.example.test", "https, http", "https://panel.example.test", "same-origin"), + ("other@panel.example.test", "https, http", "https://panel.example.test", "same-origin"), + ("panel.example.test", "http", "https://panel.example.test", "same-origin"), + ("panel.example.test", "http, https", "https://panel.example.test", "same-origin"), + ("panel.example.test", "https, http", "https://evil.example.test", "same-origin"), + ("panel.example.test", "https, http", "null", "same-origin"), + ("panel.example.test", "https, http", "https://panel.example.test", "cross-site"), + ] { + let result = router.clone().oneshot(request("/api/auth/session") + .header("cookie", &cookie) + .header("x-forwarded-host", xfh) + .header("x-forwarded-proto", xfp) + .header("origin", origin) + .header("sec-fetch-site", fetch_site) + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(result.status(), StatusCode::FORBIDDEN, + "must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}"); + } + // Credential-bearing certificate automation does not inherit the + // browser-origin override, even through the same trusted admin socket. + let automation = router.clone().oneshot(request("/api/certificates/deploy") + .method("POST") + .header(CERTIFICATE_API_KEY_HEADER, "svc-key") + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https, http") + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(automation.status(), StatusCode::FORBIDDEN); + + let untrusted = router.clone().oneshot(request("/api/auth/session") + .header("cookie", &cookie) + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https, http") + .header("origin", "https://panel.example.test") + .extension(PeerAddr("192.0.2.42:44000".parse().unwrap())) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(untrusted.status(), StatusCode::FORBIDDEN); + + let mut duplicated = request("/api/auth/session") + .header("cookie", &cookie) + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https, http") + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) + .body(Body::empty()).unwrap(); + duplicated.headers_mut().append("x-forwarded-host", "panel.example.test".parse().unwrap()); + assert_eq!(router.clone().oneshot(duplicated).await.unwrap().status(), StatusCode::FORBIDDEN); + store.close().await; + } + + #[tokio::test] + async fn without_public_origin_ambiguous_forwarded_proto_stays_forbidden() { + let mut config = Config::default(); + config.administration.allowed_hosts = vec!["panel.example.test".into()]; + let (router, store, _dir, secret, _) = fixture_with_config(config).await; + let result = router.oneshot(request("/api/auth/session") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https, http") + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) + .body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(result.status(), StatusCode::FORBIDDEN); + store.close().await; + } } diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js index d6e6808..fa086f0 100644 --- a/src/admin/web/app/core.js +++ b/src/admin/web/app/core.js @@ -151,6 +151,7 @@ async function api(path, options = {}) { function setPanel(name, navigate = true) { if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; } if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview"; + const previousPanel = currentPanel; currentPanel = name; $("overview-refresh").hidden = name !== "overview"; document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name)); @@ -159,6 +160,9 @@ function setPanel(name, navigate = true) { if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]); if (name === "settings") window.rustProxySettings?.syncLocation?.(); window.rustProxyRefresh?.setPanel(name); + // Read the cached Docker status on each visit, independent of the Live-view toggle. + // This is GET /api/docker, not the POST that triggers an agent poll. + if (name === "docker-vhosts" && previousPanel !== name && authenticated && envelope) void window.rustProxyRefresh?.manual("docker"); if (name === "tls") window.rustProxyAcme?.syncLocation?.(); if (name === "filelogs") void window.rustProxyFileLogs?.refresh(); if (name === "tls") void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); diff --git a/src/admin/web/docker.js b/src/admin/web/docker.js index e99ff84..d1fa770 100644 --- a/src/admin/web/docker.js +++ b/src/admin/web/docker.js @@ -10,6 +10,7 @@ const HIDDEN_KEY = "rustproxy.docker.hidden-vhosts"; const cfg = () => envelope?.config?.docker_agents || { agents: [] }; const obs = id => observations.find(item => item.agent_id === id); + const hasUnobservedAgent = () => (cfg().agents || []).some(agent => agent.enabled !== false && !obs(agent.id)); const firstListener = () => envelope?.config?.listeners?.endpoints?.find(item => item.enabled !== false)?.id || "http"; const defaultHealth = () => ({ mode: "tcp", path: "/", host: null, interval_ms: 5000, timeout_ms: 2000, slow_response_ms: 1500, fall: 3, rise: 2, expected_status_min: 200, expected_status_max: 399 }); const defaultPassiveHealth = () => ({ enabled: false, count_connect_errors: true, count_timeouts: true, count_io_errors: true, count_http_5xx: true, count_slow_responses: false, fall: null, rise: null }); @@ -115,7 +116,7 @@ const https = String(agent?.endpoint || "").toLowerCase().startsWith("https://"); const pinned = !!agent?.tls_fingerprint_sha256; if (!https) return { key: "dockerTlsPlain", cls: "warn", detail: t("dockerTlsPlainHelp") }; - if (pinned) return { key: "dockerTlsPinned", cls: "live", detail: `${t("dockerTlsFingerprint")}: ${prettyFingerprint(agent.tls_fingerprint_sha256)}` }; + if (pinned) return { key: "dockerTlsPinned", cls: state?.ok ? "live" : "", detail: `${t("dockerTlsFingerprint")}: ${prettyFingerprint(agent.tls_fingerprint_sha256)}` }; if (state?.ok) return { key: "dockerTlsPublicCa", cls: "live", detail: t("dockerTlsPublicCaHelp") }; return { key: "dockerTlsPublicCa", cls: "", detail: t("dockerTlsPublicCaHelp") }; } @@ -326,7 +327,10 @@ const state = obs(agent.id), box = el("article", undefined, "card docker-agent-card"); box.dataset.agentId = agent.id; box.dataset.searchText = `${agent.id} ${agent.name || ""} ${agent.base_domain || ""}`.toLowerCase(); const head = el("div", undefined, "card-heading"), left = el("div"); left.append(el("span", agent.id, "small-label"), el("h2", agent.name)); const badges = el("div", undefined, "entity-actions"), connection = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill ${state?.ok ? "live" : ""}`), sec = securityInfo(agent, state); badges.append(el("span", t(sec.key), `pill ${sec.cls}`.trim()), connection); head.append(left, badges); box.append(head); if (!agent.enabled) { box.append(el("p", t("dockerAgentDisabled"), "fine")); target.append(box); continue; } - if (!state?.ok) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state?.error || t("dockerNoObservation"), "fine"), el("small", sec.detail, "fine")); box.append(error); } + // No observation is the normal state while the first background poll is in flight. + // A configured TLS fingerprint alone is not proof that an agent was contacted. + if (!state) box.append(el("p", t("dockerNoObservation"), "fine")); + else if (!state.ok) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state.error || t("dockerUnknownPollError"), "fine")); box.append(error); } if (!state?.inventory) { const rows = el("div", undefined, "docker-service-list"); for (const item of agent.assignments || []) rows.append(assignmentRow(agent, null, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box); continue; } const inv = state.inventory, defaultIp = agent.default_target_ip || agent.target_ip_override || t("dockerTargetAuto"), serviceCount = (inv.services || []).length, overview = el("div", undefined, "docker-agent-overview"); box.dataset.searchText += ` ${inv.hostname || ""}`.toLowerCase(); overview.append(el("span", `${t("dockerHost")}: ${inv.hostname}`), el("span", `${t("dockerServices")}: ${serviceCount}`), el("span", `${t("dockerLastChecked")}: ${checkedAgo(state.checked_at_unix_ms)}`), el("span", `*.${agent.base_domain}`)); box.append(overview); @@ -406,8 +410,10 @@ $("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } }); rustProxyRefresh.register("docker", { interval: () => 5, - active: () => currentPanel === "docker-vhosts" && liveEnabled() && canLiveRender(), + // Even with local Live view off, observe the first startup result. + // Once every enabled agent has a first result, stop unless Live is enabled. + active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()), run: async context => refresh(false, context) }); - window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } }; + window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } }; })(); diff --git a/src/admin/web/error_400.html b/src/admin/web/error_400.html new file mode 100644 index 0000000..40d907d --- /dev/null +++ b/src/admin/web/error_400.html @@ -0,0 +1,37 @@ + + + + + + + 400 - Bad request - RustProxy + + + + +
+

400 - Bad request

+

The request could not be processed because it is invalid.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/error_408.html b/src/admin/web/error_408.html new file mode 100644 index 0000000..d3daee9 --- /dev/null +++ b/src/admin/web/error_408.html @@ -0,0 +1,37 @@ + + + + + + + 408 - Request timeout - RustProxy + + + + +
+

408 - Request timeout

+

The request was not completed in time. Please try again.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/error_410.html b/src/admin/web/error_410.html new file mode 100644 index 0000000..0153ecd --- /dev/null +++ b/src/admin/web/error_410.html @@ -0,0 +1,37 @@ + + + + + + + 410 - Gone - RustProxy + + + + +
+

410 - Gone

+

The requested resource is no longer available.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/error_413.html b/src/admin/web/error_413.html new file mode 100644 index 0000000..ca3ab55 --- /dev/null +++ b/src/admin/web/error_413.html @@ -0,0 +1,37 @@ + + + + + + + 413 - Payload too large - RustProxy + + + + +
+

413 - Payload too large

+

The request body exceeds the allowed size.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/error_500.html b/src/admin/web/error_500.html new file mode 100644 index 0000000..3b7819e --- /dev/null +++ b/src/admin/web/error_500.html @@ -0,0 +1,37 @@ + + + + + + + 500 - Internal server error - RustProxy + + + + +
+

500 - Internal server error

+

The server encountered an unexpected error.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/error_502.html b/src/admin/web/error_502.html new file mode 100644 index 0000000..08f9793 --- /dev/null +++ b/src/admin/web/error_502.html @@ -0,0 +1,37 @@ + + + + + + + 502 - Bad gateway - RustProxy + + + + +
+

502 - Bad gateway

+

The upstream service returned an invalid response.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/file-logs.js b/src/admin/web/file-logs.js index e14dabe..69b207f 100644 --- a/src/admin/web/file-logs.js +++ b/src/admin/web/file-logs.js @@ -11,6 +11,7 @@ ]); let availableColumns = new Set(defaultColumnIds); let visibleColumns = new Set(defaultColumnIds); + let sortBy = "time", sortDescending = true, pendingRefresh = false; const CONSOLIDATION_STORAGE_KEY = "rp-file-logs-consolidate-vhosts"; @@ -146,8 +147,22 @@ function syncHeaders() { const row = $("file-logs-table-head").querySelector("tr"); const headers = orderedColumns().filter(id => visibleColumns.has(id)).map(id => { - const header = el("th", columnLabel(id)); header.dataset.fileLogColumn = id; - const key = specialLabels.get(id); if (key) header.dataset.i18n = key; + const header = el("th"); header.dataset.fileLogColumn = id; + header.setAttribute("aria-sort", sortBy === id ? (sortDescending ? "descending" : "ascending") : "none"); + const button = el("button", undefined, "file-log-sort-button"); button.type = "button"; + const caption = el("span", columnLabel(id)); + const key = specialLabels.get(id); if (key) caption.dataset.i18n = key; + const arrow = el("span", sortBy === id ? (sortDescending ? "↓" : "↑") : "↕", "file-log-sort-indicator"); + arrow.setAttribute("aria-hidden", "true"); + const willDescend = sortBy === id ? !sortDescending : false; + button.title = `${t(willDescend ? "fileLogsSortDescending" : "fileLogsSortAscending")}: ${columnLabel(id)}`; + button.setAttribute("aria-label", button.title); + button.addEventListener("click", () => { + sortDescending = sortBy === id ? !sortDescending : false; + sortBy = id; selectedIndex = null; offset = 0; + syncHeaders(); void savePreferences(); void refresh(true); + }); + button.append(caption, arrow); header.append(button); return header; }); row.replaceChildren(...headers); @@ -172,6 +187,8 @@ ? preferences.file_log_columns.filter(id => typeof id === "string" && id.length <= 128) : []; visibleColumns = new Set(selected.length ? selected : defaultColumnIds); + sortBy = typeof preferences?.file_log_sort === "string" && /^[a-z0-9_.-]{1,128}$/i.test(preferences.file_log_sort) ? preferences.file_log_sort : "time"; + sortDescending = preferences?.file_log_descending !== false; for (const id of visibleColumns) availableColumns.add(id); preferencesLoaded = true; syncColumnPicker(); syncHeaders(); renderRows(); @@ -184,7 +201,7 @@ do { preferenceSaveQueued = false; const selected = orderedColumns().filter(id => visibleColumns.has(id)); - const preferences = await window.rustProxyPreferences.update({ file_log_columns: selected }); + const preferences = await window.rustProxyPreferences.update({ file_log_columns: selected, file_log_sort: sortBy, file_log_descending: sortDescending }); if (!preferenceSaveQueued) applyPreferences(preferences); } while (preferenceSaveQueued); } catch (error) { toast(error.message, true); } @@ -267,12 +284,14 @@ params.set("aggregate_min_count", String(Math.max(2, Number($("file-log-aggregate-min")?.value) || 2))); params.set("aggregate_limit", String(Math.max(1, Number($("file-log-aggregate-limit")?.value) || 50))); } + params.set("sort_by", sortBy); params.set("sort_descending", String(sortDescending)); params.set("offset", String(offset)); params.set("limit", String(currentLimit())); return params.toString(); } async function refresh(reset = false) { - if (!authenticated || busy) return; + if (!authenticated) return; if (reset) offset = 0; + if (busy) { pendingRefresh = true; return; } busy = true; $("file-logs-refresh").disabled = true; try { if (!inventory) await loadInventory(); @@ -289,10 +308,11 @@ $("file-logs-prev").disabled = offset === 0; $("file-logs-next").disabled = offset + currentLimit() >= lastTotal; } catch (error) { toast(error.message, true); } - finally { busy = false; $("file-logs-refresh").disabled = false; } + finally { busy = false; $("file-logs-refresh").disabled = false; if (pendingRefresh) { pendingRefresh = false; void refresh(); } } } function reset() { inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null; aggregateField = ""; + sortBy = "time"; sortDescending = true; pendingRefresh = false; preferencesLoaded = false; preferenceSaveRunning = false; preferenceSaveQueued = false; availableColumns = new Set(defaultColumnIds); visibleColumns = new Set(defaultColumnIds); sourceSelect().replaceChildren(Object.assign(el("option", t("fileLogsAllSources")), { value: "all" })); diff --git a/src/admin/web/status_200.html b/src/admin/web/status_200.html new file mode 100644 index 0000000..d2372c1 --- /dev/null +++ b/src/admin/web/status_200.html @@ -0,0 +1,37 @@ + + + + + + + 200 - OK - RustProxy + + + + +
+

200 - OK

+

The request completed successfully.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/style.css b/src/admin/web/style.css index 7e91b1e..316137f 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -8692,6 +8692,25 @@ button.config-history-day.is-selected { box-shadow: 0 1px 0 var(--line); } +/* Stored Logs sorting is performed by the bounded server scan before pagination. */ +.file-logs-table th .file-log-sort-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 2px; + font: inherit; + font-weight: inherit; + color: inherit; + border: 0; + background: transparent; + cursor: pointer; + text-align: left; +} +.file-logs-table th .file-log-sort-button:hover { color: var(--accent); } +.file-logs-table th[aria-sort="ascending"] .file-log-sort-button, +.file-logs-table th[aria-sort="descending"] .file-log-sort-button { color: var(--accent); } +.file-logs-table .file-log-sort-indicator { font-size: 11px; opacity: .8; } + .file-logs-table tbody tr:not(.file-log-detail-row) { cursor: pointer; } diff --git a/src/balance/tests.rs b/src/balance/tests.rs index da75279..77eb198 100644 --- a/src/balance/tests.rs +++ b/src/balance/tests.rs @@ -1,4 +1,643 @@ mod tests { - include!("tests/part_01.rs"); - include!("tests/part_02.rs"); +use super::*; +fn settings(mode: HealthMode) -> HealthCheck { + HealthCheck { + mode, + fall: 2, + rise: 2, + ..Default::default() + } +} +fn pool(algorithm: Algorithm, sticky: StickyMode) -> Arc { + let cfg = serde_json::from_value(serde_json::json!({ + "listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]}, + "vhosts":[{ + "id":"app","frontend":{"hosts":["app.localhost"],"listener_ids":["http"]}, + "backends":[{"id":"a","url":"http://127.0.0.1:9000","allow_private":true,"weight":3}, + {"id":"b","url":"http://127.0.0.1:9001","allow_private":true,"weight":1}], + "load_balancing":{"algorithm":algorithm,"sticky":{"mode":sticky},"slow_start_seconds":0,"health_check":{"mode":"off"}} + }]})).unwrap(); + let policy = crate::config::ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: None, + data_dir: None, + }; + crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone() +} +fn capacity_pool(limit: u32, queue_timeout_ms: u64) -> Arc { + let cfg = serde_json::from_value(serde_json::json!({ + "listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]}, + "vhosts":[{ + "id":"limited","frontend":{"hosts":["limited.localhost"],"listener_ids":["http"]}, + "backends":[{ + "id":"only","url":"http://127.0.0.1:9000","allow_private":true, + "max_concurrent_requests":limit,"queue_timeout_ms":queue_timeout_ms + }], + "load_balancing":{"health_check":{"mode":"off"}} + }] + })) + .unwrap(); + let policy = crate::config::ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: None, + data_dir: None, + }; + crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone() +} +fn select(pool: &Pool) -> Selection { + pool.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), false) + .unwrap() +} +#[test] +fn per_backend_monitoring_can_be_disabled_or_overridden() { + let mut defaults: crate::vhost::PoolMember = serde_json::from_value(serde_json::json!({ + "id": "error_404", "url": "http://127.0.0.1:8282", "pool_eligible": false + })).unwrap(); + let mut settings = Settings::default(); + settings.passive_health.enabled = true; + assert!(defaults.health_check_enabled); + assert_eq!(settings.health_for(&defaults).mode, HealthMode::Tcp); + assert!(settings.passive_for(&defaults).enabled); + defaults.health_check_enabled = false; + assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off); + assert!(!settings.passive_for(&defaults).enabled); + assert!(!defaults.pool_eligible); // routing intent is independent + defaults.health_check_enabled = true; + defaults.health_check = Some(HealthCheck { mode: HealthMode::Http, path: "/healthz".into(), ..Default::default() }); + let effective = settings.health_for(&defaults); + assert_eq!(effective.mode, HealthMode::Http); + assert_eq!(effective.path, "/healthz"); + assert_eq!(effective.host, settings.health_check.host); + let restored: crate::vhost::PoolMember = serde_json::from_str(&serde_json::to_string(&defaults).unwrap()).unwrap(); + assert_eq!(settings.health_for(&restored).path, "/healthz"); +} +#[test] +fn default_health_check_uses_tcp() { + assert_eq!(HealthMode::default(), HealthMode::Tcp); + assert_eq!(HealthCheck::default().mode, HealthMode::Tcp); +} +#[test] +fn legacy_settings_without_passive_health_default_to_off() { + let parsed: Settings = serde_json::from_value(serde_json::json!({ + "health_check": { "mode": "off" } + })) + .unwrap(); + assert!(!parsed.passive_health.enabled); + assert!(parsed.passive_health.count_connect_errors); + assert!(parsed.passive_health.count_timeouts); + assert!(parsed.passive_health.count_io_errors); + assert!(parsed.passive_health.count_http_5xx); + assert!(!parsed.passive_health.count_slow_responses); + assert_eq!(parsed.retry.max_retries, 1); + assert!(parsed.retry.retry_connect_errors); + assert!(parsed.retry.retry_timeouts); + assert_eq!(parsed.retry.retry_statuses, vec![502, 503, 504]); + assert_eq!(parsed.keepalive.pool_recycle_requests, 25_000); + assert_eq!(parsed.keepalive.pool_recycle_seconds, 3_600); + assert_eq!(parsed.keepalive.http1_max_buffer_bytes, 131_072); +} + +#[test] +fn upstream_pool_recycle_limits_are_bounded_and_can_be_disabled() { + let mut settings = Settings::default(); + assert!(settings.validate().is_ok()); + + settings.keepalive.pool_recycle_requests = 0; + settings.keepalive.pool_recycle_seconds = 0; + assert!(settings.validate().is_ok()); + + settings.keepalive.pool_recycle_requests = 999; + assert!(settings.validate().is_err()); + settings.keepalive.pool_recycle_requests = 1_000; + settings.keepalive.pool_recycle_seconds = 59; + assert!(settings.validate().is_err()); + settings.keepalive.pool_recycle_seconds = 60; + settings.keepalive.http1_max_buffer_bytes = 8_191; + assert!(settings.validate().is_err()); + settings.keepalive.http1_max_buffer_bytes = 8_192; + assert!(settings.validate().is_ok()); +} + +#[test] +fn compatible_pool_rebuild_reuses_upstream_clients() { + let previous = pool(Algorithm::LeastConnections, StickyMode::Off); + let mut rebuilt = pool(Algorithm::LeastConnections, StickyMode::Off); + let rebuilt_mut = Arc::get_mut(&mut rebuilt).expect("fresh pool must be uniquely owned"); + + assert!(!Arc::ptr_eq( + &rebuilt_mut.members[0].backend, + &previous.members[0].backend + )); + rebuilt_mut.reuse_observations(&previous); + assert!(Arc::ptr_eq( + &rebuilt_mut.members[0].backend, + &previous.members[0].backend + )); +} + +#[test] +fn retry_policy_rejects_unbounded_or_non_5xx_configuration() { + let mut settings = Settings::default(); + settings.retry.max_retries = 4; + assert!(settings.validate().is_err()); + settings.retry.max_retries = 1; + settings.retry.retry_statuses = vec![429]; + assert!(settings.validate().is_err()); +} + +#[test] +fn traffic_rate_drops_to_zero_after_an_idle_sample_window() { + let state = BackendState::new(HealthMode::Off); + state + .bytes_out + .store(2048, std::sync::atomic::Ordering::Relaxed); + lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2); + state.sample_traffic(); + assert!(state.traffic_rate() > 0.0); + lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2); + state.sample_traffic(); + assert_eq!(state.traffic_rate(), 0.0); +} +#[test] +fn active_failures_are_independent_from_passive_user_traffic() { + let state = BackendState::new(HealthMode::Http); + let settings = settings(HealthMode::Http); + let passive = PassiveHealth::default(); + let fast = Duration::from_millis(5); + assert!(!state.healthy()); + state.observe_active(&settings, "ok", fast); + assert!(!state.healthy()); + state.observe_active(&settings, "ok", fast); + assert!(state.healthy()); + state.observe_active(&settings, "status", fast); + state.observe_passive(&settings, &passive, "ok", fast); + state.observe_active(&settings, "status", fast); + assert!(!state.healthy()); + state.observe_passive(&settings, &passive, "http_5xx", fast); + assert!(!state.healthy()); + state.observe_active(&settings, "ok", fast); + assert!(!state.healthy()); + state.observe_active(&settings, "ok", fast); + assert!(state.healthy()); +} + +#[test] +fn passive_health_is_opt_in_and_uses_separate_thresholds() { + let state = BackendState::new(HealthMode::Off); + let active = settings(HealthMode::Off); + let fast = Duration::from_millis(5); + for _ in 0..10 { + state.observe_passive(&active, &PassiveHealth::default(), "http_5xx", fast); + } + assert!(state.healthy()); + assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 10); + assert_eq!( + state.passive_health_failures_total.load(Ordering::Relaxed), + 0 + ); + + let passive = PassiveHealth { + enabled: true, + fall: Some(2), + rise: Some(2), + ..Default::default() + }; + state.observe_passive(&active, &passive, "http_5xx", fast); + assert!(state.healthy()); + state.observe_passive(&active, &passive, "http_5xx", fast); + assert!(!state.healthy()); + state.observe_passive(&active, &passive, "ok", fast); + assert!(!state.healthy()); + state.observe_passive(&active, &passive, "ok", fast); + assert!(state.healthy()); +} +#[test] +fn passive_http_5xx_can_be_explicitly_ignored_when_passive_health_is_on() { + let state = BackendState::new(HealthMode::Off); + let active = settings(HealthMode::Off); + let passive = PassiveHealth { + enabled: true, + count_http_5xx: false, + fall: Some(1), + ..Default::default() + }; + for _ in 0..100 { + state.observe_passive(&active, &passive, "http_5xx", Duration::from_millis(5)); + } + assert!(state.healthy()); + assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 100); + assert_eq!( + state.passive_health_failures_total.load(Ordering::Relaxed), + 0 + ); +} + +#[test] +fn passive_connect_errors_use_the_explicit_fall_threshold() { + let state = BackendState::new(HealthMode::Off); + let active = settings(HealthMode::Off); + let passive = PassiveHealth { + enabled: true, + count_connect_errors: true, + fall: Some(3), + ..Default::default() + }; + for _ in 0..2 { + state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(5)); + assert!(state.healthy()); + } + state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(5)); + assert!(!state.healthy()); + assert_eq!(state.connect_errors_total.load(Ordering::Relaxed), 3); + assert_eq!( + state.passive_health_failures_total.load(Ordering::Relaxed), + 3 + ); +} + +#[test] +fn active_health_can_stay_healthy_while_passive_health_is_off() { + let state = BackendState::new(HealthMode::Tcp); + let active = settings(HealthMode::Tcp); + let passive = PassiveHealth::default(); + let fast = Duration::from_millis(5); + state.observe_active(&active, "ok", fast); + state.observe_active(&active, "ok", fast); + assert!(state.healthy()); + for _ in 0..20 { + state.observe_passive(&active, &passive, "http_5xx", fast); + } + assert!(state.healthy()); + assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 20); + assert_eq!( + state.passive_health_failures_total.load(Ordering::Relaxed), + 0 + ); +} + +#[test] +fn unhealthy_source_hands_off_when_the_original_health_domain_recovers() { + let state = BackendState::new(HealthMode::Tcp); + let active = HealthCheck { + fall: 1, + rise: 1, + ..settings(HealthMode::Tcp) + }; + let passive = PassiveHealth { + enabled: true, + count_http_5xx: true, + fall: Some(1), + rise: Some(1), + ..Default::default() + }; + let fast = Duration::from_millis(5); + + state.observe_active(&active, "ok", fast); + state.observe_active(&active, "connect_error", fast); + { + let health = lock(&state.health); + assert_eq!(health.unhealthy_source, "active_tcp"); + } + state.observe_passive(&active, &passive, "http_5xx", fast); + state.observe_active(&active, "ok", fast); + { + let health = lock(&state.health); + assert!(!health.healthy); + assert_eq!(health.unhealthy_source, "passive_http_5xx"); + assert_eq!(health.unhealthy_reason, "http_5xx"); + } + state.observe_passive(&active, &passive, "ok", fast); + assert!(state.healthy()); +} + +#[test] +fn disabled_checks_never_quarantine_and_probes_are_single_flight() { + let off = BackendState::new(HealthMode::Off); + let settings = settings(HealthMode::Off); + for _ in 0..5 { + off.observe_passive( + &settings, + &PassiveHealth::default(), + "connect_error", + Duration::ZERO, + ); + } + assert!(off.healthy()); + assert!(!off.claim_probe(&settings)); + let on = BackendState::new(HealthMode::Http); + let settings = super::tests::settings(HealthMode::Http); + assert!(on.claim_probe(&settings)); + assert!(!on.claim_probe(&settings)); + on.observe_active(&settings, "ok", Duration::ZERO); + assert!(!on.claim_probe(&settings)); +} +#[tokio::test] +async fn weighted_round_robin_distributes_three_to_one_without_body_collection() { + let pool = pool(Algorithm::RoundRobin, StickyMode::Off); + let mut counts = [0, 0]; + for _ in 0..40 { + let selection = select(&pool); + counts[usize::from(selection.lease.member.definition.id == "b")] += 1; + } + assert_eq!(counts, [30, 10]); + assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0); + assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0); // Mere cache selection isn't origin traffic. +} +#[tokio::test] +async fn least_connections_counts_a_lease_until_its_last_owner_drops() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let mut held = Vec::new(); + for _ in 0..3 { + let selection = select(&pool); + assert_eq!(selection.lease.member.definition.id, "a"); + held.push(selection.lease); + } + let other = select(&pool); + assert_eq!(other.lease.member.definition.id, "b"); + let clone = held[0].clone(); + held.clear(); + assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 1); + drop(clone); + assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0); +} +#[tokio::test] +async fn failover_selection_never_reuses_an_attempted_backend() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let first = select(&pool); + let first_id = first.lease.member.definition.id.clone(); + drop(first); + let retry = pool + .select_retry( + "127.0.0.1".parse().unwrap(), + &HeaderMap::new(), + false, + std::slice::from_ref(&first_id), + ) + .unwrap(); + assert_ne!(retry.lease.member.definition.id, first_id); +} +#[tokio::test] +async fn backend_concurrency_limit_queues_then_rejects_and_recovers() { + let pool = capacity_pool(1, 10); + let first = select(&pool); + let headers = HeaderMap::new(); + let saturated = pool + .select_wait("127.0.0.1".parse().unwrap(), &headers, false) + .await; + assert!(matches!(saturated, Err(SelectionFailure::Capacity))); + let view = pool.view(); + let member = &view["members"][0]; + assert_eq!(member["capacity_rejections_total"], 1); + assert_eq!(member["queue_timeouts_total"], 1); + assert_eq!(member["active"], 1); + drop(first); + assert!(pool + .select_wait("127.0.0.1".parse().unwrap(), &headers, false) + .await + .is_ok()); +} + +#[tokio::test] +async fn zero_capacity_queue_timeout_rejects_without_counting_a_queue_timeout() { + let pool = capacity_pool(1, 0); + let first = select(&pool); + let headers = HeaderMap::new(); + let saturated = pool + .select_wait("127.0.0.1".parse().unwrap(), &headers, false) + .await; + assert!(matches!(saturated, Err(SelectionFailure::Capacity))); + let view = pool.view(); + let member = &view["members"][0]; + assert_eq!(member["capacity_rejections_total"], 1); + assert_eq!(member["queue_timeouts_total"], 0); + drop(first); +} + +#[test] +fn detailed_backend_failures_are_exposed_without_losing_aggregate_counters() { + let pool = capacity_pool(0, 0); + let selection = select(&pool); + selection.lease.error_detail( + "dns", + "lookup backend.example failed", + Duration::from_millis(7), + ); + let view = pool.view(); + let member = &view["members"][0]; + assert_eq!(member["dns_errors_total"], 1); + assert_eq!(member["connect_errors_total"], 1); + assert_eq!(member["last_failure_class"], "dns"); + assert_eq!( + member["last_failure_detail"], + "lookup backend.example failed" + ); + assert!(member["last_failure_at_ms"].as_u64().unwrap_or_default() > 0); +} +#[tokio::test] +async fn traffic_and_adaptive_selection_use_observed_cost_and_weight() { + for algorithm in [Algorithm::LeastTraffic, Algorithm::Adaptive] { + let pool = pool(algorithm, StickyMode::Off); + lock(&pool.members[0].state.traffic).rate = 50_000_000.0; + lock(&pool.members[0].state.health).ewma_us = 1_000_000; + assert_eq!(select(&pool).lease.member.definition.id, "b"); + } +} +#[tokio::test] +async fn ip_hash_is_stable_and_cannot_be_spoofed_with_forwarded_headers() { + let pool = pool(Algorithm::IpHash, StickyMode::Off); + let original = select(&pool).lease.member.definition.id.clone(); + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "10.0.0.99".parse().unwrap()); + for ip in ["127.0.0.1", "::ffff:127.0.0.1"] { + for _ in 0..8 { + assert_eq!( + pool.select(ip.parse().unwrap(), &headers, false) + .unwrap() + .lease + .member + .definition + .id, + original + ); + } + } +} + +#[tokio::test] +async fn opaque_cookie_is_removed_from_origin_requests_and_protected_on_responses() { + let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie); + let first = select(&pool); + let cookie = first.cookie.unwrap().to_str().unwrap().to_owned(); + assert!( + cookie.contains("HttpOnly") + && cookie.contains("SameSite=Lax") + && !cookie.contains("Secure") + ); + let cookie = cookie.split(';').next().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("cookie", format!("{cookie}; app=ok").parse().unwrap()); + let next = pool + .select("10.0.0.1".parse().unwrap(), &headers, false) + .unwrap(); + assert_eq!( + next.lease.member.definition.id, + first.lease.member.definition.id + ); + assert!(next.cookie.is_none()); + pool.strip_affinity(&mut headers); + assert_eq!(headers["cookie"], "app=ok"); + headers.append( + "set-cookie", + format!("{}=origin-spoof", pool.cookie_name) + .parse() + .unwrap(), + ); + headers.append("set-cookie", "app=ok".parse().unwrap()); + pool.strip_affinity_response(&mut headers); + assert_eq!(headers.get_all("set-cookie").iter().count(), 1); + assert_eq!(headers["set-cookie"], "app=ok"); + let secure = pool + .select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), true) + .unwrap(); + assert!(secure + .cookie + .unwrap() + .to_str() + .unwrap() + .contains("; Secure")); +} +#[tokio::test] +async fn failed_sticky_member_is_replaced_and_duplicate_cookies_are_ignored() { + let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie); + let first = select(&pool); + let cookie = first + .cookie + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let mut headers = HeaderMap::new(); + headers.insert("cookie", format!("{cookie}; {cookie}").parse().unwrap()); + assert!(pool.affinity(&headers).is_none()); + headers.insert("cookie", cookie.parse().unwrap()); + lock(&first.lease.member.state.health).healthy = false; + let next = pool + .select("127.0.0.1".parse().unwrap(), &headers, false) + .unwrap(); + assert_ne!( + next.lease.member.definition.id, + first.lease.member.definition.id + ); + assert!(next.cookie.is_some()); + lock(&next.lease.member.state.health).healthy = false; + assert!(pool + .select("127.0.0.1".parse().unwrap(), &headers, false) + .is_none()); +} +#[tokio::test] +async fn slow_start_ramps_weight_and_error_is_counted_once_per_exchange() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let selected = select(&pool); + let state = &selected.lease.member.state; + assert!(state.weight(100, 100) < 100.0); + assert_eq!(state.weight(100, 0), 100.0); + selected.lease.started(); + selected.lease.bytes(50, true); + selected.lease.bytes(100, false); + selected.lease.error("timeout", Duration::from_secs(1)); + selected.lease.error("body", Duration::from_secs(2)); + selected.lease.retry_attempted(); + selected.lease.retry_succeeded(); + assert_eq!(state.requests.load(Ordering::Relaxed), 1); + assert_eq!(state.errors.load(Ordering::Relaxed), 1); + assert_eq!(state.timeouts_total.load(Ordering::Relaxed), 1); + assert_eq!(state.io_errors_total.load(Ordering::Relaxed), 1); + assert_eq!(state.bytes_in.load(Ordering::Relaxed), 50); + assert_eq!(state.bytes_out.load(Ordering::Relaxed), 100); + assert_eq!(state.retry_attempts_total.load(Ordering::Relaxed), 1); + assert_eq!(state.retry_successes_total.load(Ordering::Relaxed), 1); + assert!(pool + .prometheus() + .contains("rust_proxy_backend_requests_total{vhost=\"app\",backend=\"a\"} 1")); + assert!(pool + .prometheus() + .contains("rust_proxy_backend_retry_attempts_total{vhost=\"app\",backend=\"a\"} 1")); +} +#[tokio::test] +async fn slow_5xx_counts_both_observability_classes_when_passive_is_off() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let selected = select(&pool); + selected.lease.response(500, Duration::from_secs(20), true); + let state = &selected.lease.member.state; + assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 1); + assert_eq!(state.slow_responses_total.load(Ordering::Relaxed), 1); + assert_eq!( + state.passive_health_failures_total.load(Ordering::Relaxed), + 0 + ); + assert!(state.healthy()); +} + +#[tokio::test] +async fn client_upload_duration_does_not_trigger_passive_slow_failure() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let upload = select(&pool); + upload.lease.response(200, Duration::from_secs(20), false); + assert_eq!(upload.lease.member.state.errors.load(Ordering::Relaxed), 0); + let read = select(&pool); + read.lease.response(200, Duration::from_secs(20), true); + assert_eq!(read.lease.member.state.errors.load(Ordering::Relaxed), 1); +} +#[tokio::test] +async fn backup_is_only_new_assignment_when_no_primary_is_eligible() { + let mut pool = pool(Algorithm::RoundRobin, StickyMode::Off); + Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[1]) + .unwrap() + .definition + .backup = true; + for _ in 0..8 { + assert_eq!(select(&pool).lease.member.definition.id, "a"); + } + lock(&pool.members[0].state.health).healthy = false; + assert_eq!(select(&pool).lease.member.definition.id, "b"); + lock(&pool.members[0].state.health).healthy = true; + assert_eq!(select(&pool).lease.member.definition.id, "a"); +} +#[tokio::test] +async fn drain_rejects_new_sessions_but_preserves_valid_existing_affinity() { + let mut pool = pool(Algorithm::RoundRobin, StickyMode::Cookie); + Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[0]) + .unwrap() + .definition + .drain = true; + assert_eq!(select(&pool).lease.member.definition.id, "b"); + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + format!("{}={}", pool.cookie_name, pool.members[0].token) + .parse() + .unwrap(), + ); + let existing = pool + .select("127.0.0.1".parse().unwrap(), &headers, false) + .unwrap(); + assert_eq!(existing.lease.member.definition.id, "a"); + lock(&pool.members[0].state.health).healthy = false; + assert_eq!( + pool.select("127.0.0.1".parse().unwrap(), &headers, false) + .unwrap() + .lease + .member + .definition + .id, + "b" + ); +} } diff --git a/src/cache/fill.rs b/src/cache/fill.rs index febd05e..702dde5 100644 --- a/src/cache/fill.rs +++ b/src/cache/fill.rs @@ -63,6 +63,10 @@ struct Candidate { plan: Plan, headers: HeaderMap, data: Vec, + // A transformed response with one uniquely owned Bytes frame can share its + // allocation with the downstream response, avoiding an extra Vec copy. + shared: Option, + shared_allocated: usize, fill_gauge: FillGauge, settings: Settings, skip_compression: bool, @@ -77,7 +81,7 @@ impl Candidate { if self .plan .expected_length - .is_some_and(|length| self.data.len() != length) + .is_some_and(|length| self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length) { let reason = if self.transformed { "transform_length_mismatch" @@ -112,8 +116,12 @@ impl Candidate { // Keep the actual backing capacity as the HOT charge. Vec::reserve_exact // avoids geometric multi-MiB overshoot while Bytes::from(Vec) transfers // the allocation without copying. - let body_allocation = self.data.capacity().max(self.data.len()); - let body = Bytes::from(std::mem::take(&mut self.data)); + let (body, body_allocation) = if let Some(body) = self.shared.take() { + (body, self.shared_allocated) + } else { + let allocated = self.data.capacity().max(self.data.len()); + (Bytes::from(std::mem::take(&mut self.data)), allocated) + }; let scope = self.ticket.scope.clone(); let scheme = self.ticket.scheme.clone(); let authority = self.ticket.authority.clone(); @@ -192,8 +200,51 @@ impl HttpBody for FillBody { let this = self.get_mut(); match Pin::new(&mut this.inner).poll_frame(cx) { Poll::Ready(Some(Ok(frame))) => { + let mut frame = frame; + // Only the transformed single-frame case is eligible. Taking + // unique ownership lets Bytes reveal its true allocation + // capacity; we must never undercharge an arbitrary shared + // Bytes slice that could retain a much larger parent buffer. + if this.inner.is_end_stream() { + if let Some(candidate) = &mut this.candidate { + let single_frame = candidate.transformed + && candidate.data.is_empty() + && candidate.shared.is_none() + && frame.data_ref().is_some_and(|data| { + candidate.plan.expected_length == Some(data.len()) + && data.len() <= candidate.settings.max_object_bytes + }); + if single_frame { + match frame.into_data() { + Ok(bytes) => { + match bytes.try_into_mut() { + Ok(mutable) => { + let allocation = mutable.capacity(); + let bytes = mutable.freeze(); + // A very oversized backing Vec is cheaper to + // copy into a smaller HOT allocation instead. + let acceptable_spare = bytes.len().saturating_div(8).min(64 * 1024); + if allocation <= bytes.len().saturating_add(acceptable_spare) + && allocation <= candidate.settings.max_object_bytes + && candidate.fill_gauge.grow(allocation, &candidate.settings) + { + candidate.shared = Some(bytes.clone()); + candidate.shared_allocated = allocation; + } + frame = Frame::data(bytes); + } + Err(bytes) => frame = Frame::data(bytes), + } + } + Err(original) => frame = original, + } + } + } + } if let Some(candidate) = &mut this.candidate { - if let Some(data) = frame.data_ref() { + if candidate.shared.is_some() { + // The cache now owns the very same backing allocation. + } else if let Some(data) = frame.data_ref() { let needed = candidate.data.len().saturating_add(data.len()); if needed <= candidate.settings.max_object_bytes { let before = candidate.data.capacity(); diff --git a/src/cache/lookup/tiered.rs b/src/cache/lookup/tiered.rs index e0e6e09..12de7f6 100644 --- a/src/cache/lookup/tiered.rs +++ b/src/cache/lookup/tiered.rs @@ -170,7 +170,10 @@ impl Cache { Some(validators) => validators.last_modified.clone(), None => headers.get("last-modified").cloned(), }; - let initial_capacity = fill_size.min(64 * 1024); + let transformed = parts.extensions.get::().is_some(); + // A transformed, fully buffered body normally arrives as one owned + // frame. Do not allocate a second fill buffer before inspecting it. + let initial_capacity = if transformed { 0 } else { fill_size.min(64 * 1024) }; let Some(mut fill_gauge) = FillGauge::new(self.clone(), initial_capacity, settings) else { self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed); self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure"); @@ -191,15 +194,14 @@ impl Cache { plan, headers, data, + shared: None, + shared_allocated: 0, fill_gauge, settings: settings.clone(), skip_compression: parts.extensions.get::().is_some(), origin_etag, origin_last_modified, - transformed: parts - .extensions - .get::() - .is_some(), + transformed, _permit: permit, _fill_leader: fill_leader, }; diff --git a/src/cache/tests/cold_tier_tests.rs b/src/cache/tests/cold_tier_tests.rs index e170338..506070d 100644 --- a/src/cache/tests/cold_tier_tests.rs +++ b/src/cache/tests/cold_tier_tests.rs @@ -1,4 +1,746 @@ mod cold_tier_tests { - include!("cold_tier_tests/part_01.rs"); - include!("cold_tier_tests/part_02.rs"); +use super::*; +use http_body_util::BodyExt; + +fn cold_settings() -> Settings { + Settings { + enabled: true, + max_entries: 1, + max_bytes: 256 * 1024, + max_object_bytes: 64 * 1024, + cold_enabled: true, + cold_path: "cache/cold-test".into(), + cold_max_bytes: 2 * 1024 * 1024, + cold_max_entries: 16, + cold_after_seconds: 300, + ..Settings::default() + } +} + +async fn fill(cache: &Arc, settings: &Settings, path: &str, body: &'static str) -> Ticket { + let ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + path, + &HeaderMap::new(), + ) + .unwrap(); + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120"), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"cold-v1\"")); + let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap(); + cache + .wrap(response, ticket.clone(), plan, settings) + .into_body() + .collect() + .await + .unwrap(); + ticket +} + +async fn wait_for_cold(cache: &Cache, expected: usize) { + for _ in 0..100 { + if cache.view().cold_entries >= expected { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("cold tier did not persist in time"); +} + +#[tokio::test] +async fn selective_purge_removes_matching_cold_object() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + let first = fill(&cache, &settings, "/one?v=1", "first-payload").await; + let _second = fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().cold_entries, 1); + let result = cache + .invalidate(InvalidationRequest { + operation: InvalidationOperation::Purge, + selector: InvalidationSelector::Exact, + value: "/one?v=1".into(), + host: Some("cold.example".into()), + vhost: Some("cold-test".into()), + ttl_seconds: None, + confirm_wide: false, + }) + .unwrap(); + assert_eq!(result.affected_entries, 1); + assert_eq!(cache.view().cold_entries, 0); + assert!(cache.get_tiered(&first, &settings).await.is_none()); + shutdown.cancel(); +} + +#[tokio::test] +async fn lru_demotion_persists_and_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + settings.validate().unwrap(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + let first = fill(&cache, &settings, "/one", "first-payload").await; + let _second = fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().cold_entries, 1); + let cold = cache.dashboard(Some("cold-test"), 0, 50); + assert!(cold + .objects + .iter() + .any(|row| row.path == "/one" && row.tier == "cold")); + + shutdown.cancel(); + drop(cache); + + let restart_shutdown = CancellationToken::new(); + let restarted = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart_shutdown.clone(), + ); + assert_eq!(restarted.view().cold_entries, 1); + let hit = restarted + .get_tiered(&first, &settings) + .await + .expect("cold object should be readable after restart"); + let payload = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(payload, Bytes::from_static(b"first-payload")); + assert_eq!(restarted.view().cold_hits, 1); + assert_eq!(restarted.view().cold_promotions, 1); + restart_shutdown.cancel(); +} + +#[tokio::test] +async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + + let transformed_ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + "/transformed.json", + &HeaderMap::new(), + ) + .unwrap(); + let mut transformed = Response::new(Body::from(r#"{"a":1}"#)); + transformed.headers_mut().insert( + "cache-control", + HeaderValue::from_static( + "public, max-age=120, stale-while-revalidate=30, stale-if-error=90", + ), + ); + transformed.headers_mut().insert( + "etag", + HeaderValue::from_static("\"rustproxy-representation\""), + ); + transformed + .headers_mut() + .insert("content-length", HeaderValue::from_static("7")); + transformed + .extensions_mut() + .insert(crate::transform::OriginValidators { + etag: Some(HeaderValue::from_static("\"origin-v1\"")), + last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")), + }); + transformed + .extensions_mut() + .insert(crate::transform::Applied { + kind: crate::transform::Kind::Json, + original_bytes: 9, + transformed_bytes: 7, + }); + let transformed_plan = plan( + StatusCode::OK, + transformed.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap( + transformed, + transformed_ticket.clone(), + transformed_plan, + &settings, + ) + .into_body() + .collect() + .await + .unwrap(); + + let protected_ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + "/protected.css", + &HeaderMap::new(), + ) + .unwrap(); + let original_body = "body { color: red; }"; + let mut protected = Response::new(Body::from(original_body)); + protected.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120, immutable, no-transform"), + ); + protected + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin-css\"")); + protected.extensions_mut().insert(crate::compression::Skip); + let protected_plan = plan( + StatusCode::OK, + protected.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap( + protected, + protected_ticket.clone(), + protected_plan, + &settings, + ) + .into_body() + .collect() + .await + .unwrap(); + + assert_eq!(cache.flush_hot_to_cold().await, 2); + wait_for_cold(&cache, 2).await; + shutdown.cancel(); + drop(cache); + + let restart_shutdown = CancellationToken::new(); + let restarted = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart_shutdown.clone(), + ); + assert_eq!(restarted.view().cold_entries, 2); + // COLD entries intentionally keep validators and headers on disk to bound RAM. + // Inspect them through the same verified metadata loader used on COLD hits. + let (key, transformed) = { + let cold = restarted.cold.lock().unwrap(); + cold.entries + .iter() + .find(|(_, entry)| entry.path == "/transformed.json") + .map(|(key, entry)| (*key, entry.clone())) + .expect("transformed cold entry") + }; + assert_eq!(transformed.stale_while_revalidate, 30); + assert_eq!(transformed.stale_if_error, 90); + assert!(transformed.has_validator); + assert!(transformed.transformed); + let metadata = read_cold_metadata(&key, &transformed) + .await + .expect("persisted transformed metadata"); + assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\""); + assert_eq!( + metadata.origin_last_modified.as_ref().unwrap(), + "Mon, 26 Jan 2026 23:11:40 GMT" + ); + assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\""); + + let transformed_hit = restarted + .get_tiered(&transformed_ticket, &settings) + .await + .expect("transformed object after restart"); + assert_eq!( + transformed_hit.response.headers()["etag"], + "\"rustproxy-representation\"" + ); + assert!(transformed_hit.transformed); + assert!(transformed_hit + .response + .extensions() + .get::() + .is_some()); + assert_eq!( + transformed_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(br#"{"a":1}"#) + ); + + let protected_hit = restarted + .get_tiered(&protected_ticket, &settings) + .await + .expect("no-transform object after restart"); + assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\""); + assert!(crate::transform::no_transform( + protected_hit.response.headers() + )); + assert!(protected_hit + .response + .extensions() + .get::() + .is_some()); + assert_eq!( + protected_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(b"body { color: red; }") + ); + restart_shutdown.cancel(); +} + +#[tokio::test] +async fn cold_policy_can_disable_lru_demotion() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.cold_on_lru = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + tokio::time::sleep(Duration::from_millis(30)).await; + let view = cache.view(); + assert_eq!(view.entries, 1); + assert_eq!(view.cold_entries, 0); + assert_eq!(view.evictions, 1); + shutdown.cancel(); +} + +#[tokio::test] +async fn cold_policy_can_disable_idle_demotion() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_idle = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/idle", "idle-payload").await; + { + let mut store = cache.store.lock().unwrap(); + for entry in store.entries.values_mut() { + entry.last_access = Instant::now() - Duration::from_secs(600); + } + } + cache.demote_idle(); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().cold_entries, 0); + + settings.cold_on_idle = true; + cache.configure(settings.clone()); + cache.demote_idle(); + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().entries, 0); + shutdown.cancel(); +} + +#[tokio::test] +async fn cold_policy_can_demote_by_total_hot_residency_age() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_idle = false; + settings.cold_on_age = true; + settings.cold_max_hot_seconds = 5; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/aged", "aged-payload").await; + { + let mut store = cache.store.lock().unwrap(); + for entry in store.entries.values_mut() { + entry.resident_since = Instant::now() - Duration::from_secs(60); + entry.last_access = Instant::now(); + } + } + cache.demote_idle(); + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().entries, 0); + assert_eq!(cache.view().cold_entries, 1); + shutdown.cancel(); +} + +#[tokio::test] +async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + assert_eq!(cache.view().entries, 2); + assert!(cache.view().resident_bytes > 0); + assert_eq!(cache.view().cold_entries, 0); + + assert_eq!(cache.flush_hot_to_cold().await, 2); + let view = cache.view(); + assert_eq!(view.entries, 0); + assert_eq!(view.resident_bytes, 0); + assert_eq!(view.hot_logical_bytes, 0); + assert_eq!( + view.transient_bytes, 0, + "persisted ColdEntry must not retain the body allocation" + ); + assert_eq!(view.cold_entries, 2); + shutdown.cancel(); + + let restart_shutdown = CancellationToken::new(); + let restarted = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart_shutdown.clone(), + ); + assert_eq!(restarted.view().cold_entries, 2); + restart_shutdown.cancel(); +} + +#[tokio::test] +async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_shutdown = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/update", "update-payload").await; + assert_eq!(cache.flush_hot_to_cold().await, 0); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.flush_hot_to_cold_for_update().await, 1); + let view = cache.view(); + assert_eq!(view.entries, 0); + assert_eq!(view.resident_bytes, 0); + assert_eq!(view.cold_entries, 1); + shutdown.cancel(); +} + +#[tokio::test] +async fn graceful_shutdown_flush_can_be_disabled() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_shutdown = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + assert_eq!(cache.flush_hot_to_cold().await, 0); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().cold_entries, 0); + shutdown.cancel(); +} + +#[tokio::test] +async fn cold_cache_uses_two_hex_character_shards() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + let root = dir.path().join(&settings.cold_path); + let mut shards = Vec::new(); + for item in std::fs::read_dir(&root).unwrap() { + let item = item.unwrap(); + let name = item.file_name().to_string_lossy().to_string(); + assert!( + item.path().is_dir(), + "cold root must contain shard directories only" + ); + assert_eq!(name.len(), 2); + assert!(name.bytes().all(|b| b.is_ascii_hexdigit())); + shards.push(item.path()); + } + assert!(!shards.is_empty()); + assert!(shards.iter().any(|shard| { + std::fs::read_dir(shard) + .unwrap() + .filter_map(|entry| entry.ok()) + .any(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + name.ends_with(".json") || name.ends_with(".body") + }) + })); + shutdown.cancel(); +} + +#[tokio::test] +async fn legacy_flat_cold_cache_is_not_migrated() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let root = dir.path().join(&settings.cold_path); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("legacy.body"), b"old").unwrap(); + std::fs::write(root.join("legacy.json"), b"{}").unwrap(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings, + shutdown.clone(), + ); + assert_eq!(cache.view().cold_entries, 0); + assert!(!root.join("legacy.body").exists()); + assert!(!root.join("legacy.json").exists()); + shutdown.cancel(); +} + +#[tokio::test] +async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 1; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + fill(&cache, &settings, "/three", "third-payload").await; + wait_for_cold(&cache, 2).await; + let job = cache + .start_cold_cleanup(ColdCleanupRequest { + unused_for_seconds: None, + oldest: Some(1), + hits_zero: false, + }) + .unwrap(); + for _ in 0..100 { + let current = cache.cold_cleanup_job(job.id).unwrap(); + if current.status == "done" { + assert_eq!(current.removed_entries, 1); + assert!(current.freed_bytes > 0); + assert_eq!(cache.view().cold_entries, 1); + shutdown.cancel(); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("cold cleanup did not finish"); +} + +#[tokio::test] +async fn manual_cold_cleanup_is_single_flight() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings, + shutdown.clone(), + ); + let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap(); + let result = cache.start_cold_cleanup(ColdCleanupRequest { + unused_for_seconds: None, + oldest: Some(1), + hits_zero: false, + }); + assert!(result.is_err()); + shutdown.cancel(); +} + +#[tokio::test] +async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 1; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + + let key = { + let mut cold = cache.cold.lock().unwrap(); + let key = *cold.entries.keys().next().unwrap(); + cold.entries.get_mut(&key).unwrap().pinned_reads = 1; + key + }; + let request = ColdCleanupRequest { + unused_for_seconds: None, + oldest: Some(1), + hits_zero: false, + }; + let result = cache + .remove_cold_candidates(Some(&request), None, None, 1) + .await; + assert_eq!(result.removed_entries, 0); + assert!(cache.cold.lock().unwrap().entries.contains_key(&key)); + + cache + .cold + .lock() + .unwrap() + .entries + .get_mut(&key) + .unwrap() + .pinned_reads = 0; + let result = cache + .remove_cold_candidates(Some(&request), None, None, 1) + .await; + assert_eq!(result.removed_entries, 1); + assert!(!cache.cold.lock().unwrap().entries.contains_key(&key)); + shutdown.cancel(); +} + +#[test] +fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() { + let cache = Cache::new(1); + let key = [7_u8; 32]; + let template = ColdEntry { + base_key: key, + vary: Vec::new(), + revision: 1, + scope: "app".to_owned(), + scheme: "https".to_owned(), + authority: "example.test".to_owned(), + domain: "example.test".to_owned(), + path: "/asset".to_owned(), + target: "https://example.test/asset".to_owned(), + tags: Vec::new(), + created_at: SystemTime::now(), + body_path: PathBuf::from("old.body"), + meta_path: PathBuf::from("old.json"), + body_bytes: 1, + body_sha256: [0u8; 32], + disk_bytes: 2, + stored_at: SystemTime::now(), + last_access: SystemTime::now(), + age_at_store_seconds: 0, + resident_age_at_store_seconds: 0, + ttl: 60, + stale_while_revalidate: 0, + stale_if_error: 0, + must_revalidate: false, + proxy_revalidate: false, + has_validator: false, + hits: 0, + transformed: false, + sequence: 1, + generation: 1, + skip_compression: false, + requires_revalidation: false, + pinned_reads: 1, + }; + cache + .cold + .lock() + .unwrap() + .entries + .insert(key, template.clone()); + let old_guard = ColdReadGuard { + cache: &cache, + key, + generation: 1, + }; + + let mut replacement = template; + replacement.sequence = 2; + replacement.generation = 2; + replacement.pinned_reads = 1; + cache.cold.lock().unwrap().entries.insert(key, replacement); + drop(old_guard); + + let cold = cache.cold.lock().unwrap(); + let replacement = cold.entries.get(&key).unwrap(); + assert_eq!(replacement.generation, 2); + assert_eq!(replacement.pinned_reads, 1); +} + +#[test] +fn cold_path_cannot_escape_data_directory() { + let mut settings = Settings::default(); + settings.cold_path = "../outside".into(); + assert!(settings.validate().is_err()); + settings.cold_path = "/tmp/outside".into(); + assert!(settings.validate().is_err()); + settings.cold_path = "cache/cold".into(); + assert!(settings.validate().is_ok()); +} } diff --git a/src/cache/tests/tests.rs b/src/cache/tests/tests.rs index fe46cbd..8883aff 100644 --- a/src/cache/tests/tests.rs +++ b/src/cache/tests/tests.rs @@ -1,6 +1,1482 @@ mod tests { - include!("tests/part_01.rs"); - include!("tests/part_02.rs"); - include!("tests/part_03.rs"); - include!("tests/part_04.rs"); + use super::*; + use http_body_util::BodyExt; + fn headers(value: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert("cache-control", value.parse().unwrap()); + h + } + fn settings() -> Settings { + Settings { + enabled: true, + ..Default::default() + } + } + fn ticket(cache: &Cache, headers: &HeaderMap) -> Ticket { + cache + .ticket(1, "http", "example.test", "app", "/", headers) + .unwrap() + } + #[test] + fn ttl_safety_and_shared_directives() { + let s = settings(); + for control in [ + "private, max-age=60", + "no-store, max-age=60", + "max-age=0", + "max-age=invalid", + "max-age=60,max-age=90", + "public", + "must-understand, max-age=60", + "max-age=\"60", + "max-age=60\"", + "public=invalid,max-age=60", + ] { + assert!( + plan(StatusCode::OK, &headers(control), &s, Duration::ZERO).is_none(), + "{control}" + ); + } + assert!(plan( + StatusCode::OK, + &headers("public,max-age=60"), + &s, + Duration::ZERO + ) + .is_some()); + let mut revalidate_headers = headers("no-cache, max-age=60"); + revalidate_headers.insert("etag", HeaderValue::from_static("\"validator\"")); + let revalidate = plan(StatusCode::OK, &revalidate_headers, &s, Duration::ZERO) + .expect("no-cache with a validator permits storage for validation"); + assert!(revalidate.requires_revalidation); + assert!(plan(StatusCode::OK, &headers("no-cache"), &s, Duration::ZERO).is_none()); + let mut validator_only = headers("no-cache"); + validator_only.insert("etag", HeaderValue::from_static("\"validator\"")); + assert!(plan(StatusCode::OK, &validator_only, &s, Duration::ZERO).is_some()); + assert!(plan( + StatusCode::OK, + &headers("max-age=60,s-maxage=0"), + &s, + Duration::ZERO + ) + .is_none()); + let shared = plan( + StatusCode::OK, + &headers("public, max-age=600, s-maxage=30"), + &s, + Duration::ZERO, + ) + .unwrap(); + assert_eq!(shared.ttl, 30); + let mut h = headers("max-age=60"); + h.insert("age", "61".parse().unwrap()); + assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some()); + h.remove("age"); + h.insert("set-cookie", "session=secret".parse().unwrap()); + assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_none()); + } + #[test] + fn request_policy_uses_conditional_cache_and_revalidation() { + for name in [ + "authorization", + "cookie", + "if-match", + "if-unmodified-since", + "upgrade", + ] { + let mut h = HeaderMap::new(); + h.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + "value".parse().unwrap(), + ); + assert!( + !request_cacheable(&Method::GET, &h, true, "/", false, &settings()), + "{name}" + ); + } + for name in ["if-none-match", "if-modified-since"] { + let mut h = HeaderMap::new(); + h.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + "value".parse().unwrap(), + ); + assert!( + request_cacheable(&Method::GET, &h, true, "/", false, &settings()), + "{name}" + ); + } + for (value, reason) in [ + ("no-cache", "request_no_cache"), + ("max-age=0", "request_max_age"), + ] { + let h = headers(value); + assert_eq!( + request_policy(&Method::GET, &h, true, "/", false, &settings()), + RequestPolicy::Revalidate(reason) + ); + } + let min_fresh = headers("min-fresh=1"); + assert_eq!( + request_policy(&Method::GET, &min_fresh, true, "/", false, &settings()), + RequestPolicy::Use + ); + let immutable = headers("immutable"); + assert_eq!( + request_policy(&Method::GET, &immutable, true, "/", false, &settings()), + RequestPolicy::Use + ); + let h = headers("no-store"); + assert_eq!( + request_policy(&Method::GET, &h, true, "/", false, &settings()), + RequestPolicy::Bypass("request_no_store") + ); + let mut pragma = HeaderMap::new(); + pragma.insert("pragma", HeaderValue::from_static("no-cache")); + assert_eq!( + request_policy(&Method::GET, &pragma, true, "/", false, &settings()), + RequestPolicy::Revalidate("request_pragma_no_cache") + ); + assert!(!request_cacheable( + &Method::POST, + &HeaderMap::new(), + true, + "/", + false, + &settings() + )); + assert!(!request_cacheable( + &Method::GET, + &HeaderMap::new(), + false, + "/", + false, + &settings() + )); + assert!(request_cacheable( + &Method::GET, + &HeaderMap::new(), + true, + "/", + false, + &settings() + )); + assert!(request_cacheable( + &Method::HEAD, + &HeaderMap::new(), + true, + "/", + false, + &settings() + )); + let mut single_range = HeaderMap::new(); + single_range.insert("range", HeaderValue::from_static("bytes=0-3")); + assert_eq!( + request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()), + RequestPolicy::Use + ); + assert!(request_cacheable( + &Method::GET, + &single_range, + true, + "/asset.bin", + false, + &settings() + )); + single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9")); + assert_eq!( + request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()), + RequestPolicy::Bypass("request_range_unsupported") + ); + } + #[test] + fn rfc5861_windows_and_revalidation_flags_are_separate_metadata() { + let s = settings(); + let plan = plan( + StatusCode::OK, + &headers("public, max-age=60, stale-while-revalidate=30, stale-if-error=90, must-revalidate, proxy-revalidate"), + &s, + Duration::ZERO, + ) + .expect("revalidation flags must not prevent storage"); + assert_eq!(plan.ttl, 60); + assert_eq!(plan.stale_while_revalidate, 30); + assert_eq!(plan.stale_if_error, 90); + assert!(plan.must_revalidate); + assert!(plan.proxy_revalidate); + } + + #[test] + fn request_max_stale_is_bounded_and_respects_revalidation_flags() { + let unbounded = headers("max-stale"); + let bounded = headers("max-stale=15"); + assert_eq!(stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false)); + assert_eq!(stale_mode(70, 60, 0, false, false, false, &bounded), Some(false)); + assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None); + assert_eq!(stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true)); + assert_eq!(stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None); + assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None); + assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None); + } + + #[test] + fn cache_state_distinguishes_fresh_stale_and_expired() { + let none = HeaderMap::new(); + assert_eq!( + cache_state(30, 60, 20, false, false, false, &none), + CacheState::Fresh + ); + assert_eq!( + cache_state(70, 60, 20, false, false, false, &none), + CacheState::StaleButServable { + background_revalidate: true + } + ); + assert_eq!( + cache_state(90, 60, 20, false, false, false, &none), + CacheState::Expired + ); + } + + #[test] + fn stale_if_error_is_only_available_inside_its_window() { + let base = Revalidation { + headers: HeaderMap::new(), + body: Bytes::from_static(b"body"), + body_allocated: 4, + _transient_leases: Vec::new(), + hits: 0, + transformed: false, + storage_key: [0; 32], + resident_age_at_lookup: Duration::ZERO, + skip_compression: false, + reusable_without_validation: false, + age: 75, + ttl: 60, + stale_if_error: 30, + must_revalidate: false, + proxy_revalidate: false, + origin_etag: None, + origin_last_modified: None, + }; + assert!(base.can_stale_if_error()); + assert!(!Revalidation { age: 91, ..base.clone() }.can_stale_if_error()); + assert!(!Revalidation { must_revalidate: true, ..base.clone() }.can_stale_if_error()); + assert!(!Revalidation { proxy_revalidate: true, ..base }.can_stale_if_error()); + } + + #[test] + fn freshness_expiry_does_not_force_physical_eviction() { + assert_eq!(retention_deadline(60, 0, 0, false), u64::MAX); + assert_eq!(retention_deadline(60, 30, 0, false), u64::MAX); + assert_eq!(retention_deadline(60, 0, 45, false), u64::MAX); + assert_eq!(retention_deadline(60, 0, 0, true), u64::MAX); + } + + #[test] + fn request_max_age_only_revalidates_when_cached_age_exceeds_limit() { + let fresh = headers("max-age=30"); + assert!(request_freshness_allows(10, 60, &fresh)); + assert!(!request_freshness_allows(31, 60, &fresh)); + assert_eq!( + request_policy(&Method::GET, &fresh, true, "/", false, &settings()), + RequestPolicy::Use + ); + let zero = headers("max-age=0"); + assert_eq!( + request_policy(&Method::GET, &zero, true, "/", false, &settings()), + RequestPolicy::Revalidate("request_max_age") + ); + let constrained_stale = headers("max-age=30, max-stale"); + assert_eq!( + stale_mode(70, 60, 0, false, false, false, &constrained_stale), + None + ); + } + + #[test] + fn duplicate_and_argument_immutable_do_not_invalidate_cache_control() { + let duplicate = headers("public, max-age=60, immutable, immutable"); + assert!(plan(StatusCode::OK, &duplicate, &settings(), Duration::ZERO).is_some()); + let argument = headers("public, max-age=60, immutable=ignored"); + assert!(plan(StatusCode::OK, &argument, &settings(), Duration::ZERO).is_some()); + } + + #[test] + fn no_transform_does_not_disable_shared_storage() { + let protected = headers("public, max-age=60, no-transform"); + assert!(plan(StatusCode::OK, &protected, &settings(), Duration::ZERO).is_some()); + } + + #[test] + fn transformed_plan_uses_transformed_content_length() { + let s = settings(); + let origin = plan( + StatusCode::OK, + &{ + let mut h = headers("public, max-age=60"); + h.insert("content-length", HeaderValue::from_static("95")); + h + }, + &s, + Duration::ZERO, + ) + .unwrap(); + let transformed = plan( + StatusCode::OK, + &{ + let mut h = headers("public, max-age=60"); + h.insert("content-length", HeaderValue::from_static("76")); + h + }, + &s, + Duration::ZERO, + ) + .unwrap(); + assert_eq!(origin.restrict(transformed).expected_length, Some(76)); + } + + #[tokio::test] + async fn transformed_representation_is_stored_and_reused() { + let cache = Cache::new(1); + let cache_settings = settings(); + let request_headers = HeaderMap::new(); + let ticket = ticket(&cache, &request_headers); + let origin_body = br#"{ "message": "hello" }"#; + let mut response = Response::new(Body::from(origin_body.as_slice())); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&origin_body.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + let origin_plan = plan( + StatusCode::OK, + response.headers(), + &cache_settings, + Duration::ZERO, + ) + .unwrap(); + let transform_settings = crate::transform::Settings { + enabled: true, + json: true, + ..Default::default() + }; + let transformed = crate::transform::apply( + response, + "/data.json", + &transform_settings, + false, + ) + .await + .unwrap(); + let transformed_plan = plan( + StatusCode::OK, + transformed.headers(), + &cache_settings, + Duration::ZERO, + ) + .unwrap(); + let expected_etag = transformed.headers()["etag"].clone(); + let wrapped = cache.wrap( + transformed, + ticket.clone(), + origin_plan.restrict(transformed_plan), + &cache_settings, + ); + let first = wrapped.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&first[..], br#"{"message":"hello"}"#); + + let hit = cache.get(&ticket).expect("transformed representation should be cached"); + assert_eq!(hit.response.headers()["etag"], expected_etag); + assert_eq!( + hit.response.headers()["content-length"].to_str().unwrap(), + first.len().to_string() + ); + let second = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(second, first); + } + + #[tokio::test] + async fn every_enabled_transform_kind_can_fill_and_reuse_the_cached_representation() { + let cases: [(&str, &str, &[u8], &str); 5] = [ + ( + "/app.js", + "application/javascript", + &b"const value = 1;\n"[..], + "javascript", + ), + ( + "/app.css", + "text/css", + &b"body { color: red; }\n"[..], + "css", + ), + ( + "/page.html", + "text/html", + &b"
x
"[..], + "html", + ), + ( + "/icon.svg", + "image/svg+xml", + &b""[..], + "svg", + ), + ( + "/data.json", + "application/json", + &br#"{ "a": 1 }"#[..], + "json", + ), + ]; + + for (path, content_type, origin_body, kind) in cases { + let cache = Cache::new(1); + let cache_settings = settings(); + let request_headers = HeaderMap::new(); + let ticket = cache + .ticket(1, "http", "example.test", "app", path, &request_headers) + .unwrap(); + let mut response = Response::new(Body::from(origin_body)); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + response.headers_mut().insert( + "content-type", + HeaderValue::from_str(content_type).unwrap(), + ); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&origin_body.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + let origin_plan = plan( + StatusCode::OK, + response.headers(), + &cache_settings, + Duration::ZERO, + ) + .unwrap(); + + let mut transform_settings = crate::transform::Settings { + enabled: true, + javascript: false, + css: false, + html: false, + svg: false, + json: false, + ..Default::default() + }; + match kind { + "javascript" => transform_settings.javascript = true, + "css" => transform_settings.css = true, + "html" => transform_settings.html = true, + "svg" => transform_settings.svg = true, + "json" => transform_settings.json = true, + _ => unreachable!(), + } + let transformed = crate::transform::apply( + response, + path, + &transform_settings, + false, + ) + .await + .unwrap(); + let transformed_plan = plan( + StatusCode::OK, + transformed.headers(), + &cache_settings, + Duration::ZERO, + ) + .unwrap(); + let expected_etag = transformed.headers()["etag"].clone(); + let expected_length = transformed.headers()["content-length"].clone(); + let wrapped = cache.wrap( + transformed, + ticket.clone(), + origin_plan.restrict(transformed_plan), + &cache_settings, + ); + let first = wrapped.into_body().collect().await.unwrap().to_bytes(); + assert!(!first.is_empty(), "{kind}"); + assert_ne!(first.as_ref(), origin_body, "{kind} should change the chosen fixture"); + + let hit = cache.get(&ticket).expect("transformed representation should be cached"); + assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}"); + assert_eq!( + hit.response.headers()["content-length"], + expected_length, + "{kind}" + ); + let second = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(second, first, "{kind}"); + } + } + + #[test] + fn conditional_hit_uses_weak_etag_and_last_modified() { + let mut response = HeaderMap::new(); + response.insert("etag", HeaderValue::from_static("W/\"abc\"")); + response.insert( + "last-modified", + HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"), + ); + let mut request = HeaderMap::new(); + request.insert("if-none-match", HeaderValue::from_static("\"abc\"")); + assert!(conditional_not_modified(&request, &response)); + request.insert("if-none-match", HeaderValue::from_static("\"other\"")); + assert!(!conditional_not_modified(&request, &response)); + request.remove("if-none-match"); + request.insert( + "if-modified-since", + HeaderValue::from_static("Tue, 27 Jan 2026 00:00:00 GMT"), + ); + assert!(conditional_not_modified(&request, &response)); + } + #[test] + fn query_strings_remain_distinct_and_bypass_query_is_only_a_request_policy() { + let cache = Cache::new(1); + let headers = HeaderMap::new(); + let v1 = cache + .ticket(1, "http", "example.test", "app", "/file.css?v=1", &headers) + .unwrap(); + let v2 = cache + .ticket(1, "http", "example.test", "app", "/file.css?v=2", &headers) + .unwrap(); + let flag = cache + .ticket(1, "http", "example.test", "app", "/file.css?F", &headers) + .unwrap(); + assert_ne!(v1.key, v2.key); + assert_ne!(v1.key, flag.key); + assert_ne!(v2.key, flag.key); + + let mut cache_settings = settings(); + assert_eq!( + request_policy( + &Method::GET, + &headers, + true, + "/file.css", + true, + &cache_settings, + ), + RequestPolicy::Use + ); + cache_settings.bypass_query = true; + assert_eq!( + request_policy( + &Method::GET, + &headers, + true, + "/file.css", + true, + &cache_settings, + ), + RequestPolicy::Bypass("query_excluded") + ); + } + + #[tokio::test] + async fn transformed_client_conditionals_use_the_representation_etag_not_the_origin_etag() { + let transform_settings = crate::transform::Settings { + enabled: true, + json: true, + ..Default::default() + }; + let body = br#"{ "a": 1 }"#; + let mut response = Response::new(Body::from(body.as_slice())); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&body.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + let response = crate::transform::apply(response, "/data.json", &transform_settings, false) + .await + .unwrap(); + let representation_etag = response.headers()["etag"].clone(); + assert_ne!(representation_etag, HeaderValue::from_static("\"origin\"")); + + let mut request = HeaderMap::new(); + request.insert("if-none-match", representation_etag.clone()); + assert!(conditional_not_modified(&request, response.headers())); + request.insert("if-none-match", HeaderValue::from_static("\"origin\"")); + assert!(!conditional_not_modified(&request, response.headers())); + + let validators = response + .extensions() + .get::() + .unwrap(); + assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\""); + } + + #[test] + fn cache_key_separates_frontend_scheme() { + let cache = Cache::new(1); + let headers = HeaderMap::new(); + let http = cache + .ticket(1, "http", "example.test", "app", "/asset", &headers) + .unwrap(); + let https = cache + .ticket(1, "https", "example.test", "app", "/asset", &headers) + .unwrap(); + assert_ne!(http.key, https.key); + } + + #[test] + fn cache_key_ignores_revalidation_headers() { + let cache = Cache::new(1); + let base = ticket(&cache, &HeaderMap::new()); + for (name, value) in [ + ("cache-control", "no-cache"), + ("pragma", "no-cache"), + ("if-none-match", "\"origin\""), + ("if-modified-since", "Mon, 26 Jan 2026 23:11:40 GMT"), + ] { + let mut headers = HeaderMap::new(); + headers.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + assert_eq!(base.key, ticket(&cache, &headers).key, "{name}"); + } + } + #[test] + fn cache_memory_defaults_are_bounded_and_sane() { + let s = Settings::default(); + assert_eq!(s.memory_preset, None); + assert_eq!(s.cold_preset, None); + assert_eq!(s.max_bytes, 256 * 1024 * 1024); + assert_eq!(s.max_object_bytes, 16 * 1024 * 1024); + assert_eq!(s.transient_max_bytes, 64 * 1024 * 1024); + assert_eq!(s.cold_queue_max_bytes, 32 * 1024 * 1024); + assert_eq!(s.hot_eviction_max_objects, 64); + assert!(s.validate().is_ok()); + assert!(Cache::fill_headroom_bytes(8 * 1024 * 1024) <= 128 * 1024); + assert!(Cache::fill_headroom_bytes(s.max_bytes) <= 1024 * 1024); + } + + #[test] + fn cache_preset_intent_round_trips_with_configuration() { + let mut settings = Settings::default(); + settings.memory_preset = Some(MemoryPreset::Recommended); + settings.cold_preset = Some(ColdPreset::Recommended); + let encoded = serde_json::to_value(&settings).unwrap(); + assert_eq!(encoded["memory_preset"], "recommended"); + assert_eq!(encoded["cold_preset"], "recommended"); + let decoded: Settings = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.memory_preset, Some(MemoryPreset::Recommended)); + assert_eq!(decoded.cold_preset, Some(ColdPreset::Recommended)); + } + + #[test] + fn transient_budget_is_hard_and_released_with_lease() { + let cache = Cache::new(1); + let mut s = Settings::default(); + s.transient_max_bytes = 128 * 1024; + s.cold_queue_max_bytes = 64 * 1024; + let first = cache.try_reserve_transient(96 * 1024, &s).expect("first reservation"); + assert!(cache.try_reserve_transient(40 * 1024, &s).is_none()); + assert_eq!(cache.view().transient_bytes, 96 * 1024); + drop(first); + assert_eq!(cache.view().transient_bytes, 0); + assert!(cache.try_reserve_transient(128 * 1024, &s).is_some()); + } + + #[test] + fn cold_queue_budget_cannot_exceed_transient_budget() { + let mut s = Settings::default(); + s.transient_max_bytes = 8 * 1024 * 1024; + s.cold_queue_max_bytes = 16 * 1024 * 1024; + assert!(s.validate().is_err()); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn cache_limits_allow_explicit_multi_gibibyte_budgets() { + let mut s = Settings::default(); + s.max_bytes = 4usize * 1024 * 1024 * 1024; + s.max_object_bytes = 2usize * 1024 * 1024 * 1024; + s.transient_max_bytes = 512usize * 1024 * 1024; + s.cold_queue_max_bytes = 256usize * 1024 * 1024; + assert!(s.validate().is_ok()); + s.max_object_bytes = s.max_bytes.saturating_add(1); + assert!(s.validate().is_err()); + } + #[test] + fn revalidation_only_reuses_matching_client_validator() { + let cached = Revalidation { + headers: { + let mut h = HeaderMap::new(); + h.insert("etag", HeaderValue::from_static("W/\"origin\"")); + h.insert( + "last-modified", + HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"), + ); + h + }, + body: Bytes::from_static(b"body"), + body_allocated: 4, + _transient_leases: Vec::new(), + hits: 0, + transformed: false, + storage_key: [0; 32], + resident_age_at_lookup: Duration::ZERO, + skip_compression: false, + reusable_without_validation: false, + age: 61, + ttl: 60, + stale_if_error: 30, + must_revalidate: false, + proxy_revalidate: false, + origin_etag: Some(HeaderValue::from_static("W/\"origin\"")), + origin_last_modified: Some(HeaderValue::from_static( + "Mon, 26 Jan 2026 23:11:40 GMT", + )), + }; + let mut matching = HeaderMap::new(); + matching.insert("if-none-match", HeaderValue::from_static("\"origin\"")); + assert!(cached.apply_validators(&mut matching)); + let mut other = HeaderMap::new(); + other.insert("if-none-match", HeaderValue::from_static("\"other\"")); + assert!(!cached.apply_validators(&mut other)); + assert_eq!(other["if-none-match"], "\"other\""); + let mut empty = HeaderMap::new(); + assert!(cached.apply_validators(&mut empty)); + assert_eq!(empty["if-none-match"], "W/\"origin\""); + } + #[tokio::test] + async fn transformed_etag_is_never_used_as_an_origin_validator() { + let cache = Cache::new(1); + let ticket = ticket(&cache, &HeaderMap::new()); + let settings = settings(); + let modified = HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"); + let public_etag = HeaderValue::from_static("\"rustproxy-deadbeef\""); + let mut response_headers = headers("public, max-age=60"); + response_headers.insert("etag", public_etag.clone()); + response_headers.insert("last-modified", modified.clone()); + let plan = plan( + StatusCode::OK, + &response_headers, + &settings, + Duration::ZERO, + ) + .unwrap(); + let mut response = Response::new(Body::from("transformed")); + *response.headers_mut() = response_headers; + response + .extensions_mut() + .insert(crate::transform::OriginValidators { + etag: None, + last_modified: Some(modified.clone()), + }); + cache + .wrap(response, ticket.clone(), plan, &settings) + .into_body() + .collect() + .await + .unwrap(); + + let cached = cache.revalidation(&ticket).expect("stored response"); + assert!(cached.origin_etag.is_none()); + assert_eq!(cached.origin_last_modified.as_ref(), Some(&modified)); + let mut outbound = HeaderMap::new(); + assert!(cached.apply_validators(&mut outbound)); + assert!(!outbound.contains_key("if-none-match")); + assert_eq!(outbound["if-modified-since"], modified); + + let mut client_conditional = HeaderMap::new(); + client_conditional.insert("if-none-match", public_etag.clone()); + assert!(cached.apply_validators(&mut client_conditional)); + assert!(!client_conditional.contains_key("if-none-match")); + assert_eq!(client_conditional["if-modified-since"], modified); + + let mut validation = HeaderMap::new(); + validation.insert("cache-control", HeaderValue::from_static("public, max-age=60")); + validation.insert("etag", HeaderValue::from_static("\"origin-new\"")); + validation.insert("last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT")); + let merged = cached.merge_validation_headers(&validation); + assert_eq!(merged["etag"], public_etag); + } + + #[tokio::test] + async fn vary_selects_variants_without_fragmenting_the_primary_hash() { + let cache = Cache::new(1); + let s = settings(); + let mut en = HeaderMap::new(); + en.insert("accept-language", "en".parse().unwrap()); + let mut pl = en.clone(); + pl.insert("accept-language", "pl".parse().unwrap()); + let en_ticket = ticket(&cache, &en); + let pl_ticket = ticket(&cache, &pl); + assert_eq!(en_ticket.key, pl_ticket.key); + + let mut h = headers("max-age=60"); + h.insert("vary", HeaderValue::from_static("Accept-Language")); + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("hello")); + *response.headers_mut() = h.clone(); + cache + .wrap(response, en_ticket.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + assert!(cache.get(&en_ticket).is_some()); + assert!(cache.get(&pl_ticket).is_none()); + + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("czesc")); + *response.headers_mut() = h; + cache + .wrap(response, pl_ticket.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + let en_hit = cache.get(&en_ticket).expect("English Vary variant"); + let pl_hit = cache.get(&pl_ticket).expect("Polish Vary variant"); + assert_eq!( + en_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(b"hello") + ); + assert_eq!( + pl_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(b"czesc") + ); + + let mut star = headers("max-age=60"); + star.insert("vary", HeaderValue::from_static("*")); + assert!(plan(StatusCode::OK, &star, &s, Duration::ZERO).is_none()); + for vary in [ + "x-request-id", + "cache-control", + "pragma", + "if-none-match", + "if-modified-since", + ] { + let mut h = headers("max-age=60"); + h.insert("vary", vary.parse().unwrap()); + assert!( + plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some(), + "valid Vary field must remain cacheable: {vary}" + ); + } + } + #[tokio::test] + async fn canonical_identity_drops_accept_encoding_as_a_cache_variant() { + let cache = Cache::new(1); + let s = settings(); + let mut gzip = HeaderMap::new(); + gzip.insert("accept-encoding", HeaderValue::from_static("gzip")); + let mut brotli = HeaderMap::new(); + brotli.insert("accept-encoding", HeaderValue::from_static("br")); + let gzip_ticket = ticket(&cache, &gzip); + let brotli_ticket = ticket(&cache, &brotli); + + let mut origin_headers = headers("public, max-age=60"); + origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding")); + origin_headers.insert("content-length", HeaderValue::from_static("7")); + let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO) + .expect("origin plan"); + + let mut transformed_headers = headers("public, max-age=60"); + transformed_headers.insert("content-length", HeaderValue::from_static("7")); + let transformed_plan = plan( + StatusCode::OK, + &transformed_headers, + &s, + Duration::ZERO, + ) + .expect("transformed plan"); + let cache_plan = origin_plan + .without_vary("accept-encoding") + .restrict(transformed_plan); + assert!(cache_plan.vary.is_empty()); + + let mut response = Response::new(Body::from("payload")); + *response.headers_mut() = transformed_headers; + cache + .wrap(response, gzip_ticket.clone(), cache_plan, &s) + .into_body() + .collect() + .await + .unwrap(); + + let hit = cache + .get(&brotli_ticket) + .expect("canonical identity must be shared across client encodings"); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"payload") + ); + } + + #[tokio::test] + async fn canonical_identity_preserves_non_encoding_vary_dimensions() { + let cache = Cache::new(1); + let s = settings(); + let mut light = HeaderMap::new(); + light.insert("accept-encoding", HeaderValue::from_static("gzip")); + light.insert("x-theme", HeaderValue::from_static("light")); + let mut dark = HeaderMap::new(); + dark.insert("accept-encoding", HeaderValue::from_static("br")); + dark.insert("x-theme", HeaderValue::from_static("dark")); + + let mut origin_headers = headers("public, max-age=60"); + origin_headers.insert( + "vary", + HeaderValue::from_static("Accept-Encoding, X-Theme"), + ); + let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO) + .expect("origin plan"); + let mut transformed_headers = headers("public, max-age=60"); + transformed_headers.insert("vary", HeaderValue::from_static("X-Theme")); + let transformed_plan = plan( + StatusCode::OK, + &transformed_headers, + &s, + Duration::ZERO, + ) + .expect("transformed plan"); + let cache_plan = origin_plan + .without_vary("accept-encoding") + .restrict(transformed_plan); + assert_eq!(cache_plan.vary, vec!["x-theme".to_owned()]); + + let mut response = Response::new(Body::from("light")); + *response.headers_mut() = transformed_headers; + cache + .wrap(response, ticket(&cache, &light), cache_plan, &s) + .into_body() + .collect() + .await + .unwrap(); + assert!(cache.get(&ticket(&cache, &light)).is_some()); + assert!(cache.get(&ticket(&cache, &dark)).is_none()); + } + + #[tokio::test] + async fn vary_schema_change_removes_incompatible_fallbacks() { + let cache = Cache::new(1); + let s = settings(); + let mut en = HeaderMap::new(); + en.insert("accept-language", HeaderValue::from_static("en")); + let mut pl = HeaderMap::new(); + pl.insert("accept-language", HeaderValue::from_static("pl")); + let en_ticket = ticket(&cache, &en); + let pl_ticket = ticket(&cache, &pl); + + fill(&cache, en_ticket.clone(), "shared-old", &s).await; + assert!(cache.get(&pl_ticket).is_some()); + + let mut varied_headers = headers("max-age=60"); + varied_headers.insert("vary", HeaderValue::from_static("Accept-Language")); + let varied_plan = plan(StatusCode::OK, &varied_headers, &s, Duration::ZERO).unwrap(); + let mut varied = Response::new(Body::from("english-new")); + *varied.headers_mut() = varied_headers; + cache + .wrap(varied, en_ticket.clone(), varied_plan, &s) + .into_body() + .collect() + .await + .unwrap(); + assert!(cache.get(&en_ticket).is_some()); + assert!( + cache.get(&pl_ticket).is_none(), + "an older no-Vary object must not shadow a newer Vary schema" + ); + + fill(&cache, pl_ticket.clone(), "shared-new", &s).await; + let hit = cache + .get(&en_ticket) + .expect("new no-Vary schema should replace old variants"); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"shared-new") + ); + } + + async fn fill(cache: &Arc, ticket: Ticket, data: &'static str, settings: &Settings) { + let h = headers("max-age=60"); + let p = plan(StatusCode::OK, &h, settings, Duration::ZERO).unwrap(); + let mut r = Response::new(Body::from(data)); + *r.headers_mut() = h; + cache + .wrap(r, ticket, p, settings) + .into_body() + .collect() + .await + .unwrap(); + } + #[tokio::test] + async fn max_stale_can_reuse_an_object_after_normal_ttl_expiry() { + let cache = Cache::new(1); + let s = settings(); + let initial = ticket(&cache, &HeaderMap::new()); + let response_headers = headers("public, max-age=1"); + let p = plan(StatusCode::OK, &response_headers, &s, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("stale-payload")); + *response.headers_mut() = response_headers; + cache + .wrap(response, initial.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + { + let mut store = cache.store.lock().unwrap(); + let entry = store.entries.values_mut().next().expect("stored object"); + entry.received = Instant::now() - Duration::from_secs(2); + } + assert!(cache.get(&initial).is_none(), "normal request must not receive stale data"); + assert_eq!(cache.view().entries, 1, "freshness expiry must not physically evict the object"); + + let stale = ticket(&cache, &headers("max-stale")); + let hit = cache.get(&stale).expect("explicit max-stale may reuse retained stale data"); + assert!(hit.stale); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"stale-payload") + ); + } + + #[tokio::test] + async fn response_no_cache_is_stored_only_for_validation() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + let s = settings(); + let mut h = headers("no-cache"); + h.insert("etag", HeaderValue::from_static("\"origin\"")); + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + assert!(p.requires_revalidation); + let mut response = Response::new(Body::from("payload")); + *response.headers_mut() = h; + cache + .wrap(response, t.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + assert!( + cache.get(&t).is_none(), + "no-cache must not be reused without validation" + ); + let candidate = cache.revalidation(&t).expect("stored validator/body"); + assert!(!candidate.reusable_without_validation()); + let mut validation = HeaderMap::new(); + validation.insert("cache-control", HeaderValue::from_static("no-cache")); + validation.insert("etag", HeaderValue::from_static("\"origin\"")); + let merged = candidate.merge_validation_headers(&validation); + let hit = cache + .publish_revalidated(t.clone(), &candidate, merged, &validation, &s, Duration::ZERO) + .expect("validated body can be delivered"); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"payload") + ); + assert!( + cache.get(&t).is_none(), + "stored no-cache response still requires validation next time" + ); + } + #[tokio::test] + async fn resident_age_survives_successful_revalidation() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + let s = settings(); + let mut h = headers("no-cache"); + h.insert("etag", HeaderValue::from_static("\"origin\"")); + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("payload")); + *response.headers_mut() = h; + cache + .wrap(response, t.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + { + let mut store = cache.store.lock().unwrap(); + let entry = store.entries.values_mut().next().expect("stored object"); + entry.resident_age_base = Duration::from_secs(30); + entry.resident_since = Instant::now(); + } + let candidate = cache.revalidation(&t).expect("revalidation candidate"); + assert!(candidate.resident_age_at_lookup >= Duration::from_secs(30)); + let mut validation = HeaderMap::new(); + validation.insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + validation.insert("etag", HeaderValue::from_static("\"origin\"")); + let merged = candidate.merge_validation_headers(&validation); + let hit = cache + .publish_revalidated(t, &candidate, merged, &validation, &s, Duration::from_secs(2)) + .expect("validated object"); + assert!(hit.resident_age_seconds >= 32); + } + + #[tokio::test] + async fn complete_body_hit_age_and_purge() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + assert!(cache.get(&t).is_none()); + fill(&cache, t.clone(), "payload", &settings()).await; + let view = cache.view(); + assert_eq!(view.hot_logical_bytes, b"payload".len()); + assert_eq!(view.hot_allocated_bytes, view.resident_bytes); + assert!(view.hot_allocated_bytes > view.hot_logical_bytes); + assert!(view.hot_allocated_bytes <= view.hot_limit_bytes); + let hit = cache.get(&t).unwrap(); + assert_eq!(hit.hits, 1); + assert!(hit.response.headers().contains_key("age")); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"payload") + ); + assert_eq!(cache.purge(None), 1); + assert!(cache.get(&t).is_none()); + } + + #[tokio::test] + async fn cache_hit_and_dashboard_age_advance_from_storage_clock() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + fill(&cache, t.clone(), "payload", &settings()).await; + { + let mut store = cache.store.lock().unwrap_or_else(|e| e.into_inner()); + let entry = store.entries.values_mut().next().expect("stored entry"); + entry.received = Instant::now() - Duration::from_secs(5); + } + + let hit = cache.get(&t).expect("fresh cached response"); + assert!( + hit.response.headers()["age"] + .to_str() + .unwrap() + .parse::() + .unwrap() + >= 5 + ); + assert!(cache.dashboard(None, 0, 50).objects[0].age_seconds >= 5); + } + + #[test] + fn defaults_enable_static_cookie_boost_for_common_assets() { + let settings = Settings::default(); + assert!(settings.enabled); + assert!(settings.static_ignore_cookies); + for path in [ + "/asset.jpg", + "/asset.jpeg", + "/asset.png", + "/app.css", + "/app.js", + "/font.woff2", + "/audio.mp3", + "/note.txt", + ] { + assert!(settings.ignore_cookie_for_path(path), "{path}"); + } + assert!(!settings.ignore_cookie_for_path("/account")); + assert!(!settings.ignore_cookie_for_path("/api/profile.json")); + } + #[test] + fn static_cookie_bypass_is_limited_to_configured_extensions() { + let mut settings = settings(); + settings.static_ignore_cookies = true; + let mut h = HeaderMap::new(); + h.insert("cookie", HeaderValue::from_static("session=secret")); + assert_eq!( + request_reason(&Method::GET, &h, true, "/asset.jpg", false, &settings), + None + ); + assert_eq!( + request_reason(&Method::GET, &h, true, "/account", false, &settings), + Some("request_cookie") + ); + } + #[test] + fn age_uses_corrected_received_age_plus_response_delay_without_rounding_up() { + let settings = settings(); + let mut h = headers("max-age=120"); + h.insert( + "date", + httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10)) + .parse() + .unwrap(), + ); + h.insert("age", HeaderValue::from_static("3")); + let age_plan = plan(StatusCode::OK, &h, &settings, Duration::from_millis(250)).unwrap(); + assert!(age_plan.initial_age >= Duration::from_secs(10)); + assert!(age_plan.initial_age < Duration::from_secs(12)); + let fresh = plan( + StatusCode::OK, + &headers("max-age=60"), + &settings, + Duration::from_millis(250), + ) + .unwrap(); + assert_eq!(fresh.initial_age.as_secs(), 0); + } + #[tokio::test] + async fn purged_inflight_fill_cannot_repopulate() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + cache.purge(None); + fill(&cache, t, "old", &settings()).await; + assert_eq!(cache.view().entries, 0); + } + + #[tokio::test] + async fn revision_isolation_and_capacity() { + let cache = Cache::new(1); + let s = Settings { + max_entries: 1, + ..settings() + }; + let a = ticket(&cache, &HeaderMap::new()); + fill(&cache, a.clone(), "a", &s).await; + let h = HeaderMap::new(); + let b = cache + .ticket(1, "http", "example.test", "app", "/other", &h) + .unwrap(); + fill(&cache, b.clone(), "b", &s).await; + assert!(cache.get(&a).is_none()); + assert!(cache.get(&b).is_some()); + assert_eq!(cache.view().evictions, 1); + cache.purge(Some(2)); + assert!(cache + .ticket(1, "http", "example.test", "app", "/", &h) + .is_none()); + } + #[tokio::test] + async fn hot_byte_budget_evicts_before_another_fill_grows() { + let cache = Cache::new(1); + let s = Settings { + max_bytes: 96 * 1024, + max_object_bytes: 64 * 1024, + max_entries: 16, + transient_max_bytes: 128 * 1024, + cold_queue_max_bytes: 64 * 1024, + ..settings() + }; + let headers = HeaderMap::new(); + let first = cache + .ticket(1, "http", "example.test", "app", "/first", &headers) + .unwrap(); + let second = cache + .ticket(1, "http", "example.test", "app", "/second", &headers) + .unwrap(); + fill(&cache, first.clone(), "first", &s).await; + assert!(cache.get(&first).is_some()); + let before = cache.view(); + assert!(before.hot_allocated_bytes <= s.max_bytes); + + fill(&cache, second.clone(), "second", &s).await; + let after = cache.view(); + assert!(after.hot_allocated_bytes <= s.max_bytes); + assert_eq!(after.fill_bytes_active, 0); + assert_eq!(after.transient_bytes, 0); + assert!(cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation"); + assert!(cache.get(&second).is_some()); + assert!(after.evictions >= 1); + } + #[tokio::test] + async fn dropped_partial_bodies_and_oversize_are_never_cached() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + let s = settings(); + let h = headers("max-age=60"); + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + let r = Response::new(Body::from("not-consumed")); + drop(cache.wrap(r, t.clone(), p, &s)); + assert!(cache.get(&t).is_none()); + assert_eq!(cache.view().fills_active, 0); + let small = Settings { + max_object_bytes: 2, + ..s + }; + let h = headers("max-age=60"); + let p = plan(StatusCode::OK, &h, &small, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("oversize")); + *response.headers_mut() = h; + let streamed = cache + .wrap(response, t.clone(), p, &small) + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + assert_eq!(streamed, Bytes::from_static(b"oversize")); + assert!(cache.get(&t).is_none()); + assert_eq!(cache.view().fill_bytes_active, 0); + assert_eq!(cache.view().transient_bytes, 0); + } + #[test] + fn varnish_primary_hash_uses_host_and_url_not_unrelated_request_headers() { + let cache = Cache::new(1); + let mut a = HeaderMap::new(); + a.append("user-agent", "browser-a".parse().unwrap()); + a.append("sec-ch-ua", "brand-a".parse().unwrap()); + let mut b = HeaderMap::new(); + b.append("user-agent", "browser-b".parse().unwrap()); + b.append("x-forwarded-for", "192.0.2.8".parse().unwrap()); + let base = cache + .ticket( + 1, + "http", + "Example.Test", + "pool/member-a", + "/asset.js?x=1", + &a, + ) + .unwrap(); + let other_transport = cache + .ticket( + 1, + "https", + "example.test", + "pool/member-b", + "/asset.js?x=1", + &b, + ) + .unwrap(); + assert_eq!(base.key, other_transport.key); + let other_url = cache + .ticket( + 1, + "https", + "example.test", + "pool/member-b", + "/asset.js?x=2", + &b, + ) + .unwrap(); + assert_ne!(base.key, other_url.key); + let explicit_port = cache + .ticket( + 1, + "http", + "example.test:8080", + "pool/member-a", + "/asset.js?x=1", + &a, + ) + .unwrap(); + assert_ne!(base.key, explicit_port.key); + } + #[tokio::test] + async fn only_if_cached_can_reuse_a_normal_get() { + let cache = Cache::new(1); + let plain = HeaderMap::new(); + fill(&cache, ticket(&cache, &plain), "payload", &settings()).await; + let only = headers("only-if-cached"); + assert!(request_cacheable( + &Method::GET, + &only, + true, + "/", + false, + &settings() + )); + assert!(only_if_cached(&only)); + assert!(cache.get(&ticket(&cache, &only)).is_some()); + } + #[tokio::test] + async fn dropped_partial_fill_is_not_published_and_a_later_get_can_fill() { + use futures_util::stream; + use http_body_util::StreamBody; + + let cache = Cache::new(1); + let s = settings(); + let t = ticket(&cache, &HeaderMap::new()); + let frames = vec![ + Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"first-"))), + Ok(Frame::data(Bytes::from_static(b"second"))), + ]; + let mut response = Response::new(Body::new(StreamBody::new(stream::iter(frames)))); + *response.headers_mut() = headers("max-age=60"); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("12")); + let p = plan(StatusCode::OK, response.headers(), &s, Duration::ZERO).unwrap(); + let mut body = cache.wrap(response, t.clone(), p, &s).into_body(); + let first = body + .frame() + .await + .expect("first frame") + .expect("successful first frame") + .into_data() + .expect("data frame"); + assert_eq!(first, Bytes::from_static(b"first-")); + drop(body); + + assert!(cache.get(&t).is_none(), "partial body must never become a HIT"); + assert_eq!(cache.view().fills_active, 0); + assert_eq!(cache.view().rejected_fills, 1); + + fill(&cache, t.clone(), "complete", &s).await; + let hit = cache.get(&t).expect("a later complete GET can fill the cache"); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"complete") + ); + } + + #[tokio::test] + async fn failed_streams_and_trailers_cannot_publish_entries() { + use futures_util::stream; + use http_body_util::StreamBody; + for fail in [true, false] { + let cache = Cache::new(1); + let s = settings(); + let t = ticket(&cache, &HeaderMap::new()); + let frames = vec![ + Ok(Frame::data(Bytes::from_static(b"partial"))), + if fail { + Err(std::io::Error::other("broken origin")) + } else { + Ok(Frame::trailers(HeaderMap::new())) + }, + ]; + let mut r = Response::new(Body::new(StreamBody::new(stream::iter(frames)))); + *r.headers_mut() = headers("max-age=60"); + let p = plan(StatusCode::OK, r.headers(), &s, Duration::ZERO).unwrap(); + let result = cache.wrap(r, t.clone(), p, &s).into_body().collect().await; + assert_eq!(result.is_err(), fail); + assert!(cache.get(&t).is_none()); + assert_eq!(cache.view().fills_active, 0); + } + } } diff --git a/src/cache/tests/v022_cache_tests.rs b/src/cache/tests/v022_cache_tests.rs index fdfcb32..e899067 100644 --- a/src/cache/tests/v022_cache_tests.rs +++ b/src/cache/tests/v022_cache_tests.rs @@ -1,4 +1,950 @@ mod v022_cache_tests { - include!("v022_cache_tests/part_01.rs"); - include!("v022_cache_tests/part_02.rs"); + use super::*; + use http_body_util::BodyExt; + + fn active_settings() -> Settings { + Settings { + enabled: true, + ..Settings::default() + } + } + + async fn store( + cache: &Arc, + scope: &str, + host: &str, + target: &str, + request_headers: HeaderMap, + extra_response_headers: &[(&str, &str)], + body: &'static str, + ) -> Ticket { + let ticket = cache + .ticket_scoped( + 22, + scope, + "http", + host, + "origin@127.0.0.1:8080", + target, + &request_headers, + ) + .unwrap(); + let settings = active_settings(); + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120, stale-if-error=60"), + ); + for (name, value) in extra_response_headers { + response.headers_mut().insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap(response, ticket.clone(), plan, &settings) + .into_body() + .collect() + .await + .unwrap(); + ticket + } + + fn invalidation( + operation: InvalidationOperation, + selector: InvalidationSelector, + value: &str, + host: Option<&str>, + vhost: Option<&str>, + ) -> InvalidationRequest { + InvalidationRequest { + operation, + selector, + value: value.to_owned(), + host: host.map(str::to_owned), + vhost: vhost.map(str::to_owned), + ttl_seconds: None, + confirm_wide: false, + } + } + + #[tokio::test] + async fn transformed_fill_retains_identical_content_and_releases_fill_gauge() { + for body in [ + Bytes::from(vec![b'x'; 4096]), + Bytes::from_static(b"unchanged static bytes"), + ] { + let cache = Cache::new(22); + let settings = active_settings(); + let ticket = cache.ticket(22, "http", "example.test", "origin", "/transform.js", &HeaderMap::new()).unwrap(); + let mut response = Response::new(Body::from(body.clone())); + response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120")); + response.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap()); + response.extensions_mut().insert(crate::transform::Applied { + kind: crate::transform::Kind::Javascript, + original_bytes: body.len(), + transformed_bytes: body.len(), + }); + let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + assert_eq!(policy.expected_length, Some(body.len())); + let downstream = cache.wrap(response, ticket.clone(), policy, &settings) + .into_body().collect().await.unwrap().to_bytes(); + assert_eq!(downstream, body); + let cached = cache.get(&ticket).unwrap().response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(cached, body); + assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0); + } + } + + #[tokio::test] + async fn uniquely_owned_transformed_frame_is_cached_without_body_copy() { + let cache = Cache::new(22); + let settings = active_settings(); + let ticket = cache.ticket(22, "http", "example.test", "origin", "/owned.js", &HeaderMap::new()).unwrap(); + let payload = Bytes::from(vec![b'z'; 8192]); + let original_allocation = payload.as_ptr(); + let mut response = Response::new(Body::from(payload)); + response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120")); + response.headers_mut().insert("content-length", HeaderValue::from_static("8192")); + response.extensions_mut().insert(crate::transform::Applied { + kind: crate::transform::Kind::Javascript, + original_bytes: 8192, + transformed_bytes: 8192, + }); + let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap(); + let downstream = cache.wrap(response, ticket.clone(), policy, &settings) + .into_body().collect().await.unwrap().to_bytes(); + assert_eq!(downstream.len(), 8192); + assert_eq!(downstream.as_ptr(), original_allocation); + let hit = cache.get(&ticket).unwrap(); + let cached = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(cached, downstream); + assert_eq!(cached.as_ptr(), original_allocation, "cache fill copied the transformed body"); + assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0); + } + + #[tokio::test] + async fn cached_single_range_returns_206_and_preserves_validators() { + let cache = Cache::new(22); + let ticket = store( + &cache, + "range", + "range.example", + "/asset.bin", + HeaderMap::new(), + &[ + ("etag", "\"asset-v1\""), + ("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"), + ], + "abcdefghij", + ) + .await; + let mut request_headers = HeaderMap::new(); + request_headers.insert("range", HeaderValue::from_static("bytes=2-5")); + let range = match request_range(&Method::GET, &request_headers) { + RangeSelection::Single(range) => range, + other => panic!("unexpected range selection: {other:?}"), + }; + let hit = cache.get(&ticket).unwrap().apply_range(range, None); + assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(hit.response.headers()["content-range"], "bytes 2-5/10"); + assert_eq!(hit.response.headers()["content-length"], "4"); + assert_eq!(hit.response.headers()["accept-ranges"], "bytes"); + assert_eq!(hit.response.headers()["etag"], "\"asset-v1\""); + assert!(hit.response.headers().contains_key("age")); + let body = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"cdef")); + } + + #[tokio::test] + async fn cached_range_supports_suffix_open_unsatisfied_and_if_range() { + let cache = Cache::new(22); + let ticket = store( + &cache, + "range", + "range.example", + "/asset.bin", + HeaderMap::new(), + &[ + ("etag", "\"asset-v1\""), + ("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"), + ], + "abcdefghij", + ) + .await; + let cases = [ + (ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"), + (ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"), + ]; + for (range, expected, content_range) in cases { + let hit = cache.get(&ticket).unwrap().apply_range(range, None); + assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(hit.response.headers()["content-range"], content_range); + let body = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::copy_from_slice(expected.as_bytes())); + } + + let unsatisfied = cache.get(&ticket).unwrap().apply_range( + ByteRange { start: Some(50), end: None }, + None, + ); + assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10"); + assert_eq!( + unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(), + 0 + ); + + let mismatch = HeaderValue::from_static("\"asset-v2\""); + let full = cache.get(&ticket).unwrap().apply_range( + ByteRange { start: Some(0), end: Some(2) }, + Some(&mismatch), + ); + assert_eq!(full.response.status(), StatusCode::OK); + assert!(!full.response.headers().contains_key("content-range")); + assert_eq!( + full.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"abcdefghij") + ); + + let matched = HeaderValue::from_static("\"asset-v1\""); + let partial = cache.get(&ticket).unwrap().apply_range( + ByteRange { start: Some(0), end: Some(2) }, + Some(&matched), + ); + assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT); + } + + #[test] + fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() { + let mut multipart = HeaderMap::new(); + multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5")); + assert_eq!( + request_range(&Method::GET, &multipart), + RangeSelection::Unsupported + ); + + let mut duplicate = HeaderMap::new(); + duplicate.append("range", HeaderValue::from_static("bytes=0-1")); + duplicate.append("range", HeaderValue::from_static("bytes=4-5")); + assert_eq!( + request_range(&Method::GET, &duplicate), + RangeSelection::Unsupported + ); + + let mut response_headers = HeaderMap::new(); + response_headers.insert( + "last-modified", + HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"), + ); + let newer_if_range = HeaderValue::from_static("Tue, 27 Jan 2026 23:11:40 GMT"); + assert!(if_range_matches(&response_headers, Some(&newer_if_range))); + } + + #[tokio::test] + async fn concurrent_miss_single_flight_waits_until_fill_is_published() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.collapse_wait_timeout_ms = 500; + let ticket = cache + .ticket_scoped( + 22, + "collapse", + "http", + "collapse.example", + "origin@127.0.0.1:8080", + "/same", + &HeaderMap::new(), + ) + .unwrap(); + let leader = match cache.claim_fill(&ticket, &settings).await { + FillClaim::Leader(guard) => guard, + _ => panic!("first request must become fill leader"), + }; + let follower_cache = cache.clone(); + let follower_ticket = ticket.clone(); + let follower_settings = settings.clone(); + let follower = tokio::spawn(async move { + match follower_cache + .claim_fill(&follower_ticket, &follower_settings) + .await + { + FillClaim::Waited => follower_cache.get(&follower_ticket).is_some(), + _ => false, + } + }); + for _ in 0..50 { + if cache.view().collapse_waiters == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + assert_eq!(cache.view().collapse_waiters, 1); + assert!(!follower.is_finished()); + + let mut response = Response::new(Body::from("collapsed-payload")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap_with_fill(response, ticket.clone(), plan, &settings, Some(leader)) + .into_body() + .collect() + .await + .unwrap(); + assert!(follower.await.unwrap()); + assert_eq!(cache.view().collapsed_fills, 1); + assert_eq!(cache.view().collapse_waiters, 0); + } + + #[tokio::test] + async fn single_flight_timeout_and_leader_drop_release_the_key() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.collapse_wait_timeout_ms = 10; + let ticket = cache + .ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new()) + .unwrap(); + let leader = match cache.claim_fill(&ticket, &settings).await { + FillClaim::Leader(guard) => guard, + _ => panic!("missing leader"), + }; + assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout)); + assert_eq!(cache.view().collapse_timeouts, 1); + drop(leader); + assert!(matches!( + cache.claim_fill(&ticket, &settings).await, + FillClaim::Leader(_) + )); + } + + #[tokio::test] + async fn cancelled_single_flight_waiter_releases_waiter_metric() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.collapse_wait_timeout_ms = 5_000; + let ticket = cache + .ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new()) + .unwrap(); + let leader = match cache.claim_fill(&ticket, &settings).await { + FillClaim::Leader(guard) => guard, + _ => panic!("missing leader"), + }; + let follower_cache = cache.clone(); + let follower_ticket = ticket.clone(); + let follower_settings = settings.clone(); + let follower = tokio::spawn(async move { + follower_cache + .claim_fill(&follower_ticket, &follower_settings) + .await + }); + for _ in 0..50 { + if cache.view().collapse_waiters == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + assert_eq!(cache.view().collapse_waiters, 1); + follower.abort(); + let _ = follower.await; + tokio::task::yield_now().await; + assert_eq!(cache.view().collapse_waiters, 0); + drop(leader); + } + + #[tokio::test] + async fn single_flight_retries_after_another_vary_representation_is_published() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.collapse_wait_timeout_ms = 500; + let mut en_headers = HeaderMap::new(); + en_headers.insert("accept-language", HeaderValue::from_static("en")); + let mut pl_headers = HeaderMap::new(); + pl_headers.insert("accept-language", HeaderValue::from_static("pl")); + let en = cache + .ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers) + .unwrap(); + let pl = cache + .ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers) + .unwrap(); + assert_eq!(en.key, pl.key, "Vary is learned from the response"); + let leader = match cache.claim_fill(&en, &settings).await { + FillClaim::Leader(guard) => guard, + _ => panic!("first representation must become leader"), + }; + let follower_cache = cache.clone(); + let follower_ticket = pl.clone(); + let follower_settings = settings.clone(); + let follower = tokio::spawn(async move { + assert!(matches!( + follower_cache + .claim_fill(&follower_ticket, &follower_settings) + .await, + FillClaim::Waited + )); + assert!(follower_cache.get(&follower_ticket).is_none()); + matches!( + follower_cache + .claim_fill(&follower_ticket, &follower_settings) + .await, + FillClaim::Leader(_) + ) + }); + for _ in 0..50 { + if cache.view().collapse_waiters == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + + let mut response = Response::new(Body::from("hello")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + response + .headers_mut() + .insert("vary", HeaderValue::from_static("Accept-Language")); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap_with_fill(response, en, plan, &settings, Some(leader)) + .into_body() + .collect() + .await + .unwrap(); + assert!(follower.await.unwrap()); + } + + #[test] + fn stale_if_error_policy_is_configurable_by_error_class_and_status() { + let mut settings = Settings::default(); + settings.stale_if_error_timeout = false; + settings.stale_if_error_dns = false; + settings.stale_if_error_statuses = vec![500, 503]; + assert!(!settings.allows_stale_error_class(StaleErrorClass::Timeout)); + assert!(!settings.allows_stale_error_class(StaleErrorClass::Dns)); + assert!(settings.allows_stale_error_class(StaleErrorClass::Connection)); + assert!(settings.allows_stale_error_class(StaleErrorClass::Tls)); + assert!(settings.allows_stale_error_status(StatusCode::INTERNAL_SERVER_ERROR)); + assert!(settings.allows_stale_error_status(StatusCode::SERVICE_UNAVAILABLE)); + assert!(!settings.allows_stale_error_status(StatusCode::BAD_GATEWAY)); + } + + #[test] + fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() { + let cache = Cache::new(22); + let ticket = cache + .ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new()) + .unwrap(); + let mut settings = active_settings(); + settings.proactive_refresh_enabled = true; + settings.proactive_refresh_percent = 100; + settings.proactive_refresh_max_inflight = 1; + let scheduled = cache + .begin_proactive_schedule(&ticket, &settings) + .expect("first schedule"); + assert!(cache.begin_proactive_schedule(&ticket, &settings).is_none()); + let running = cache + .try_begin_proactive_refresh(&settings) + .expect("first inflight refresh"); + assert!(cache.try_begin_proactive_refresh(&settings).is_none()); + assert_eq!(cache.view().proactive_inflight, 1); + drop(running); + drop(scheduled); + assert!(cache.begin_proactive_schedule(&ticket, &settings).is_some()); + assert_eq!(cache.view().proactive_inflight, 0); + } + + #[test] + fn proactive_refresh_pending_tasks_are_bounded() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.proactive_refresh_enabled = true; + settings.proactive_refresh_percent = 100; + settings.proactive_refresh_max_inflight = 1; + let mut guards = Vec::new(); + for index in 0..64 { + let ticket = cache + .ticket( + 22, + "http", + "refresh.example", + "origin", + &format!("/asset-{index}"), + &HeaderMap::new(), + ) + .unwrap(); + guards.push( + cache + .begin_proactive_schedule(&ticket, &settings) + .expect("schedule below pending limit"), + ); + } + let overflow = cache + .ticket( + 22, + "http", + "refresh.example", + "origin", + "/overflow", + &HeaderMap::new(), + ) + .unwrap(); + assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none()); + assert!(cache.view().proactive_skipped > 0); + drop(guards); + } + + #[test] + fn background_revalidation_set_has_a_hard_limit() { + let cache = Cache::new(22); + let base = Revalidation { + headers: HeaderMap::new(), + body: Bytes::new(), + body_allocated: 0, + _transient_leases: Vec::new(), + hits: 0, + transformed: false, + storage_key: [0; 32], + resident_age_at_lookup: Duration::ZERO, + skip_compression: false, + reusable_without_validation: false, + age: 0, + ttl: 0, + stale_if_error: 0, + must_revalidate: false, + proxy_revalidate: false, + origin_etag: None, + origin_last_modified: None, + }; + let mut guards = Vec::new(); + for index in 0..BACKGROUND_REVALIDATION_LIMIT { + let mut cached = base.clone(); + cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes()); + guards.push( + cache + .begin_background_revalidation(&cached) + .expect("revalidation below hard limit"), + ); + } + let mut overflow = base; + overflow.storage_key = [0xff; 32]; + assert!(cache.begin_background_revalidation(&overflow).is_none()); + drop(guards); + } + + #[tokio::test] + async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() { + let cache = Cache::new(22); + let one = store( + &cache, + "app", + "purge.example", + "/asset.js?v=1", + HeaderMap::new(), + &[("cache-tag", "release:one")], + "one", + ) + .await; + let two = store( + &cache, + "app", + "purge.example", + "/asset.js?v=2", + HeaderMap::new(), + &[("cache-tag", "release:two")], + "two", + ) + .await; + let result = cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Exact, + "/asset.js?v=1", + Some("purge.example"), + Some("app"), + )) + .unwrap(); + assert_eq!(result.affected_entries, 1); + assert!(cache.get(&one).is_none()); + assert!(cache.get(&two).is_some()); + + let three = store( + &cache, + "app", + "purge.example", + "/assets/a.css", + HeaderMap::new(), + &[("cache-tag", "release:three")], + "three", + ) + .await; + let four = store( + &cache, + "app", + "purge.example", + "/assets/b.css", + HeaderMap::new(), + &[("cache-tag", "release:four")], + "four", + ) + .await; + assert_eq!( + cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Prefix, + "/assets/", + Some("purge.example"), + Some("app"), + )) + .unwrap() + .affected_entries, + 2 + ); + assert!(cache.get(&three).is_none()); + assert!(cache.get(&four).is_none()); + + let tagged = store( + &cache, + "app", + "purge.example", + "/tagged", + HeaderMap::new(), + &[("cache-tag", "release:tagged")], + "tagged", + ) + .await; + assert_eq!( + cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Tag, + "release:tagged", + Some("purge.example"), + Some("app"), + )) + .unwrap() + .affected_entries, + 1 + ); + assert!(cache.get(&tagged).is_none()); + + let keyed = store( + &cache, + "app", + "purge.example", + "/by-key", + HeaderMap::new(), + &[], + "keyed", + ) + .await; + let key = key_hex(&keyed.key); + assert_eq!( + cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::CacheKey, + &key, + Some("purge.example"), + Some("app"), + )) + .unwrap() + .affected_entries, + 1 + ); + assert!(cache.get(&keyed).is_none()); + } + + #[tokio::test] + async fn selective_purge_is_a_barrier_for_a_fill_started_before_the_purge() { + let cache = Cache::new(22); + let settings = active_settings(); + let old_ticket = cache + .ticket_scoped( + 22, + "app", + "http", + "purge.example", + "origin@127.0.0.1:8080", + "/late", + &HeaderMap::new(), + ) + .unwrap(); + let result = cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Tag, + "release:late", + Some("purge.example"), + Some("app"), + )) + .unwrap(); + assert_eq!(result.affected_entries, 0); + + let mut response = Response::new(Body::from("old-fill")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + response + .headers_mut() + .insert("cache-tag", HeaderValue::from_static("release:late")); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap(response, old_ticket, plan, &settings) + .into_body() + .collect() + .await + .unwrap(); + assert_eq!(cache.view().entries, 0, "pre-purge fill must not republish"); + + let new_ticket = store( + &cache, + "app", + "purge.example", + "/late", + HeaderMap::new(), + &[("cache-tag", "release:late")], + "new-fill", + ) + .await; + assert!(cache.get(&new_ticket).is_some()); + } + + #[tokio::test] + async fn purge_of_one_vary_variant_removes_the_whole_variant_family() { + let cache = Cache::new(22); + let mut en_headers = HeaderMap::new(); + en_headers.insert("accept-language", HeaderValue::from_static("en")); + let mut pl_headers = HeaderMap::new(); + pl_headers.insert("accept-language", HeaderValue::from_static("pl")); + let en = store( + &cache, + "app", + "vary.example", + "/localized", + en_headers, + &[("vary", "Accept-Language"), ("cache-tag", "variant:en")], + "hello", + ) + .await; + let pl = store( + &cache, + "app", + "vary.example", + "/localized", + pl_headers, + &[("vary", "Accept-Language"), ("cache-tag", "variant:pl")], + "czesc", + ) + .await; + let result = cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Tag, + "variant:en", + Some("vary.example"), + Some("app"), + )) + .unwrap(); + assert_eq!(result.affected_entries, 2); + assert!(cache.get(&en).is_none()); + assert!(cache.get(&pl).is_none()); + } + + #[tokio::test] + async fn ban_is_lazy_blocks_old_object_allows_new_fill_and_can_be_removed() { + let cache = Cache::new(22); + let old = store( + &cache, + "app", + "ban.example", + "/page", + HeaderMap::new(), + &[], + "old", + ) + .await; + assert_eq!(cache.view().entries, 1); + let result = cache + .invalidate(invalidation( + InvalidationOperation::Ban, + InvalidationSelector::Exact, + "/page", + Some("ban.example"), + Some("app"), + )) + .unwrap(); + let ban_id = result.ban_id.unwrap(); + assert_eq!(result.affected_entries, 0); + assert_eq!(cache.view().entries, 1, "BAN must not scan storage eagerly"); + assert!(cache.get(&old).is_none()); + assert_eq!(cache.view().entries, 0); + + tokio::time::sleep(Duration::from_millis(2)).await; + let fresh = store( + &cache, + "app", + "ban.example", + "/page", + HeaderMap::new(), + &[], + "fresh", + ) + .await; + assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects"); + assert_eq!(cache.bans().len(), 1); + assert!(cache.remove_ban(ban_id)); + assert!(cache.bans().is_empty()); + assert!(!cache.remove_ban(ban_id)); + } + + #[tokio::test] + async fn regex_and_root_prefix_require_explicit_wide_confirmation() { + let cache = Cache::new(22); + store( + &cache, + "app", + "wide.example", + "/one", + HeaderMap::new(), + &[], + "one", + ) + .await; + let regex = invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Regex, + ".*", + None, + None, + ); + assert!(cache.invalidate(regex.clone()).is_err()); + let scoped_regex = invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Regex, + "^/one$", + Some("wide.example"), + Some("app"), + ); + assert!(cache.invalidate(scoped_regex).is_err()); + let mut confirmed = regex; + confirmed.confirm_wide = true; + assert_eq!(cache.invalidate(confirmed).unwrap().affected_entries, 1); + + let prefix = invalidation( + InvalidationOperation::Ban, + InvalidationSelector::Prefix, + "/", + Some("wide.example"), + Some("app"), + ); + assert!(cache.invalidate(prefix.clone()).is_err()); + let mut confirmed_prefix = prefix; + confirmed_prefix.confirm_wide = true; + assert!(cache.invalidate(confirmed_prefix).unwrap().ban_id.is_some()); + } + + #[tokio::test] + async fn many_distinct_selective_purges_do_not_retain_tombstones_forever() { + let cache = Cache::new(22); + store( + &cache, + "app", + "memory.example", + "/stable", + HeaderMap::new(), + &[], + "unchanged", + ) + .await; + // A production cache may see an unbounded stream of PURGEs for URLs + // that will never be cached again. The invalidated-key index must + // not remember all of those removed objects indefinitely. + for index in 0..512 { + let target = format!("/unique-{index}"); + let old = store( + &cache, + "app", + "memory.example", + &target, + HeaderMap::new(), + &[], + "x", + ) + .await; + assert!(cache.ticket_current(&old)); + let result = cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Exact, + &target, + Some("memory.example"), + Some("app"), + )) + .unwrap(); + assert_eq!(result.affected_entries, 1); + assert!(!cache.ticket_current(&old), "pre-purge ticket must remain stale"); + let state = cache.store.lock().unwrap_or_else(|e| e.into_inner()); + assert!( + state.key_generations.len() <= 1, + "key generations must not accumulate for already purged URLs" + ); + } + // A new ticket after the invalidation barrier is valid and can fill + // the same URL again, preserving ordinary cache behavior. + let new = store( + &cache, + "app", + "memory.example", + "/unique-0", + HeaderMap::new(), + &[], + "new", + ) + .await; + assert!(cache.ticket_current(&new)); + assert!(cache.get(&new).is_some()); + let stable = cache + .ticket_scoped( + 22, + "app", + "http", + "memory.example", + "origin@127.0.0.1:8080", + "/stable", + &HeaderMap::new(), + ) + .unwrap(); + assert!(cache.get(&stable).is_some(), "unrelated entries must survive"); + } } diff --git a/src/config/tests.rs b/src/config/tests.rs index 0de936e..c403bc9 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1,5 +1,907 @@ mod tests { - include!("tests/part_01.rs"); - include!("tests/part_02.rs"); - include!("tests/part_03.rs"); +use super::*; +fn policy() -> ValidationPolicy { + ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + } +} +#[test] +fn missing_http2_settings_use_release_defaults() { + let config: Config = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(config.http2, crate::settings::Http2Connections::default()); + config.http2.validate().unwrap(); +} + +#[test] +fn normalized_unicode_prefix_survives_serialization_and_recompile() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into(); + let first = compile(config, 0, &policy()).unwrap(); + let saved = serde_json::to_string(&first.envelope.config).unwrap(); + let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap(); + assert!(second.select("localhost", "/caf\u{e9}/hello").is_some()); +} +#[test] +fn web_server_main_source_needs_no_backend_pool() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.web_services.push(crate::web_server::Service { + id: "site".into(), + name: "Site".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: false, + ..Default::default() + }); + config.vhosts[0].source = crate::vhost::Source::WebServer { + web_service_id: "site".into(), + }; + config.vhosts[0].backends.clear(); + config.vhosts[0].routes.clear(); + let snapshot = compile(config, 1, &policy()).unwrap(); + let route = snapshot.select("localhost", "/").unwrap(); + assert!(matches!(&route.source, CompiledSource::WebServer(_))); + assert!(route.pool.is_none()); + assert!(route.backend.is_none()); + assert!(snapshot.pools.is_empty()); +} + +#[test] +fn web_server_and_reverse_proxy_can_share_one_vhost() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.web_services.push(crate::web_server::Service { + id: "site".into(), + name: "Site".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: false, + ..Default::default() + }); + let host = &mut config.vhosts[0]; + host.source = crate::vhost::Source::WebServer { + web_service_id: "site".into(), + }; + host.routes = vec![crate::vhost::PathRoute { + id: "api".into(), + path_prefix: "/api".into(), + source: crate::vhost::Source::ReverseProxy, + ..Default::default() + }]; + let snapshot = compile(config, 1, &policy()).unwrap(); + let root = snapshot.select("localhost", "/").unwrap(); + let api = snapshot.select("localhost", "/api/test").unwrap(); + assert!(matches!(&root.source, CompiledSource::WebServer(_))); + assert!(matches!(&api.source, CompiledSource::ReverseProxy)); + assert!(api.pool.is_some()); + assert!(api.backend.is_some()); +} + +#[test] +fn one_web_service_can_be_reused_by_multiple_vhosts() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"shared").unwrap(); + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.web_services.push(crate::web_server::Service { + id: "shared-site".into(), + name: "Shared site".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: false, + ..Default::default() + }); + let first = &mut config.vhosts[0]; + first.source = crate::vhost::Source::WebServer { + web_service_id: "shared-site".into(), + }; + first.backends.clear(); + first.routes.clear(); + + let mut second = first.clone(); + second.id = "shared-second".into(); + second.frontend.hosts = vec!["second.localhost".into()]; + config.vhosts.push(second); + + let snapshot = compile(config, 1, &policy()).unwrap(); + let first_route = snapshot.select("localhost", "/").unwrap(); + let second_route = snapshot.select("second.localhost", "/").unwrap(); + assert!( + matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") + ); + assert!( + matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") + ); +} + +#[test] +fn unknown_web_service_reference_is_rejected() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].source = crate::vhost::Source::WebServer { + web_service_id: "missing".into(), + }; + let error = compile(config, 1, &policy()).unwrap_err().to_string(); + assert!(error.contains("unknown Web Service"), "{error}"); +} + +#[test] +fn missing_source_fields_remain_reverse_proxy_for_old_configs() { + let source: crate::vhost::Source = + serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap(); + assert_eq!(source, crate::vhost::Source::ReverseProxy); + let route: crate::vhost::PathRoute = + serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap(); + assert_eq!(route.source, crate::vhost::Source::ReverseProxy); +} + +#[test] +fn host_normalization() { + assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com"); + for invalid in [ + "", + "a..b", + "*.test", + "a/b", + "user@host", + "-bad.test", + "a:99999", + ] { + assert!(request_host(invalid).is_err(), "{invalid}"); + } +} +#[test] +fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() { + assert_eq!( + normalize_host_pattern("*.Example.TEST.").unwrap(), + "*.example.test" + ); + for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] { + assert!(normalize_host_pattern(invalid).is_err(), "{invalid}"); + } + + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].id = "wildcard".into(); + c.vhosts[0].frontend.hosts = vec!["*.example.test".into()]; + let mut exact = c.vhosts[0].clone(); + exact.id = "exact".into(); + exact.frontend.hosts = vec!["api.example.test".into()]; + c.vhosts.push(exact); + let snapshot = compile(c, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + assert_eq!( + snapshot + .select_on("api.example.test", "/", local, false) + .unwrap() + .id, + "exact/main" + ); + assert_eq!( + snapshot + .select_on("www.example.test", "/", local, false) + .unwrap() + .id, + "wildcard/main" + ); +} +#[test] +fn logging_vhost_resolution_reuses_router_host_precedence() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].id = "wildcard".into(); + config.vhosts[0].frontend.hosts = vec!["*.example.test".into()]; + let mut exact = config.vhosts[0].clone(); + exact.id = "exact".into(); + exact.frontend.hosts = vec!["api.example.test".into()]; + config.vhosts.push(exact); + let snapshot = compile(config, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + + let routed = snapshot + .resolve_logging_vhost( + local, + false, + Some("wildcard"), + Some("api.example.test"), + None, + None, + ) + .unwrap(); + assert_eq!(routed.vhost, "wildcard"); + assert_eq!(routed.matched_by, "router"); + + let exact = snapshot + .resolve_logging_vhost(local, false, None, Some("API.EXAMPLE.TEST:8080"), None, None) + .unwrap(); + assert_eq!(exact.vhost, "exact"); + assert_eq!(exact.matched_by, "authority"); + + let wildcard = snapshot + .resolve_logging_vhost(local, false, None, None, Some("www.example.test:8080"), None) + .unwrap(); + assert_eq!(wildcard.vhost, "wildcard"); + assert_eq!(wildcard.matched_by, "host"); + + let sni = snapshot + .resolve_logging_vhost(local, false, None, None, None, Some("api.example.test")) + .unwrap(); + assert_eq!(sni.vhost, "exact"); + assert_eq!(sni.matched_by, "sni"); + + assert!(snapshot + .resolve_logging_vhost(local, false, None, None, None, Some("unknown.test")) + .is_none()); + assert!(snapshot + .resolve_logging_vhost(local, false, None, None, None, None) + .is_none()); +} + +#[test] +fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.acl_profiles = serde_json::from_value(serde_json::json!([{ + "id":"office", "name":"Office", + "acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]}, + "country_access":{"mode":"block","countries":["RU"]} + }])) + .unwrap(); + c.vhosts[0].acl_profile_id = Some("office".into()); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let frontend = snapshot.frontend("localhost", local, false).unwrap(); + assert_eq!(frontend.acl_profile_id.as_deref(), Some("office")); + assert!(frontend + .profile_acl + .as_ref() + .unwrap() + .allows("10.2.3.4".parse().unwrap())); + assert!(!frontend + .profile_acl + .as_ref() + .unwrap() + .allows("192.0.2.1".parse().unwrap())); + assert!(!frontend.profile_country_access.allows(Some("RU"))); + + c.vhosts[0].acl_profile_id = Some("missing".into()); + assert!(compile(c, 2, &policy()).is_err()); +} + +#[test] +fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].websockets = true; + c.vhosts[0].websocket_rfc8441 = true; + let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); + assert!(error.contains("HTTP/2-capable listener"), "{error}"); + + c.listeners.endpoints[0].binding.h2c = true; + let snapshot = compile(c.clone(), 2, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + assert!(snapshot.websocket_rfc8441_on(local, false)); + assert!(!snapshot.websocket_rfc8441_on(local, true)); + + c.vhosts[0].websocket_rfc8441 = false; + let snapshot = compile(c, 3, &policy()).unwrap(); + assert!(!snapshot.websocket_rfc8441_on(local, false)); +} +#[test] +fn force_ssl_requires_plain_http_and_chooses_https_target() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].force_ssl = true; + let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); + assert!( + error.contains("at least one selected TLS listener"), + "{error}" + ); + + c.listeners.endpoints.push(crate::settings::NamedListener { + id: "https-a".into(), + name: "HTTPS A".into(), + scope: crate::settings::ListenerScope::Internal, + enabled: true, + binding: crate::vhost::Listener { + address: "127.0.0.1:8443".parse().unwrap(), + tls: true, + http2: true, + http2_settings: Some(crate::settings::Http2Connections::default()), + h2c: false, + http3: None, + }, + additional_addresses: vec![], + }); + c.listeners.endpoints.push(crate::settings::NamedListener { + id: "https-b".into(), + name: "HTTPS B".into(), + scope: crate::settings::ListenerScope::Internal, + enabled: true, + binding: crate::vhost::Listener { + address: "127.0.0.1:9443".parse().unwrap(), + tls: true, + http2: true, + http2_settings: Some(crate::settings::Http2Connections::default()), + h2c: false, + http3: None, + }, + additional_addresses: vec![], + }); + c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()]; + let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string(); + assert!(error.contains("cannot choose an HTTPS target"), "{error}"); + + c.listeners + .endpoints + .iter_mut() + .find(|item| item.id == "https-b") + .unwrap() + .binding + .address = "127.0.0.1:443".parse().unwrap(); + let snapshot = compile(c.clone(), 3, &policy()).unwrap(); + let frontend = snapshot + .frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false) + .unwrap(); + assert_eq!(frontend.https_redirect_port, Some(443)); + + c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()]; + let error = compile(c, 4, &policy()).unwrap_err().to_string(); + assert!(error.contains("plain HTTP listener"), "{error}"); +} +#[test] +fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); + c.vhosts[0].backends[0].allow_private = true; + c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into()); + let s = compile(c, 1, &policy()).unwrap(); + let backend = &s.pools[0].members[0].backend; + assert_eq!(backend.origin, "https://10.87.20.2:443"); + assert_eq!( + backend.request_origin, + "https://backend.unitra.internal:443" + ); +} +#[test] +fn tls_server_name_is_https_dns_only() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into()); + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); + c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into()); + assert!(compile(c, 1, &policy()).is_err()); +} +#[test] +fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(!c.vhosts[0].backends[0].tls_verify); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + + c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); + c.vhosts[0].backends[0].allow_private = true; + c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into()); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + + c.vhosts[0].backends[0].tls_verify = true; + assert!(compile(c, 1, &policy()).is_err()); +} +#[test] +fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.redirects = serde_json::from_value(serde_json::json!([{ + "id":"legacy","name":"Legacy","frontend":{"hosts":["old.localhost"],"listener_ids":["http"]}, + "rules":[{"id":"root","target":"https://new.localhost/","status_code":308}] + }])).unwrap(); + let snapshot = compile(c, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let (_host, rule) = snapshot + .redirect_on("old.localhost", "/a", local, false) + .unwrap(); + assert_eq!(rule.id, "root"); + assert!(snapshot + .select_on("old.localhost", "/a", local, false) + .is_none()); +} + +#[test] +fn custom_locations_override_but_main_vhost_remains_fallback() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + // The demo document still contains the pre-0.16 synthetic root route. + // Validation must normalize it away and keep the main backend internally. + let mut custom = c.vhosts[0].routes[0].clone(); + custom.id = "test".into(); + custom.path_prefix = "/test".into(); + custom.response = Some(crate::vhost::LocationResponse { + status: 200, + content_type: "text/plain".into(), + body: "ok".into(), + }); + c.vhosts[0].routes.push(custom); + let snapshot = compile(c, 1, &policy()).unwrap(); + + assert_eq!( + snapshot.select("localhost", "/test").unwrap().id, + "demo/test" + ); + assert_eq!( + snapshot.select("localhost", "/test/a").unwrap().id, + "demo/test" + ); + assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main"); + assert_eq!( + snapshot.select("localhost", "/abc").unwrap().id, + "demo/main" + ); + assert_eq!( + snapshot.select("localhost", "/static/a.js").unwrap().id, + "demo/main" + ); + assert!(snapshot.envelope.config.vhosts[0] + .routes + .iter() + .all(|route| !route.is_legacy_main_fallback())); +} + +#[test] +fn deliberate_root_custom_location_can_override_main_fallback() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let root = &mut c.vhosts[0].routes[0]; + root.response = Some(crate::vhost::LocationResponse { + status: 204, + content_type: "text/plain".into(), + body: String::new(), + }); + let snapshot = compile(c, 1, &policy()).unwrap(); + assert_eq!( + snapshot.select("localhost", "/anything").unwrap().id, + "demo/root" + ); +} + +#[test] +fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.administration.listen = Some("0.0.0.0:9090".parse().unwrap()); + c.administration.tls = true; + c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into(); + c.vhosts[0].backends[0].allow_private = true; + c.vhosts[0].backends[0].accept_local_listener_risk = true; + let snapshot = compile(c, 1, &policy()).unwrap(); + assert!(snapshot.pools[0].members[0].backend.targets_admin_listener); +} + +#[test] +fn backend_diagnostic_flags_compile_to_frontend() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backend_header = true; + c.vhosts[0].backend_timing_header = true; + let snapshot = compile(c, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let frontend = snapshot.frontend("localhost", local, false).unwrap(); + assert!(frontend.backend_header); + assert!(frontend.backend_timing_header); +} + + +#[test] +fn path_boundaries() { + assert!(prefix_matches("/api", "/api")); + assert!(prefix_matches("/api", "/api/users")); + assert!(!prefix_matches("/api", "/apix")); + assert!(prefix_matches("/", "/anything")); +} +#[test] +fn invalid_per_backend_health_policy_rejected_without_global_override() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck { + mode: crate::balance::HealthMode::Http, + interval_ms: 300, + timeout_ms: 400, + ..Default::default() + }); + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].backends[0].health_check.as_mut().unwrap().timeout_ms = 200; + assert!(compile(c, 1, &policy()).is_ok()); +} +#[test] +fn private_requires_opt_in_and_metadata_always_denied() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + c.vhosts[0].backends[0].allow_private = false; + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].backends[0].allow_private = true; + for target in [ + "http://169.254.169.254", + "http://100.100.100.200", + "http://0.0.0.0", + "http://127.0.0.1:9090", + "http://[::ffff:169.254.169.254]", + "http://[fe80::1]", + "http://user:pass@127.0.0.1:9000", + ] { + c.vhosts[0].backends[0].url = target.into(); + assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}"); + } + c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into(); + c.vhosts[0].backends[0].accept_local_listener_risk = true; + assert!(compile(c.clone(), 1, &policy()).is_ok()); + c.vhosts[0].backends[0].allow_private = false; + assert!(compile(c, 1, &policy()).is_err()); +} +#[test] +fn longest_prefix_and_aliases() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut specific = c.vhosts[0].routes[0].clone(); + specific.id = "api".into(); + specific.path_prefix = "/api".into(); + c.vhosts[0].routes.push(specific); + let s = compile(c, 7, &policy()).unwrap(); + assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api"); + assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main"); + assert!(s.select("unknown", "/").is_none()); + assert_eq!(s.envelope.revision, 7); +} +#[test] +fn nginx_style_location_precedence_exact_priority_regex_prefix() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].routes[0].id = "root".into(); + + let mut prefix = c.vhosts[0].routes[0].clone(); + prefix.id = "api-prefix".into(); + prefix.path_prefix = "/api".into(); + + let mut regex = c.vhosts[0].routes[0].clone(); + regex.id = "api-regex".into(); + regex.match_type = crate::vhost::PathMatch::Regex; + regex.path_prefix = r"^/api/v[0-9]+/".into(); + + let mut exact = c.vhosts[0].routes[0].clone(); + exact.id = "api-exact".into(); + exact.match_type = crate::vhost::PathMatch::Exact; + exact.path_prefix = "/api/v1/fixed".into(); + + let mut trailing_exact = c.vhosts[0].routes[0].clone(); + trailing_exact.id = "trailing-exact".into(); + trailing_exact.match_type = crate::vhost::PathMatch::Exact; + trailing_exact.path_prefix = "/trailing/".into(); + + c.vhosts[0] + .routes + .extend([prefix.clone(), regex, exact, trailing_exact]); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + assert_eq!( + snapshot.select("localhost", "/api/v1/fixed").unwrap().id, + "demo/api-exact" + ); + assert_eq!( + snapshot.select("localhost", "/api/v2/users").unwrap().id, + "demo/api-regex" + ); + assert_eq!( + snapshot.select("localhost", "/api/plain").unwrap().id, + "demo/api-prefix" + ); + assert_eq!( + snapshot.select("localhost", "/trailing/").unwrap().id, + "demo/trailing-exact" + ); + assert_eq!( + snapshot.select("localhost", "/trailing").unwrap().id, + "demo/main" + ); + + c.vhosts[0].routes.retain(|route| route.id != "api-prefix"); + prefix.match_type = crate::vhost::PathMatch::PrefixPriority; + prefix.id = "api-priority".into(); + c.vhosts[0].routes.push(prefix); + let snapshot = compile(c, 2, &policy()).unwrap(); + assert_eq!( + snapshot.select("localhost", "/api/v2/users").unwrap().id, + "demo/api-priority" + ); +} +#[test] +fn location_rewrites_are_normalized_and_regex_captures_expand() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let route = &mut c.vhosts[0].routes[0]; + route.match_type = crate::vhost::PathMatch::Regex; + route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into(); + route.rewrite = crate::vhost::PathRewrite { + mode: crate::vhost::RewriteMode::Regex, + value: "/service/${1}${2}".into(), + }; + let snapshot = compile(c, 1, &policy()).unwrap(); + let selected = snapshot.select("localhost", "/api/v42/items").unwrap(); + assert_eq!( + selected + .rewritten_path("/api/v42/items") + .unwrap() + .as_deref(), + Some("/service/42/items") + ); + assert_eq!( + selected + .rewritten_path("/api/v42/Ubuntu 14.04 LTS") + .unwrap() + .as_deref(), + Some("/service/42/Ubuntu 14.04 LTS") + ); +} +#[test] +fn custom_location_response_is_validated() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse { + status: 200, + content_type: "application/json".into(), + body: r#"{"ok":true}"#.into(), + }); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199; + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200; + c.vhosts[0].routes[0] + .response + .as_mut() + .unwrap() + .content_type = "text/plain\r\nx-bad: 1".into(); + assert!(compile(c, 1, &policy()).is_err()); +} +#[test] +fn local_response_hosts_reserve_frontends_and_do_not_require_backends() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.not_found_hosts = serde_json::from_value(serde_json::json!([{ + "id":"missing", + "name":"Missing site", + "frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]}, + "page":{"kind":"html","status":404,"html":"

Not found

"} + }])) + .unwrap(); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let host = snapshot + .not_found_host_on("missing.localhost", local, false) + .unwrap(); + assert_eq!(host.id, "missing"); + assert_eq!(host.page.status, Some(404)); + + c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()]; + assert!(compile(c, 2, &policy()).is_err()); +} + +#[test] +fn conflicts_are_rejected() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut duplicate = c.vhosts[0].routes[0].clone(); + duplicate.id = "other".into(); + c.vhosts[0].routes.push(duplicate); + assert!(compile(c, 0, &policy()).is_err()); +} +#[test] +fn tls12_only_vhost_override_is_valid_without_http3_listener() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy { + tls12: true, + tls13: false, + cipher_suites: vec![], + }); + assert!(compile(c, 1, &policy()).is_ok()); +} + +#[test] +fn persisted_remote_administration_requires_tls() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.administration.listen = Some("192.0.2.10:9090".parse().unwrap()); + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.administration.tls = true; + assert!(compile(c, 1, &policy()).is_ok()); +} +#[test] +fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() { + let mut p = policy(); + p.admin_addr = "0.0.0.0:9090".parse().unwrap(); + let c = demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(c.administration.listen.is_none()); + assert!(compile(c, 1, &p).is_ok()); +} + +#[test] +fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["example.test".into()]; + c.vhosts[0].www_redirect = true; + let snapshot = compile(c, 1, &policy()).unwrap(); + assert!(snapshot.select("www.example.test", "/path").is_some()); + let frontend = snapshot + .frontend("www.example.test", "127.0.0.1:8080".parse().unwrap(), false) + .unwrap(); + assert_eq!( + frontend.www_redirect.as_ref(), + Some(&("www.example.test".into(), "example.test".into())) + ); +} + +#[test] +fn multiple_domains_share_vhost_and_selected_canonical_redirect() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec![ + "suba.local".into(), "subb.local".into(), "site.test".into(), + ]; + c.vhosts[0].www_redirect = true; + c.vhosts[0].www_redirect_target = Some("site.test".into()); + let snapshot = compile(c, 1, &policy()).unwrap(); + for hostname in ["suba.local", "subb.local", "site.test", "www.site.test"] { + assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}"); + } + for hostname in ["suba.local", "subb.local", "www.site.test"] { + let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap(); + assert_eq!(frontend.www_redirect.as_ref(), + Some(&("www.site.test".into(), "site.test".into()))); + } +} + +#[test] +fn implicit_www_target_is_claimed_and_other_domain_stays_independent() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["suba.local".into(), "subb.local".into()]; + c.vhosts[0].www_redirect = true; + c.vhosts[0].www_redirect_target = Some("www.subb.local".into()); + let snapshot = compile(c, 1, &policy()).unwrap(); + for host in ["suba.local", "subb.local", "www.subb.local"] { + assert!(snapshot.select(host, "/path").is_some(), "{host}"); + } + assert!(snapshot.select("www.suba.local", "/path").is_none()); + let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap(); + assert_eq!(frontend.www_redirect.as_ref(), + Some(&(String::from("subb.local"), String::from("www.subb.local")))); +} + +#[test] +fn selected_www_redirect_target_must_belong_to_vhost() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["suba.local".into()]; + c.vhosts[0].www_redirect = true; + c.vhosts[0].www_redirect_target = Some("elsewhere.local".into()); + assert!(compile(c, 1, &policy()).is_err()); +} + +#[test] +fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; + c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http; + c.vhosts[0].load_balancing.health_check.host = None; + let snapshot = compile(c, 1, &policy()).unwrap(); + assert_eq!( + snapshot.pools[0].settings.health_check.host.as_deref(), + Some("app.example.test") + ); +} + +#[test] +fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; + assert_eq!(c.vhosts[0].load_balancing.health_check.mode, crate::balance::HealthMode::Tcp); + c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck { + mode: crate::balance::HealthMode::Http, path: "/healthz".into(), ..Default::default() + }); + let snapshot = compile(c, 1, &policy()).unwrap(); + let pool = &snapshot.pools[0]; + assert_eq!(pool.settings.health_for(&pool.members[0].definition).host.as_deref(), Some("app.example.test")); +} +#[test] +fn explicit_http_health_check_host_override_wins_over_vhost_hostname() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; + c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http; + c.vhosts[0].load_balancing.health_check.host = Some("health.example.test".into()); + let snapshot = compile(c, 1, &policy()).unwrap(); + assert_eq!( + snapshot.pools[0].settings.health_check.host.as_deref(), + Some("health.example.test") + ); +} + +#[test] +fn custom_location_redirect_supports_separate_capture_regex_and_query() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].routes = vec![crate::vhost::PathRoute { + id: "archives".into(), + path_prefix: r"\.(?:tar\.gz|zip?)$".into(), + match_type: crate::vhost::PathMatch::RegexInsensitive, + response: None, + redirect: Some(crate::vhost::LocationRedirect { + status: 301, + target: "https://storage.example.test/o/$1".into(), + rewrite_pattern: Some(r"^/(.*)$".into()), + preserve_query: true, + }), + ..Default::default() + }]; + let snapshot = compile(c, 1, &policy()).unwrap(); + let route = snapshot.select("localhost", "/repo/file.tar.gz").unwrap(); + let uri: http::Uri = "/repo/file.tar.gz?download=1".parse().unwrap(); + let location = route + .redirect_location("/repo/file.tar.gz", &uri) + .unwrap() + .unwrap(); + assert_eq!( + location, + "https://storage.example.test/o/repo/file.tar.gz?download=1" + ); +} + +#[test] +fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.php"), b", ipv6: Vec }, + Csv { ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]> }, Mmdb(mmdb::Database), } @@ -66,7 +68,7 @@ pub struct View { } impl Database { pub fn empty() -> Self { - Self { backend: Backend::Csv { ipv4: Vec::new(), ipv6: Vec::new() } } + Self { backend: Backend::Csv { ipv4: Box::new([]), ipv6: Box::new([]) } } } pub fn load(directory: &Path) -> Result { if directory.join("country.mmdb").is_file() { @@ -81,7 +83,9 @@ impl Database { let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?; let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?; ensure!(!ipv4.is_empty() && !ipv6.is_empty(), "GeoIP country database is empty"); - Ok(Self { backend: Backend::Csv { ipv4, ipv6 } }) + // Convert to exactly sized immutable slices: the CSV parser may have + // doubled Vec capacity many times during loading. + Ok(Self { backend: Backend::Csv { ipv4: ipv4.into_boxed_slice(), ipv6: ipv6.into_boxed_slice() } }) } DatabaseFormat::Mmdb => Ok(Self { backend: Backend::Mmdb(mmdb::Database::load(&directory.join("country.mmdb"), MAX_FILE_BYTES)?), @@ -118,7 +122,7 @@ impl Database { Backend::Csv { ipv4, ipv6 } => { let code = match ip { IpAddr::V4(ip) => find_v4(ipv4, u32::from(ip)), - IpAddr::V6(ip) => find_v6(ipv6, u128::from(ip)), + IpAddr::V6(ip) => find_v6(ipv6, u128::from(ip).to_be_bytes()), }?; Some(String::from_utf8_lossy(&code).into_owned()) } @@ -227,8 +231,8 @@ fn parse_v6(path: &Path) -> Result> { previous_end = Some(end); if let Some(country) = normalize_country_code(country) { result.push(V6Range { - start, - end, + start: start.to_be_bytes(), + end: end.to_be_bytes(), country, }); ensure!(result.len() <= MAX_RANGES, "too many GeoIP IPv6 ranges"); @@ -243,7 +247,7 @@ fn find_v4(ranges: &[V4Range], ip: u32) -> Option<[u8; 2]> { let item = ranges.get(index)?; (ip <= item.end).then_some(item.country) } -fn find_v6(ranges: &[V6Range], ip: u128) -> Option<[u8; 2]> { +fn find_v6(ranges: &[V6Range], ip: [u8; 16]) -> Option<[u8; 2]> { let index = ranges .partition_point(|item| item.start <= ip) .checked_sub(1)?; diff --git a/src/geoip/tests.rs b/src/geoip/tests.rs index dc63959..5d91bef 100644 --- a/src/geoip/tests.rs +++ b/src/geoip/tests.rs @@ -1,6 +1,24 @@ mod tests { use super::*; + #[test] + fn compact_ipv6_ranges_preserve_numeric_order() { + assert_eq!(std::mem::size_of::(), 34); + assert_eq!(std::mem::size_of::(), 12); + let ranges = [ + V6Range { start: 0u128.to_be_bytes(), end: 127u128.to_be_bytes(), country: *b"PL" }, + V6Range { start: 256u128.to_be_bytes(), end: 65535u128.to_be_bytes(), country: *b"DE" }, + V6Range { start: (1u128 << 120).to_be_bytes(), end: u128::MAX.to_be_bytes(), country: *b"US" }, + ]; + for (address, expected) in [ + (0, Some(*b"PL")), (127, Some(*b"PL")), (128, None), + (255, None), (256, Some(*b"DE")), (65535, Some(*b"DE")), + (65536, None), (1u128 << 120, Some(*b"US")), (u128::MAX, Some(*b"US")), + ] { + assert_eq!(find_v6(&ranges, address.to_be_bytes()), expected); + } + } + #[test] fn range_lookup_handles_gaps() { let directory = tempfile::tempdir().unwrap(); diff --git a/src/pages.rs b/src/pages.rs index 89e4200..50fa50f 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -8,26 +8,58 @@ use std::collections::BTreeMap; pub const MAX_HTML_BYTES: usize = 32 * 1024; pub const ERROR_CODES: [u16; 11] = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]; -pub const BUILTIN_ERROR_CODES: [u16; 6] = [401, 403, 404, 429, 503, 504]; +pub const BUILTIN_ERROR_CODES: [u16; 12] = [400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504]; /// Built-in operational gateway pages. They are deliberately not marked as /// `PageServed`, so an administrator-configured vhost/global error page can /// still replace them in the normal response policy pass. pub fn builtin_gateway_error(status: StatusCode) -> Option<&'static str> { match status { + StatusCode::BAD_REQUEST => Some(include_str!("admin/web/error_400.html")), StatusCode::UNAUTHORIZED => Some(include_str!("admin/web/error_401.html")), StatusCode::FORBIDDEN => Some(include_str!("admin/web/error_403.html")), StatusCode::NOT_FOUND => Some(include_str!("admin/web/error_404.html")), + StatusCode::REQUEST_TIMEOUT => Some(include_str!("admin/web/error_408.html")), + StatusCode::GONE => Some(include_str!("admin/web/error_410.html")), + StatusCode::PAYLOAD_TOO_LARGE => Some(include_str!("admin/web/error_413.html")), StatusCode::TOO_MANY_REQUESTS => Some(include_str!("admin/web/error_429.html")), + StatusCode::INTERNAL_SERVER_ERROR => Some(include_str!("admin/web/error_500.html")), + StatusCode::BAD_GATEWAY => Some(include_str!("admin/web/error_502.html")), StatusCode::SERVICE_UNAVAILABLE => Some(include_str!("admin/web/error_503.html")), StatusCode::GATEWAY_TIMEOUT => Some(include_str!("admin/web/error_504.html")), _ => None, } } fn builtin_application_page(status: StatusCode) -> String { - if let Some(page) = builtin_gateway_error(status) { return page.to_owned(); } - let reason = status.canonical_reason().unwrap_or("Application response"); - format!(r#"{code} {reason}

{code}

{reason}

RustProxy

"#, code=status.as_u16(), reason=reason) + if status == StatusCode::OK { + return include_str!("admin/web/status_200.html").to_owned(); + } + if let Some(page) = builtin_gateway_error(status) { + return page.to_owned(); + } + // A deliberately generic status still uses the *same* visual language + // as the packaged pages, rather than the former bright bare 200/4xx page. + // Code and canonical reason originate from http::StatusCode, never a request. + let reason = status.canonical_reason().unwrap_or("Response"); + format!(r#" + + + + + {code} - {reason} - RustProxy + + + +
+

{code} - {reason}

+

RustProxy generated this response.

+
+ +"#, code = status.as_u16(), reason = reason) } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] diff --git a/src/pages/tests.rs b/src/pages/tests.rs index fa6dffe..673c304 100644 --- a/src/pages/tests.rs +++ b/src/pages/tests.rs @@ -77,7 +77,21 @@ mod tests { assert!(builtin_gateway_error(StatusCode::GATEWAY_TIMEOUT) .unwrap() .contains("504 - Gateway timeout")); - assert!(builtin_gateway_error(StatusCode::BAD_GATEWAY).is_none()); + for (code, label) in [ + (StatusCode::BAD_REQUEST, "400 - Bad request"), + (StatusCode::REQUEST_TIMEOUT, "408 - Request timeout"), + (StatusCode::GONE, "410 - Gone"), + (StatusCode::PAYLOAD_TOO_LARGE, "413 - Payload too large"), + (StatusCode::INTERNAL_SERVER_ERROR, "500 - Internal server error"), + (StatusCode::BAD_GATEWAY, "502 - Bad gateway"), + ] { + let page = builtin_gateway_error(code).unwrap(); + assert!(page.contains(label)); + assert!(page.contains("background: #131615")); + } + assert!(builtin_gateway_error(StatusCode::OK).is_none()); + assert!(builtin_application_page(StatusCode::OK).contains("200 - OK")); + assert!(builtin_application_page(StatusCode::CREATED).contains("background: #131615")); } #[test] fn validated_bounded_pages_and_ip_detection() { diff --git a/src/web_server/tests.rs b/src/web_server/tests.rs index e1619b2..cd55974 100644 --- a/src/web_server/tests.rs +++ b/src/web_server/tests.rs @@ -1,4 +1,1396 @@ -include!("tests/part_01.rs"); -include!("tests/part_02.rs"); -include!("tests/part_03.rs"); -include!("tests/part_04.rs"); +use super::*; +use super::fastcgi::{ + fastcgi_body_stream, fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record, + FCGI_END_REQUEST, FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT, +}; +use axum::{body::Body, response::Response}; +use bytes::Bytes; +use futures_util::StreamExt; +use http::{ + header::{ + ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH, + CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE, + LAST_MODIFIED, LOCATION, RANGE, VARY, + }, + HeaderMap, HeaderValue, Method, StatusCode, Uri, Version, +}; +use http_body_util::BodyExt; +use std::{collections::BTreeMap, path::Path, time::Duration}; +use tempfile::TempDir; +use tokio::{io::AsyncWriteExt, sync::mpsc, time::Instant}; + +fn service(root: &Path, listing: bool) -> CompiledService { + Service { + id: "site".into(), + name: "Site".into(), + document_root: root.to_path_buf(), + index: vec!["index.html".into(), "index.htm".into()], + directory_listing: listing, + ..Default::default() + } + .compile() + .unwrap() +} + +fn request(method: Method, uri: &str) -> http::Request<()> { + http::Request::builder() + .method(method) + .uri(uri) + .body(()) + .unwrap() +} + +async fn serve_request( + service: &CompiledService, + request: &http::Request<()>, + path: &str, +) -> Response { + serve_body_request(service, request, path, Body::empty()).await +} + +async fn serve_body_request( + service: &CompiledService, + request: &http::Request<()>, + path: &str, + body: Body, +) -> Response { + serve( + service, + request.method().clone(), + request.headers().clone(), + request.uri().clone(), + path, + body, + RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: Some(12345), + server_name: "example.test".into(), + server_port: 80, + tls: false, + version: Version::HTTP_11, + }, + ) + .await +} + +#[tokio::test] +async fn get_head_index_and_directory_redirect_work() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("dir")).unwrap(); + std::fs::write(tmp.path().join("index.html"), b"root").unwrap(); + std::fs::write(tmp.path().join("dir/index.html"), b"nested").unwrap(); + let service = service(tmp.path(), false); + + let response = serve_request(&service, &request(Method::GET, "/"), "/").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "root"); + + let response = serve_request(&service, &request(Method::HEAD, "/"), "/").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_LENGTH], "4"); + assert!(response.into_body().collect().await.unwrap().to_bytes().is_empty()); + + let response = serve_request(&service, &request(Method::GET, "/dir?x=1"), "/dir").await; + assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY); + assert_eq!(response.headers()[LOCATION], "/dir/?x=1"); +} + +#[tokio::test] +async fn missing_files_and_disabled_directory_listing_return_normal_errors() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("empty")).unwrap(); + let service = service(tmp.path(), false); + + let missing = serve_request(&service, &request(Method::GET, "/missing.txt"), "/missing.txt").await; + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + + let directory = serve_request(&service, &request(Method::GET, "/empty/"), "/empty/").await; + assert_eq!(directory.status(), StatusCode::FORBIDDEN); + assert_eq!(directory.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + let directory_body = directory.into_body().collect().await.unwrap().to_bytes(); + let directory_body = std::str::from_utf8(&directory_body).unwrap(); + assert!(directory_body.contains("403")); + assert!(!directory_body.contains("directory listing is disabled")); + + let directory_head = serve_request(&service, &request(Method::HEAD, "/empty/"), "/empty/").await; + assert_eq!(directory_head.status(), StatusCode::FORBIDDEN); + assert!(directory_head.into_body().collect().await.unwrap().to_bytes().is_empty()); + + let method = serve_request(&service, &request(Method::POST, "/missing.txt"), "/missing.txt").await; + assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(method.headers()[ALLOW], "GET, HEAD"); +} + +#[tokio::test] +async fn last_modified_condition_returns_304() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("file.txt"), b"validator").unwrap(); + let service = service(tmp.path(), false); + let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await; + let last_modified = first.headers()[LAST_MODIFIED].clone(); + assert_eq!(first.headers()[CONTENT_TYPE], "text/plain; charset=utf-8"); + assert_eq!(first.headers()[CONTENT_LENGTH], "9"); + + let mut conditional = request(Method::GET, "/file.txt"); + conditional + .headers_mut() + .insert(IF_MODIFIED_SINCE, last_modified); + let response = serve_request(&service, &conditional, "/file.txt").await; + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + assert!(response.headers().contains_key(LAST_MODIFIED)); +} + +#[tokio::test] +async fn validators_and_single_ranges_work() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap(); + let service = service(tmp.path(), false); + let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await; + let etag = first.headers()[ETAG].clone(); + assert_eq!(first.headers()[ACCEPT_RANGES], "bytes"); + + let mut conditional = request(Method::GET, "/file.txt"); + conditional.headers_mut().insert(IF_NONE_MATCH, etag); + assert_eq!( + serve_request(&service, &conditional, "/file.txt").await.status(), + StatusCode::NOT_MODIFIED + ); + + let mut ranged = request(Method::GET, "/file.txt"); + ranged.headers_mut().insert(RANGE, "bytes=2-5".parse().unwrap()); + let response = serve_request(&service, &ranged, "/file.txt").await; + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[CONTENT_RANGE], "bytes 2-5/10"); + assert_eq!(response.headers()[CONTENT_LENGTH], "4"); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "2345"); + + let mut invalid = request(Method::GET, "/file.txt"); + invalid.headers_mut().insert(RANGE, "bytes=99-100".parse().unwrap()); + let response = serve_request(&service, &invalid, "/file.txt").await; + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10"); + + let mut if_range_match = request(Method::GET, "/file.txt"); + if_range_match.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap()); + if_range_match.headers_mut().insert(IF_RANGE, first.headers()[ETAG].clone()); + assert_eq!(serve_request(&service, &if_range_match, "/file.txt").await.status(), StatusCode::PARTIAL_CONTENT); + + let mut if_range_miss = request(Method::GET, "/file.txt"); + if_range_miss.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap()); + if_range_miss.headers_mut().insert(IF_RANGE, "\"different\"".parse().unwrap()); + let response = serve_request(&service, &if_range_miss, "/file.txt").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_LENGTH], "10"); +} + +#[tokio::test] +async fn multipart_byte_ranges_stream_each_requested_part() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap(); + let service = service(tmp.path(), false); + let mut ranged = request(Method::GET, "/file.txt"); + ranged + .headers_mut() + .insert(RANGE, "bytes=0-1,6-8".parse().unwrap()); + let response = serve_request(&service, &ranged, "/file.txt").await; + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap().to_owned(); + assert!(content_type.starts_with("multipart/byteranges; boundary=")); + assert!(!response.headers().contains_key(CONTENT_RANGE)); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8(body.to_vec()).unwrap(); + assert!(body.contains("Content-Range: bytes 0-1/10")); + assert!(body.contains("Content-Range: bytes 6-8/10")); + assert!(body.contains("\r\n01\r\n")); + assert!(body.contains("\r\n678\r\n")); +} + +#[tokio::test] +async fn too_many_byte_ranges_are_rejected() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap(); + let service = service(tmp.path(), false); + let ranges = std::iter::repeat("0-0").take(17).collect::>().join(","); + let mut ranged = request(Method::GET, "/file.txt"); + ranged + .headers_mut() + .insert(RANGE, format!("bytes={ranges}").parse().unwrap()); + let response = serve_request(&service, &ranged, "/file.txt").await; + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10"); +} + +#[tokio::test] +async fn listing_escapes_html_and_encodes_urls() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a & b.txt"), b"x").unwrap(); + let service = service(tmp.path(), true); + let response = serve_request(&service, &request(Method::GET, "/"), "/").await; + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body = std::str::from_utf8(&body).unwrap(); + assert!(body.contains("a & b.txt")); + assert!(body.contains("a%20%26%20b.txt")); + assert!(!body.contains(tmp.path().to_string_lossy().as_ref())); +} + +#[tokio::test] +async fn root_directory_listing_is_repeatable_across_refreshes() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("first.txt"), b"one").unwrap(); + std::fs::write(tmp.path().join("second.txt"), b"two").unwrap(); + let mut configured = Service { + id: "repeat-listing".into(), + name: "Repeat listing".into(), + document_root: tmp.path().to_path_buf(), + index: Vec::new(), + directory_listing: true, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + + for _ in 0..2 { + let response = serve_request(&service, &request(Method::GET, "/"), "/").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body = std::str::from_utf8(&body).unwrap(); + assert!(body.contains("first.txt")); + assert!(body.contains("second.txt")); + } +} + +#[tokio::test] +async fn index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("with-index")).unwrap(); + std::fs::create_dir(tmp.path().join("without-index")).unwrap(); + std::fs::write(tmp.path().join("with-index/index.html"), b"INDEX-WINS").unwrap(); + std::fs::write(tmp.path().join("with-index/other.txt"), b"hidden by index").unwrap(); + std::fs::write(tmp.path().join("without-index/listed.txt"), b"listed").unwrap(); + let service = service(tmp.path(), true); + + let indexed = serve_request( + &service, + &request(Method::GET, "/with-index/"), + "/with-index/", + ) + .await; + assert_eq!(indexed.status(), StatusCode::OK); + assert_eq!( + indexed.into_body().collect().await.unwrap().to_bytes(), + "INDEX-WINS" + ); + + let listed = serve_request( + &service, + &request(Method::GET, "/without-index/"), + "/without-index/", + ) + .await; + assert_eq!(listed.status(), StatusCode::OK); + let body = listed.into_body().collect().await.unwrap().to_bytes(); + let body = std::str::from_utf8(&body).unwrap(); + assert!(body.contains("listed.txt")); + assert!(body.contains("DIRECTORY LISTING")); +} + +#[tokio::test] +async fn mime_overrides_default_mime_and_cache_control_are_applied_to_files() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("asset.foo"), b"foo").unwrap(); + std::fs::write(tmp.path().join("asset.unknown"), b"unknown").unwrap(); + let mut configured = Service { + id: "mime".into(), + name: "MIME".into(), + document_root: tmp.path().to_path_buf(), + mime_types: BTreeMap::from([(".FOO".into(), "application/x-foo".into())]), + default_mime_type: "application/x-fallback".into(), + cache_control: Some("public, max-age=3600".into()), + ..Default::default() + }; + configured.validate().unwrap(); + assert!(configured.mime_types.contains_key("foo")); + let service = configured.compile().unwrap(); + + let custom = serve_request(&service, &request(Method::GET, "/asset.foo"), "/asset.foo").await; + assert_eq!(custom.headers()[CONTENT_TYPE], "application/x-foo"); + assert_eq!(custom.headers()[CACHE_CONTROL], "public, max-age=3600"); + + let fallback = serve_request( + &service, + &request(Method::GET, "/asset.unknown"), + "/asset.unknown", + ) + .await; + assert_eq!(fallback.headers()[CONTENT_TYPE], "application/x-fallback"); + assert_eq!(fallback.headers()[CACHE_CONTROL], "public, max-age=3600"); +} + +#[tokio::test] +async fn try_files_rewrites_cache_rules_headers_and_downloads_work() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"SPA").unwrap(); + std::fs::write(tmp.path().join("style.css"), b"css").unwrap(); + std::fs::write(tmp.path().join("archive.zip"), b"zip").unwrap(); + let mut configured = Service { + id: "routing".into(), + name: "Routing".into(), + document_root: tmp.path().to_path_buf(), + try_files: vec!["$uri".into(), "/index.html?$query_string".into()], + rewrites: vec![ + RewriteRule { + pattern: "^/internal$".into(), + replacement: "/style.css".into(), + status: None, + }, + RewriteRule { + pattern: "^/old/(.*)$".into(), + replacement: "/new/$1".into(), + status: Some(301), + }, + ], + cache_control: Some("no-cache".into()), + cache_rules: vec![CacheRule { + matcher: "ext:css".into(), + cache_control: "public, max-age=3600".into(), + }], + response_headers: BTreeMap::from([("x-static-service".into(), "yes".into())]), + download_extensions: vec!["zip".into()], + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + + let spa = serve_request( + &service, + &request(Method::GET, "/missing?x=1"), + "/missing", + ) + .await; + assert_eq!(spa.status(), StatusCode::OK); + assert_eq!(spa.headers()[CACHE_CONTROL], "no-cache"); + assert_eq!(spa.headers()["x-static-service"], "yes"); + assert_eq!(spa.into_body().collect().await.unwrap().to_bytes(), "SPA"); + + let internal = serve_request( + &service, + &request(Method::GET, "/internal"), + "/internal", + ) + .await; + assert_eq!(internal.status(), StatusCode::OK); + assert_eq!(internal.headers()[CONTENT_TYPE], "text/css; charset=utf-8"); + assert_eq!(internal.headers()[CACHE_CONTROL], "public, max-age=3600"); + + let redirect = serve_request( + &service, + &request(Method::GET, "/old/path"), + "/old/path", + ) + .await; + assert_eq!(redirect.status(), StatusCode::MOVED_PERMANENTLY); + assert_eq!(redirect.headers()[LOCATION], "/new/path"); + + let download = serve_request( + &service, + &request(Method::GET, "/archive.zip"), + "/archive.zip", + ) + .await; + assert!(download.headers()[CONTENT_DISPOSITION] + .to_str() + .unwrap() + .starts_with("attachment;")); +} + +#[tokio::test] +async fn precompressed_sidecars_keep_mime_validators_and_range_semantics() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("app.js"), b"ORIGINAL").unwrap(); + std::fs::write(tmp.path().join("app.js.br"), b"BROTLI").unwrap(); + let mut configured = Service { + id: "compressed".into(), + name: "Compressed".into(), + document_root: tmp.path().to_path_buf(), + precompressed: Precompressed { + enabled: true, + brotli: true, + gzip: false, + }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + + let mut accepted = request(Method::GET, "/app.js"); + accepted + .headers_mut() + .insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap()); + let response = serve_request(&service, &accepted, "/app.js").await; + assert_eq!(response.headers()[CONTENT_ENCODING], "br"); + assert_eq!(response.headers()[CONTENT_TYPE], "text/javascript; charset=utf-8"); + assert_eq!(response.headers()[VARY], "Accept-Encoding"); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BROTLI"); + + let mut ranged = request(Method::GET, "/app.js"); + ranged + .headers_mut() + .insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap()); + ranged + .headers_mut() + .insert(RANGE, "bytes=0-2".parse().unwrap()); + let response = serve_request(&service, &ranged, "/app.js").await; + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[CONTENT_RANGE], "bytes 0-2/6"); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BRO"); + + let plain = serve_request(&service, &request(Method::GET, "/app.js"), "/app.js").await; + assert!(!plain.headers().contains_key(CONTENT_ENCODING)); + assert_eq!(plain.into_body().collect().await.unwrap().to_bytes(), "ORIGINAL"); +} + +async fn fastcgi_fixture(response_body: &'static [u8]) -> std::net::SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap(); + if kind == FCGI_STDIN && content.is_empty() { + break; + } + } + let mut stdout = b"Status: 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nX-PHP-FPM: fixture\r\n\r\n".to_vec(); + stdout.extend_from_slice(response_body); + write_fcgi_record(&mut stream, FCGI_STDOUT, &stdout).await.unwrap(); + write_fcgi_record(&mut stream, FCGI_STDOUT, &[]).await.unwrap(); + write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]).await.unwrap(); + stream.flush().await.unwrap(); + }); + address +} + +#[tokio::test] +async fn fastcgi_stream_can_be_polled_repeatedly_after_eof() { + let (tx, rx) = mpsc::channel(1); + tx.send(Ok(Bytes::from_static(b"data"))).await.unwrap(); + drop(tx); + let mut body = Box::pin(fastcgi_body_stream(rx)); + assert_eq!(body.next().await.unwrap().unwrap(), Bytes::from_static(b"data")); + assert!(body.next().await.is_none()); + // Without `fuse` this panics inside futures-util::stream::unfold. + assert!(body.next().await.is_none()); +} + +#[tokio::test] +async fn php_fpm_gzip_response_finishes_after_fastcgi_eof() { + use std::io::Read; + + let expected = b"FastCGI compressed response regression: 0123456789 0123456789 0123456789"; + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.php"), b"{}", env!("CARGO_PKG_VERSION")))); +} + +#[tokio::test] +async fn traversal_and_symlink_escape_are_not_served() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("ok.txt"), b"ok").unwrap(); + let service = service(tmp.path(), false); + assert_eq!( + serve_request(&service, &request(Method::GET, "/../secret"), "/../secret") + .await + .status(), + StatusCode::BAD_REQUEST + ); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let outside = TempDir::new().unwrap(); + std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap(); + symlink(outside.path().join("secret.txt"), tmp.path().join("escape.txt")).unwrap(); + let response = serve_request( + &service, + &request(Method::GET, "/escape.txt"), + "/escape.txt", + ) + .await; + assert_ne!(response.status(), StatusCode::OK); + } +} +#[tokio::test] +async fn htaccess_wordpress_style_front_controller_and_protected_files_work() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"front-controller").unwrap(); + std::fs::write(tmp.path().join("existing.txt"), b"existing").unwrap(); + std::fs::write( + tmp.path().join(".htaccess"), + br#" +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.html [L] + +"#, + ) + .unwrap(); + let mut configured = Service { + id: "htaccess-rewrite".into(), + name: "htaccess rewrite".into(), + document_root: tmp.path().to_path_buf(), + htaccess: htaccess::Config { enabled: true, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + + let rewritten = serve_request(&service, &request(Method::GET, "/missing"), "/missing").await; + assert_eq!(rewritten.status(), StatusCode::OK); + assert_eq!(rewritten.into_body().collect().await.unwrap().to_bytes(), "front-controller"); + + let existing = serve_request(&service, &request(Method::GET, "/existing.txt"), "/existing.txt").await; + assert_eq!(existing.status(), StatusCode::OK); + assert_eq!(existing.into_body().collect().await.unwrap().to_bytes(), "existing"); + + let protected = serve_request(&service, &request(Method::GET, "/.htaccess"), "/.htaccess").await; + assert_eq!(protected.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn htaccess_directory_index_mime_headers_expires_and_nested_scope_work() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("docs")).unwrap(); + std::fs::write(tmp.path().join("docs/home.foo"), b"nested").unwrap(); + std::fs::write( + tmp.path().join("docs/.htaccess"), + br#"DirectoryIndex home.foo +Options -Indexes +AddType text/x-foo .foo +Header always set X-Htaccess yes +ExpiresActive On +ExpiresByType text/x-foo "access plus 1 hour" +"#, + ) + .unwrap(); + let mut configured = Service { + id: "htaccess-nested".into(), + name: "htaccess nested".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: true, + htaccess: htaccess::Config { enabled: true, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + let response = serve_request(&service, &request(Method::GET, "/docs/"), "/docs/").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/x-foo"); + assert_eq!(response.headers()["x-htaccess"], "yes"); + assert_eq!(response.headers()[CACHE_CONTROL], "public, max-age=3600"); + assert!(response.headers().contains_key(http::header::EXPIRES)); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "nested"); +} + +#[tokio::test] +async fn php_source_extensions_are_not_served_without_php_fpm() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.php"), b" +SetHandler application/x-httpd-php + +"#, + ) + .unwrap(); + let mut configured = Service { + id: "htaccess-filesmatch".into(), + name: "htaccess filesmatch".into(), + document_root: tmp.path().to_path_buf(), + htaccess: htaccess::Config { enabled: true, strict: false, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let error = configured.compile().unwrap_err().to_string(); + assert!(error.contains("Files/FilesMatch")); +} + +#[test] +fn htaccess_strict_mode_rejects_unsupported_directives() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); + std::fs::write(tmp.path().join(".htaccess"), b"ErrorDocument 404 /404.html\n").unwrap(); + let mut configured = Service { + id: "htaccess-strict".into(), + name: "htaccess strict".into(), + document_root: tmp.path().to_path_buf(), + htaccess: htaccess::Config { enabled: true, strict: true, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + assert!(configured.compile().unwrap_err().to_string().contains("ErrorDocument")); +} + +#[cfg(unix)] +#[test] +fn htaccess_configuration_file_cannot_be_a_symlink() { + use std::os::unix::fs::symlink; + let tmp = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + std::fs::write(outside.path().join("outside.conf"), b"RewriteEngine On\n").unwrap(); + symlink(outside.path().join("outside.conf"), tmp.path().join(".htaccess")).unwrap(); + let mut configured = Service { + id: "htaccess-symlink".into(), + name: "htaccess symlink".into(), + document_root: tmp.path().to_path_buf(), + htaccess: htaccess::Config { enabled: true, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let error = configured.compile().unwrap_err().to_string(); + assert!(error.contains("must not be a symlink")); +} + +#[test] +fn fastcgi_never_forwards_proxy_header_as_http_proxy() { + let tmp = TempDir::new().unwrap(); + let mut configured = Service { + id: "fastcgi-params".into(), + name: "FastCGI params".into(), + document_root: tmp.path().to_path_buf(), + php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + let php = service.php.as_ref().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("proxy", HeaderValue::from_static("http://attacker.invalid:8080")); + headers.insert("user-agent", HeaderValue::from_static("security-test")); + let uri: Uri = "/index.php?q=1".parse().unwrap(); + let encoded = fastcgi_params( + &service, + php, + &Method::GET, + &headers, + &uri, + "/index.php", + None, + Some("q=1"), + &RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: Some(12345), + server_name: "example.test".into(), + server_port: 80, + tls: false, + version: Version::HTTP_11, + }, + ) + .unwrap(); + assert!(!encoded.windows(b"HTTP_PROXY".len()).any(|window| window == b"HTTP_PROXY")); + assert!(encoded.windows(b"HTTP_USER_AGENT".len()).any(|window| window == b"HTTP_USER_AGENT")); +} + +#[test] +fn php_custom_fastcgi_params_and_php_values_are_validated() { + let tmp = TempDir::new().unwrap(); + let mut configured = Service { + id: "php-custom".into(), + name: "PHP custom".into(), + document_root: tmp.path().to_path_buf(), + php: Php { + enabled: true, + endpoint: "tcp:127.0.0.1:9000".into(), + pass_authorization: false, + custom_params: BTreeMap::from([("app_env".into(), "production".into())]), + php_values: BTreeMap::from([("memory_limit".into(), "256M".into())]), + ..Default::default() + }, + ..Default::default() + }; + configured.validate().unwrap(); + assert!(configured.php.custom_params.contains_key("APP_ENV")); + let service = configured.compile().unwrap(); + let php = service.php.as_ref().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("Bearer secret")); + let uri: Uri = "/index.php".parse().unwrap(); + let encoded = fastcgi_params( + &service, + php, + &Method::GET, + &headers, + &uri, + "/index.php", + None, + None, + &RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: None, + server_name: "example.test".into(), + server_port: 443, + tls: true, + version: Version::HTTP_2, + }, + ) + .unwrap(); + assert!(encoded.windows(b"APP_ENV".len()).any(|window| window == b"APP_ENV")); + assert!(encoded.windows(b"production".len()).any(|window| window == b"production")); + assert!(encoded.windows(b"PHP_VALUE".len()).any(|window| window == b"PHP_VALUE")); + assert!(encoded.windows(b"memory_limit=256M".len()).any(|window| window == b"memory_limit=256M")); + assert!(!encoded.windows(b"HTTP_AUTHORIZATION".len()).any(|window| window == b"HTTP_AUTHORIZATION")); +} + +#[test] +fn fastcgi_path_info_keeps_script_filename_separate() { + let tmp = TempDir::new().unwrap(); + let mut configured = Service { + id: "php-path-info".into(), + name: "PHP PATH_INFO".into(), + document_root: tmp.path().to_path_buf(), + php: Php { + enabled: true, + path_info: true, + endpoint: "tcp:127.0.0.1:9000".into(), + ..Default::default() + }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + assert!(service.php.as_ref().unwrap().path_info); + let uri: Uri = "/index.php/foo/bar?q=1".parse().unwrap(); + let encoded = fastcgi_params( + &service, + service.php.as_ref().unwrap(), + &Method::GET, + &HeaderMap::new(), + &uri, + "/index.php", + Some("/foo/bar"), + Some("q=1"), + &RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: Some(12345), + server_name: "example.test".into(), + server_port: 80, + tls: false, + version: Version::HTTP_11, + }, + ) + .unwrap(); + assert!(encoded.windows(b"PATH_INFO".len()).any(|window| window == b"PATH_INFO")); + assert!(encoded.windows(b"/foo/bar".len()).any(|window| window == b"/foo/bar")); + assert!(encoded.windows(b"SCRIPT_FILENAME".len()).any(|window| window == b"SCRIPT_FILENAME")); +} + +#[test] +fn php_custom_fastcgi_params_cannot_override_core_or_php_admin_values() { + let tmp = TempDir::new().unwrap(); + for name in ["SCRIPT_FILENAME", "HTTP_HOST", "PHP_VALUE", "PHP_ADMIN_VALUE"] { + let mut configured = Service { + id: "php-reserved".into(), + name: "PHP reserved".into(), + document_root: tmp.path().to_path_buf(), + php: Php { + custom_params: BTreeMap::from([(name.into(), "bad".into())]), + ..Default::default() + }, + ..Default::default() + }; + assert!(configured.validate().unwrap_err().to_string().contains("reserved name")); + } +} + +#[test] +fn php_custom_fastcgi_data_has_a_combined_bound() { + let tmp = TempDir::new().unwrap(); + let custom_params = (0..17) + .map(|index| (format!("APP_{index}"), "x".repeat(8192))) + .collect(); + let mut configured = Service { + id: "php-custom-bound".into(), + name: "PHP custom bound".into(), + document_root: tmp.path().to_path_buf(), + php: Php { custom_params, ..Default::default() }, + ..Default::default() + }; + assert!(configured + .validate() + .unwrap_err() + .to_string() + .contains("at most 128 KiB")); +} + +#[test] +fn fastcgi_response_cannot_control_downstream_framing_or_hop_headers() { + let (_, headers) = parse_cgi_headers( + b"Content-Type: text/plain\r\nContent-Length: 999999\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nTE: trailers\r\nTrailer: X-Test\r\nProxy-Authenticate: Basic realm=x\r\nProxy-Authorization: secret\r\nX-App: ok\r\n", + ) + .unwrap(); + assert_eq!(headers.get("x-app").unwrap(), "ok"); + for name in [ + "content-length", + "transfer-encoding", + "connection", + "te", + "trailer", + "proxy-authenticate", + "proxy-authorization", + ] { + assert!(!headers.contains_key(name), "unexpected FastCGI framing header: {name}"); + } +} + +#[test] +fn fastcgi_response_rejects_informational_status_codes() { + let error = parse_cgi_headers(b"Status: 101 Switching Protocols\r\nContent-Type: text/plain\r\n") + .unwrap_err() + .to_string(); + assert!(error.contains("unsupported informational/invalid status")); +} + +#[tokio::test] +async fn hardening_blocks_common_probe_paths_and_trace() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); + std::fs::write(tmp.path().join(".env"), b"secret").unwrap(); + std::fs::write(tmp.path().join("backup.sql"), b"secret").unwrap(); + std::fs::write(tmp.path().join("public.txt"), b"public").unwrap(); + let mut configured = Service { + id: "hardened".into(), + name: "hardened".into(), + document_root: tmp.path().to_path_buf(), + index: vec![], + directory_listing: true, + hardening: Hardening { block_common_attacks: true, strip_unnecessary_headers: false }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + for path in ["/.env", "/wp-config.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php", "/backup.sql"] { + let response = serve_request(&service, &request(Method::GET, path), path).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); + } + let trace = serve_request(&service, &request(Method::TRACE, "/"), "/").await; + assert_eq!(trace.status(), StatusCode::FORBIDDEN); + let listing = serve_request(&service, &request(Method::GET, "/"), "/").await; + let listing = String::from_utf8_lossy(&listing.into_body().collect().await.unwrap().to_bytes()).to_string(); + assert!(listing.contains("public.txt")); + assert!(!listing.contains(".env")); + assert!(!listing.contains("backup.sql")); + let well_known = serve_request(&service, &request(Method::GET, "/.well-known/missing"), "/.well-known/missing").await; + assert_eq!(well_known.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn hardening_strips_fingerprint_headers_after_source_rules() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); + let mut configured = Service { + id: "headers".into(), + name: "headers".into(), + document_root: tmp.path().to_path_buf(), + response_headers: BTreeMap::from([ + ("server".into(), "Example/1.0".into()), + ("x-powered-by".into(), "PHP/8.4".into()), + ("x-custom".into(), "kept".into()), + ]), + hardening: Hardening { block_common_attacks: false, strip_unnecessary_headers: true }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + let response = serve_request(&service, &request(Method::GET, "/index.html"), "/index.html").await; + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key("server")); + assert!(!response.headers().contains_key("x-powered-by")); + assert_eq!(response.headers()["x-custom"], "kept"); +} + +#[test] +fn php_admin_values_are_operator_only_and_serialized() { + let tmp = TempDir::new().unwrap(); + let mut configured = Service { + id: "php-admin".into(), name: "PHP admin".into(), + document_root: tmp.path().to_path_buf(), + php: Php { + enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), + php_admin_values: BTreeMap::from([("open_basedir".into(), "/var/www/webmail/:/tmp/".into())]), + ..Default::default() + }, ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + let encoded = fastcgi_params( + &service, service.php.as_ref().unwrap(), &Method::GET, &HeaderMap::new(), + &"/index.php".parse().unwrap(), "/index.php", None, None, + &RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: None, + server_name: "webmail.example.test".into(), server_port: 8589, tls: false, + version: Version::HTTP_11 }, + ).unwrap(); + assert!(encoded.windows(b"PHP_ADMIN_VALUE".len()).any(|w| w == b"PHP_ADMIN_VALUE")); + assert!(encoded.windows(b"open_basedir=/var/www/webmail/:/tmp/".len()) + .any(|w| w == b"open_basedir=/var/www/webmail/:/tmp/")); + configured.php.php_values.insert("open_basedir".into(), "/".into()); + assert!(configured.validate().is_err()); + configured.php.php_values.clear(); + configured.php.php_admin_values.insert("unsafe".into(), "value\nother=1".into()); + assert!(configured.validate().is_err()); +} + +#[test] +fn fastcgi_always_sets_http_host_for_http2_http3_wordpress_requests() { + let tmp = TempDir::new().unwrap(); + let mut configured = Service { + id: "host-fallback".into(), + name: "Host fallback".into(), + document_root: tmp.path().to_path_buf(), + php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + let php = service.php.as_ref().unwrap(); + let uri: Uri = "/wp-admin/index.php".parse().unwrap(); + for version in [Version::HTTP_2, Version::HTTP_3] { + let encoded = fastcgi_params( + &service, + php, + &Method::GET, + &HeaderMap::new(), + &uri, + "/wp-admin/index.php", + None, + None, + &RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: Some(12345), + server_name: "example.test".into(), + server_port: 443, + tls: true, + version, + }, + ).unwrap(); + assert!(encoded.windows(b"HTTP_HOST".len()).any(|w| w == b"HTTP_HOST")); + assert!(encoded.windows(b"example.test".len()).any(|w| w == b"example.test")); + } +} + +#[test] +fn fastcgi_http_host_keeps_explicit_host_and_port() { + let tmp = TempDir::new().unwrap(); + let mut configured = Service { + id: "host-port".into(), + name: "Host port".into(), + document_root: tmp.path().to_path_buf(), + php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(http::header::HOST, HeaderValue::from_static("example.test:8443")); + let encoded = fastcgi_params( + &service, + service.php.as_ref().unwrap(), + &Method::GET, + &headers, + &"/index.php".parse().unwrap(), + "/index.php", + None, + None, + &RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: None, + server_name: "example.test".into(), + server_port: 443, + tls: true, + version: Version::HTTP_11, + }, + ).unwrap(); + assert!(encoded.windows(b"HTTP_HOST".len()).any(|w| w == b"HTTP_HOST")); + assert!(encoded.windows(b"example.test:8443".len()).any(|w| w == b"example.test:8443")); +} + +// Decode the actual FastCGI PARAMS records received by a mock PHP-FPM peer. +// This tests the Web Service dispatch and parameter generation together. +fn decode_fastcgi_params(mut bytes: &[u8]) -> BTreeMap { + fn read_len(bytes: &mut &[u8]) -> usize { + let prefix = bytes[0]; + if prefix & 0x80 == 0 { + *bytes = &bytes[1..]; + prefix as usize + } else { + let value = u32::from_be_bytes(bytes[..4].try_into().unwrap()) & 0x7fff_ffff; + *bytes = &bytes[4..]; + value as usize + } + } + let mut result = BTreeMap::new(); + while !bytes.is_empty() { + let name_len = read_len(&mut bytes); + let value_len = read_len(&mut bytes); + let name = std::str::from_utf8(&bytes[..name_len]).unwrap().to_owned(); + let value = std::str::from_utf8(&bytes[name_len..name_len + value_len]).unwrap().to_owned(); + bytes = &bytes[name_len + value_len..]; + result.insert(name, value); + } + result +} + +async fn fastcgi_fixture_record_params() -> ( + std::net::SocketAddr, + tokio::sync::oneshot::Receiver>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let mut params = Vec::new(); + loop { + let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap(); + if kind == FCGI_PARAMS { + params.extend_from_slice(&content); + } + if kind == FCGI_STDIN && content.is_empty() { + break; + } + } + let _ = tx.send(decode_fastcgi_params(¶ms)); + write_fcgi_record(&mut stream, FCGI_STDOUT, + b"Status: 200 OK\r\nContent-Type: text/css\r\n\r\nPHP-STATIC-OK").await.unwrap(); + write_fcgi_record(&mut stream, FCGI_STDOUT, &[]).await.unwrap(); + write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]).await.unwrap(); + stream.flush().await.unwrap(); + }); + (address, rx) +} + +#[tokio::test] +async fn roundcube_static_php_path_info_runs_before_front_controller_try_files() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("static.php"), b" str: preflight(root) manifest = tomllib.loads((root / "Cargo.toml").read_text()) -assert manifest["package"]["version"] == "0.31.7" +assert manifest["package"]["version"] == "0.31.8" assert manifest["dependencies"]["quinn"]["version"] == "0.11.12" h3dep = manifest["dependencies"]["h3"] assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"] @@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"] lock_path = root / "Cargo.lock" if lock_path.exists(): lock = lock_path.read_text() - assert 'name = "rust-proxy"\nversion = "0.31.7"' in lock + assert 'name = "rust-proxy"\nversion = "0.31.8"' in lock assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries" for release_doc, marker in { - "README.md": "# RustProxy 0.31.7", - "CHANGELOG.md": "## 0.31.7", - "ROADMAP.md": "## 0.31.7", - "AGENTS.md": "Current source version: 0.31.7.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.7", + "README.md": "# RustProxy 0.31.8", + "CHANGELOG.md": "## 0.31.8", + "ROADMAP.md": "## 0.31.8", + "AGENTS.md": "Current source version: 0.31.8.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.8", }.items(): assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}" assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable" @@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi assert '"StreamMetrics"' in openapi and '"streams"' in openapi -for marker in ['"version": "0.31.7"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']: +for marker in ['"version": "0.31.8"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']: assert marker in openapi, marker for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: assert marker in openapi, marker @@ -1179,7 +1179,8 @@ for memory_marker_0230 in [ ]: assert memory_marker_0230 in server_source or memory_marker_0230 in metrics_source, memory_marker_0230 cache_fill_0230 = rust_source(root, 'src/cache/fill.rs') -assert 'let body_allocation = self.data.capacity().max(self.data.len());' in cache_fill_0230 +assert 'let allocated = self.data.capacity().max(self.data.len());' in cache_fill_0230 +assert 'shared_allocated' in cache_fill_0230 and 'try_into_mut()' in cache_fill_0230 assert 'let cost = hot_entry_cost(' in cache_fill_0230 assert 'fn hot_entry_cost(' in cache_source for marker_0221 in [ @@ -1394,7 +1395,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log" assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241 assert 'location.pathname === "/logging"' in settings_js_0241 -assert '"RuntimeInformation"' in openapi and '"version": "0.31.7"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.31.8"' in openapi # 0.24.1 compiler and hostname-cardinality regressions. tls_0241 = rust_source(root, "src/tls.rs") @@ -1633,5 +1634,5 @@ for marker_0282 in [ assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text() assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/INSTALL.md').read_text() -print("PASS: 0.31.7 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.") +print("PASS: 0.31.8 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.") print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.") diff --git a/tools/checks/dependency_upgrade_0312.py b/tools/checks/dependency_upgrade_0312.py index cebf8be..8ccc7bd 100755 --- a/tools/checks/dependency_upgrade_0312.py +++ b/tools/checks/dependency_upgrade_0312.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Check the 0.31.7 dependency/source migration; optionally require a resolved lock. +"""Check the 0.31.8 dependency/source migration; optionally require a resolved lock. This is not a substitute for rustc, cargo metadata or runtime tests. """ @@ -14,7 +14,7 @@ parser = argparse.ArgumentParser() parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update') args = parser.parse_args() manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text()) -assert manifest['package']['version'] == '0.31.7' +assert manifest['package']['version'] == '0.31.8' assert manifest['package']['rust-version'] == '1.99' deps = manifest['dependencies'] expected_requirements = { @@ -78,4 +78,4 @@ if missing: print(f'PENDING: lockfile still requires cargo update ({details})') else: print('PASS: pinned key packages found in post-upgrade lock; cargo --locked verification is still required') -print('PASS: 0.31.7 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)') +print('PASS: 0.31.8 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)') diff --git a/tools/checks/docker_startup_0318_ui_test.mjs b/tools/checks/docker_startup_0318_ui_test.mjs new file mode 100644 index 0000000..357ff30 --- /dev/null +++ b/tools/checks/docker_startup_0318_ui_test.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// Runs the real Docker VHosts dashboard module against a minimal DOM + API fixture. +// This is a JS-only regression, not a compiled RustProxy or live agent test. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const dockerSource = readFileSync('src/admin/web/docker.js', 'utf8'); +const coreSource = readFileSync('src/admin/web/app/core.js', 'utf8'); +const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations; +const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations; +for (const translations of [english, polish]) { + assert.ok(translations.dockerNoObservation); + assert.ok(translations.dockerAgentConnectionFailed); + assert.ok(translations.dockerUnknownPollError); + assert.doesNotMatch(translations.dockerNoObservation.toLowerCase(), /refresh|odśwież/); +} + +class FakeNode { + constructor(tag, text = '', cls = '') { + this.tagName = tag; + this.content = String(text ?? ''); + this.className = cls; + this.children = []; + this.dataset = {}; + this.attrs = {}; + this.events = {}; + this.hidden = false; + this.open = false; + this.classList = { + toggle: (name, value) => { + const parts = new Set(this.className.split(' ').filter(Boolean)); + if (value) parts.add(name); else parts.delete(name); + this.className = [...parts].join(' '); + }, + add: name => { this.className += ` ${name}`; }, + remove: name => { this.className = this.className.split(' ').filter(part => part !== name).join(' '); }, + }; + } + get textContent() { return this.content + this.children.map(child => child.textContent || '').join(''); } + set textContent(value) { this.content = String(value ?? ''); this.children = []; } + append(...nodes) { this.children.push(...nodes.filter(Boolean)); } + replaceChildren(...nodes) { this.children = nodes.filter(Boolean); } + setAttribute(name, value) { this.attrs[name] = value; } + addEventListener(name, fn) { this.events[name] = fn; } + descendants() { return this.children.flatMap(child => [child, ...child.descendants()]); } + querySelectorAll(selector) { + const part = selector.split(' ').at(-1); + if (part.startsWith('.')) return this.descendants().filter(child => child.className.split(' ').includes(part.slice(1))); + return []; + } + querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } +} +const node = (tag, text, cls) => new FakeNode(tag, text, cls); +const destination = node('div'); +const elements = new Map([ + ['docker-discovery-list', destination], + ['overview-refresh', node('button')], + ['page-label', node('span')], + ['page-title', node('h1')], + ['page-description', node('p')] +]); +const agent = { + id: 'docker1', name: 'Docker One', enabled: true, + base_domain: 'apps.example.test', endpoint: 'https://192.0.2.2:9876', + tls_fingerprint_sha256: 'A'.repeat(64), poll_interval_seconds: 30, + assignments: [] +}; +const config = { docker_agents: { agents: [agent] }, listeners: { endpoints: [] }, tls: { certificates: [] } }; +const storage = new Map(); +const requests = []; +let nextResult = { observations: [], pools: [], pool_routes: [] }; +let refreshSpec; +let syncs = 0, manuals = 0; +const coordinator = { + register(id, spec) { assert.equal(id, 'docker'); refreshSpec = spec; }, + sync() { syncs++; }, + manual(id) { assert.equal(id, 'docker'); manuals++; return Promise.resolve(); }, + setPanel(name) { this.panel = name; }, +}; +const ctx = { + el: node, + $: name => elements.get(name) || null, + t: name => english[name] || name, + document: { + activeElement: null, + querySelectorAll(selector) { + if (selector === '#docker-discovery-list .docker-agent-card') return destination.children; + return []; + } + }, + window: { rustProxyRefresh: coordinator, rustProxySettings: {} }, + rustProxyRefresh: coordinator, + localStorage: { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value) }, + authenticated: true, authEpoch: 1, + currentPanel: 'docker-vhosts', envelope: { config }, + settingsDirty: new Set(), busy: false, + api: async (path, args = {}) => { requests.push({ path, args }); return nextResult; }, + toast(message) { throw new Error(`Unexpected toast: ${message}`); }, + console, +}; +vm.createContext(ctx); +vm.runInContext(dockerSource, ctx, { filename: 'src/admin/web/docker.js' }); +assert(refreshSpec, 'register Docker refresh with central scheduler'); +assert.equal(refreshSpec.interval(), 5); +ctx.window.rustProxyDocker.render(); +assert.equal(manuals, 1, 'deep-link bootstrap fetches a snapshot after configuration loads'); +assert.equal(syncs, 1, 'deep-link bootstrap syncs waiting-state scheduler'); +assert.equal(refreshSpec.active(), true, 'bootstrap retries cached status without requiring local Live view'); +assert.match(destination.textContent, /WAITING/); +assert.match(destination.textContent, /Waiting for the first agent check/); +assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/); +assert.doesNotMatch(destination.textContent, /Agent connection failed/); +assert.equal(destination.querySelector('.docker-agent-error'), null, 'pending must not be styled as a failure'); +let tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED'); +assert.ok(tlsPill); +assert.ok(!tlsPill.className.split(' ').includes('live'), 'configured fingerprint does not prove successful handshake'); + +await ctx.window.rustProxyDocker.refresh(false); +assert.equal(requests.at(-1).path, '/api/docker'); +assert.notEqual(requests.at(-1).args.method, 'POST', 'automatic status fetch never polls the agent'); +assert.equal(refreshSpec.active(), true, 'retry while the initial server-side poll is not complete'); + +nextResult = { observations: [{ agent_id: 'docker1', ok: false, error: 'connection refused', checked_at_unix_ms: 1, inventory: null }], pools: [], pool_routes: [] }; +await ctx.window.rustProxyDocker.refresh(false); +assert.match(destination.textContent, /Last Docker agent check failed/); +assert.match(destination.textContent, /connection refused/); +assert.doesNotMatch(destination.textContent, /TLS certificate SHA-256:/, 'pinned fingerprint is configuration, not the reason for an arbitrary failure'); +assert.ok(destination.querySelector('.docker-agent-error'), 'a genuine failed observation must be visible'); +assert.equal(refreshSpec.active(), false, 'local Live-off stops automatic refresh after first status, even if it is a failure'); + +nextResult = { observations: [{ agent_id: 'docker1', ok: true, error: null, checked_at_unix_ms: Date.now(), inventory: { + agent_version: '0.22.3', hostname: 'docker1', addresses: [], services: [], public_ip: '192.0.2.2', certificate_sha256: 'A'.repeat(64) +} }], pools: [], pool_routes: [] }; +await ctx.window.rustProxyDocker.refresh(false); +assert.match(destination.textContent, /CONNECTED/); +assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/); +assert.equal(refreshSpec.active(), false, 'local Live-off remains off with a successful observation'); +tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED'); +assert.ok(tlsPill.className.split(' ').includes('live'), 'pin may be marked verified after a successful poll'); +storage.set('rustproxy.docker.live-view', '1'); +assert.equal(refreshSpec.active(), true, 'Live view still polls normally'); +storage.delete('rustproxy.docker.live-view'); + +// Execute the real panel-navigation function with side effects disabled. +const from = coreSource.indexOf('function setPanel(name, navigate = true) {'); +const to = coreSource.indexOf('function bytes(value)', from); +assert(from >= 0 && to > from); +ctx.PANEL_PATHS = { overview: '/', 'docker-vhosts': '/docker-vhosts' }; +vm.runInContext(coreSource.slice(from, to), ctx); +ctx.currentPanel = 'overview'; +ctx.setPanel('docker-vhosts', false); +assert.equal(manuals, 2, 'opening Docker VHosts requests a new cached snapshot even with Live view off'); +ctx.setPanel('docker-vhosts', false); +assert.equal(manuals, 2, 'rendering the same panel does not duplicate one-shot requests'); +ctx.setPanel('overview', false); +ctx.setPanel('docker-vhosts', false); +assert.equal(manuals, 3, 're-entering the page reads the newest cached status'); + +ctx.currentPanel = 'overview'; +assert.equal(refreshSpec.active(), false, 'no bootstrap activity outside Docker VHosts'); +ctx.currentPanel = 'docker-vhosts'; +config.docker_agents.agents = [{ ...agent, enabled: false }]; +ctx.window.rustProxyDocker.render(); +assert.equal(refreshSpec.active(), false, 'disabled agents do not cause polling'); +assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/); +console.log('PASS: 0.31.8 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).'); diff --git a/tools/checks/docs_check.py b/tools/checks/docs_check.py index 3d4ef0c..b433d40 100755 --- a/tools/checks/docs_check.py +++ b/tools/checks/docs_check.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Rust-independent documentation/Reader consistency checks for the 0.31.7 release.""" +"""Rust-independent documentation/Reader consistency checks for the 0.31.8 release.""" from pathlib import Path import re from rust_source import rust_source @@ -51,7 +51,7 @@ assert '"/docs/content.json"' in admin readme = (ROOT / "README.md").read_text(encoding="utf-8") roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8") changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") -assert readme.startswith("# RustProxy 0.31.7") +assert readme.startswith("# RustProxy 0.31.8") assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive" assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme diff --git a/tools/checks/h3_navigation_0316.py b/tools/checks/h3_navigation_0316.py index 6512d8c..6fa944b 100755 --- a/tools/checks/h3_navigation_0316.py +++ b/tools/checks/h3_navigation_0316.py @@ -4,7 +4,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] html = (ROOT / 'src/admin/web/index.html').read_text() -assert '0.31.7' in (ROOT / "Cargo.toml").read_text() +assert '0.31.8' in (ROOT / "Cargo.toml").read_text() assert '',1)[0] body = (ROOT / 'src/proxy/cache_helpers.rs').read_text() diff --git a/tools/checks/memory_contracts.py b/tools/checks/memory_contracts.py index dac2caa..05bc157 100755 --- a/tools/checks/memory_contracts.py +++ b/tools/checks/memory_contracts.py @@ -168,7 +168,7 @@ reset = invalidation.index("store.key_generations.clear();", barrier) assert "store.key_generations.capacity() > 256" in invalidation[barrier:reset] matcher = invalidation.index("let affected = self.purge_matcher(&matcher);", reset) assert start < barrier < reset < matcher -require("src/cache/tests/v022_cache_tests/part_02.rs", "many_distinct_selective_purges_do_not_retain_tombstones_forever") +require("src/cache/tests/v022_cache_tests.rs", "many_distinct_selective_purges_do_not_retain_tombstones_forever") # Repeated hot-path analytics labels should use borrowed lookups instead of # allocating a String for every request in an already populated top table. diff --git a/tools/checks/preflight.py b/tools/checks/preflight.py index 78df69d..dffda6b 100755 --- a/tools/checks/preflight.py +++ b/tools/checks/preflight.py @@ -76,13 +76,13 @@ def check(root: Path, repair_known: bool = False) -> list[str]: raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/rates.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]: if not (root / path).is_file(): - raise ValueError(f"Incomplete RustProxy 0.31.7 source tree: missing {path}") + raise ValueError(f"Incomplete RustProxy 0.31.8 source tree: missing {path}") # The Reader uses compile-time include_str! for its bundled Markdown. A # dangling registration is a real compiler error, unlike an omitted old # release audit. Match both reader_doc! and the expanded include_str! form. registry = root / "src/admin/documentation/documents.rs" if not registry.is_file(): - raise ValueError("Incomplete RustProxy 0.31.7 source tree: missing src/admin/documentation/documents.rs") + raise ValueError("Incomplete RustProxy 0.31.8 source tree: missing src/admin/documentation/documents.rs") embedded_docs = set(re.findall( r'"(docs/[A-Za-z0-9_.-]+\.md)"', registry.read_text(encoding="utf-8") )) @@ -91,7 +91,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]: raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration") text = (root / "src/lib.rs").read_text(encoding="utf-8") if '#[path = "auth/mod.rs"]' not in text: - raise ValueError("src/lib.rs is not the 0.31.7 entry point; extract the complete package, not individual files.") + raise ValueError("src/lib.rs is not the 0.31.8 entry point; extract the complete package, not individual files.") return messages diff --git a/tools/checks/release_0318.py b/tools/checks/release_0318.py new file mode 100755 index 0000000..e3925fa --- /dev/null +++ b/tools/checks/release_0318.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""0.31.8 source-only regression; does NOT claim Rust compilation or memory profiling.""" +from pathlib import Path +import json +import re +import tomllib +from html.parser import HTMLParser + +ROOT = Path(__file__).resolve().parents[2] +def read(rel): return (ROOT / rel).read_text(encoding='utf-8') +assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.31.8' +assert 'name = "rust-proxy"\nversion = "0.31.8"' in read('Cargo.lock') +assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.31.8' + +pages = read('src/pages.rs') +for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504): + file = f'src/admin/web/error_{code}.html' + markup = read(file) + assert markup.lstrip().startswith(''), file + assert 'background: #131615' in markup and 'max-width: 620px' in markup, file + assert re.search(rf'

{code} - [^<]+

', markup), file + assert f'include_str!("admin/web/error_{code}.html")' in pages, file +assert 'status_200.html' in pages and '

200 - OK

' in read('src/admin/web/status_200.html') +assert 'RustProxy generated this response.' in pages and 'background: #131615' in pages +assert 'matches!(status, StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED)' in pages + +geoip = read('src/geoip.rs') +assert 'ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]>' in geoip +assert 'ipv4.into_boxed_slice()' in geoip and 'ipv6.into_boxed_slice()' in geoip +assert 'start: [u8; 16]' in geoip and 'end: [u8; 16]' in geoip +assert 'u128::from(ip).to_be_bytes()' in geoip and 'ip <= item.end' in geoip +assert 'size_of::(), 34' in read('src/geoip/tests.rs') + +cached = read('src/cache/fill.rs') + read('src/cache/lookup/tiered.rs') +for marker in ('bytes.try_into_mut()', 'mutable.capacity()', 'candidate.shared = Some(bytes.clone())', + 'shared_allocated', 'candidate.fill_gauge.grow(allocation', 'if transformed { 0 }', + 'candidate.data.extend_from_slice(data)'): + assert marker in cached, marker + +q = read('src/admin/file_logs.rs') + read('src/admin/file_logs_query.rs') +for marker in ('sort_by: Option', 'sort_descending: Option', 'valid_raw_field_key(field)', + 'fn sort_file_log_records(', 'stored_log_sort_key(&record, field)', + 'file_log_field(record, field)', 'None, Some(_)', 'sort_file_log_records(&mut records'): + assert marker in q, marker +assert q.index('sort_file_log_records(&mut records') < q.index('let page = records') +print('PASS: 0.31.8 versions, 13 matching HTML pages, GeoIP packed/boxed sources, cache ownership/accounting and bounded server sorting (static only).') diff --git a/tools/checks/release_0318_ui_test.mjs b/tools/checks/release_0318_ui_test.mjs new file mode 100644 index 0000000..5620196 --- /dev/null +++ b/tools/checks/release_0318_ui_test.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// JS-only regression of the Stored Logs sorting interaction (no Rust server). +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +const read = name => fs.readFileSync(name, 'utf8'); +const ui = read('src/admin/web/file-logs.js'); +const css = read('src/admin/web/style.css'); +const api = JSON.parse(read('src/admin/openapi.json')); +const params = api.paths['/api/file-logs/query'].get.parameters.map(param => param.name); +assert.ok(params.includes('sort_by') && params.includes('sort_descending')); +assert.ok(api.components.schemas.AdminPreferences.properties.file_log_sort); +assert.ok(api.components.schemas.AdminPreferences.properties.file_log_descending); +assert.match(ui, /params\.set\("sort_by", sortBy\)/); +assert.match(ui, /params\.set\("sort_descending", String\(sortDescending\)\)/); +assert.match(css, /file-log-sort-button/); + +class FakeNode { + constructor(tag, text = '', cls = '') { this.tag = tag; this.textContent = text; this.className = cls; this.children = []; this.dataset = {}; this.attrs = {}; this.events = {}; } + setAttribute(name, value) { this.attrs[name] = value; } + append(...nodes) { this.children.push(...nodes); } + replaceChildren(...nodes) { this.children = nodes; } + addEventListener(name, handler) { this.events[name] = handler; } + click() { this.events.click?.(); } +} +const row = new FakeNode('tr'); +const sort = ui.slice(ui.indexOf(' function syncHeaders() {'), ui.indexOf(' function syncColumnPicker()')); +assert.ok(sort.startsWith(' function syncHeaders() {') && sort.includes('file-log-sort-button')); +const ctx = { + el: (tag, text, cls) => new FakeNode(tag, text, cls), + $: () => ({querySelector: () => row}), + orderedColumns: () => ['time', 'status'], + visibleColumns: new Set(['time', 'status']), + specialLabels: new Map([['time', 'time'], ['status', 'status']]), + columnLabel: id => id, + t: key => key, + sortBy: 'time', sortDescending: true, selectedIndex: 3, offset: 100, + saved: 0, refreshed: 0, + savePreferences() { this.saved++; }, + refresh() { this.refreshed++; }, +}; +// Methods capture explicit counters rather than relying on vm's `this` binding. +ctx.savePreferences = () => { ctx.saved++; }; +ctx.refresh = reset => { if (reset) ctx.refreshed++; }; +vm.createContext(ctx); +vm.runInContext(`${sort}\nglobalThis.draw = syncHeaders;`, ctx); +ctx.draw(); +assert.equal(row.children.length, 2); +assert.equal(row.children[0].attrs['aria-sort'], 'descending'); +assert.equal(row.children[1].attrs['aria-sort'], 'none'); +row.children[1].children[0].click(); +assert.equal(ctx.sortBy, 'status'); +assert.equal(ctx.sortDescending, false); +assert.equal(ctx.offset, 0); +assert.equal(ctx.saved, 1); +assert.equal(ctx.refreshed, 1); +assert.equal(row.children[1].attrs['aria-sort'], 'ascending'); +row.children[1].children[0].click(); +assert.equal(ctx.sortDescending, true); +assert.equal(row.children[1].attrs['aria-sort'], 'descending'); +assert.equal(ctx.saved, 2); +assert.equal(ctx.refreshed, 2); +console.log('PASS: 0.31.8 Stored Logs headers sort by column, toggle direction, reset pagination, persist preferences and request server sorting.'); diff --git a/tools/checks/release_smoke.py b/tools/checks/release_smoke.py index 8250e41..a729611 100755 --- a/tools/checks/release_smoke.py +++ b/tools/checks/release_smoke.py @@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None: assert backup.is_dir() assert (backup / "src/obsolete.rs").is_file() assert not (target / "src/obsolete.rs").exists() - assert 'version = "0.31.7"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert 'version = "0.31.8"' in (target / "Cargo.toml").read_text(encoding="utf-8") assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n" assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n" assert (target / "langs/site.json").is_file() @@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None: assert (target / "docker-agent/certificate.go").is_file() assert (target / "docker-agent/server.go").is_file() manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) - assert manifest["to_version"] == "0.31.7" + assert manifest["to_version"] == "0.31.8" assert manifest["removed_obsolete_runtime_state"] is True assert not legacy.exists() assert not (backup / "legacy-data").exists() @@ -617,7 +617,7 @@ def source_contract_smoke() -> None: assert marker in admin, marker anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0] assert '"/api/status"' not in anonymous_block - assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.7"' in openapi_0241 + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.8"' in openapi_0241 assert 'id="panel-logging"' not in html for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']: assert marker in html, marker @@ -1102,7 +1102,7 @@ def source_contract_smoke() -> None: assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8") assert '"/api/config/history/restore"' in openapi - for marker in ['"version": "0.31.7"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: + for marker in ['"version": "0.31.8"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: assert marker in openapi, marker assert '"software_update"' in openapi and '"update_available"' in openapi for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']: @@ -1216,7 +1216,15 @@ def reproducible_archive_smoke(tmp: Path) -> None: assert "RustProxy/tools/checks/source_module_contracts.py" in names for name in names: if name.startswith("RustProxy/") and name.endswith(".rs"): - assert len(archive.read(name).decode("utf-8").splitlines()) <= 500, name + relative = Path(name).parts[1:] + is_test = relative[0] == "tests" or ( + relative[0] == "src" and ( + relative[-1] == "tests.rs" or relative[-1].endswith("_tests.rs") + or "tests" in relative[1:-1] + ) + ) + if not is_test: + assert len(archive.read(name).decode("utf-8").splitlines()) <= 500, name if name.startswith("RustProxy/src/admin/web/") and name.endswith(".js"): assert len(archive.read(name).decode("utf-8").splitlines()) <= 500, name if name.startswith("RustProxy/docker-agent/") and name.endswith(".go"): @@ -1307,7 +1315,7 @@ def main() -> None: source_contract_smoke() container_contract_smoke() grafana_contract_smoke() - print("PASS: 0.31.7 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") + print("PASS: 0.31.8 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") if __name__ == "__main__": diff --git a/tools/checks/rust_module_contracts.py b/tools/checks/rust_module_contracts.py index e139e07..1842903 100755 --- a/tools/checks/rust_module_contracts.py +++ b/tools/checks/rust_module_contracts.py @@ -2,7 +2,7 @@ """Static structure contracts for the split Rust source tree. These checks are intentionally compiler-independent. They prevent the module split -from regressing into large monoliths, keep unit tests out of implementation files, +from regressing into large implementation monoliths, allow full-sized test files, and catch the class of dangling-attribute/broken-include mistakes that can otherwise surface only at cargo check time. """ @@ -19,8 +19,12 @@ MAX_LINES = 500 def is_test_source(path: Path) -> bool: - relative = path.relative_to(SRC) - return path.name == "tests.rs" or path.stem.endswith("_tests") or "tests" in relative.parts + relative = path.relative_to(ROOT) + if relative.parts[0] == "tests": + return True + return relative.parts[0] == "src" and ( + path.name == "tests.rs" or path.stem.endswith("_tests") or "tests" in relative.parts[1:] + ) include_re = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)') @@ -89,10 +93,12 @@ def delimiters_balanced(text: str) -> bool: return not stack and state in {"code", "line"} -# The 500-line rule applies to every Rust source in the repository, including integration tests. +# The 500-line rule applies to production Rust sources, not unit/integration tests. for path in sorted(ROOT.rglob("*.rs")): if any(part in {"target", ".git"} for part in path.parts): continue + if is_test_source(path): + continue lines = path.read_text(encoding="utf-8").splitlines() assert len(lines) <= MAX_LINES, f"{path.relative_to(ROOT)} has {len(lines)} lines (max {MAX_LINES})" @@ -126,6 +132,22 @@ for path in sorted(SRC.rglob("*.rs")): f"test cfg in implementation must target a separate test file: {path.relative_to(ROOT)}" ) +# Unit tests are intentionally consolidated rather than mechanically numbered. +for folder in ( + "admin/tests", "balance/tests", "config/tests", "web_server/tests", + "cache/tests/tests", "cache/tests/v022_cache_tests", "cache/tests/cold_tier_tests", +): + assert not (SRC / folder).exists(), f"obsolete split-test directory: {folder}" +for relative in ( + "admin/tests.rs", "balance/tests.rs", "config/tests.rs", "web_server/tests.rs", + "cache/tests/tests.rs", "cache/tests/v022_cache_tests.rs", "cache/tests/cold_tier_tests.rs", +): + assert (SRC / relative).is_file(), f"missing consolidated test file: {relative}" +assert 'include_str!("web/login-keys.json")' in (SRC / "admin/tests.rs").read_text(), ( + "admin test asset path must be relative to the consolidated test file" +) +assert (SRC / "admin/web/login-keys.json").is_file() + # Explicit regressions from the first Web Server split. response = (SRC / "web_server/response.rs").read_text(encoding="utf-8") assert not response.rstrip().endswith("#[derive(Debug, Clone)]"), "dangling derive returned in Web Server response module" @@ -150,16 +172,21 @@ server_tests = (SRC / "server/serve/tests.rs").read_text(encoding="utf-8") assert server_tests.count("serve_inner(") == server_tests.count("Http2Connections::default()"), ( "direct serve_inner test calls must pass the Http2Connections argument" ) -cache_policy_tests = (SRC / "cache/tests/tests/part_03.rs").read_text(encoding="utf-8") -assert "let plan = plan(" not in cache_policy_tests, "cache policy test shadows the plan() function" -cold_tests = (SRC / "cache/tests/cold_tier_tests/part_02.rs").read_text(encoding="utf-8") +cache_policy_tests = (SRC / "cache/tests/tests.rs").read_text(encoding="utf-8") +# Older cache tests legitimately use `let plan = plan(...)`. The regression +# applies to the Vary/revalidation group formerly stored in part_03.rs. +revalidation_tests = cache_policy_tests.split( + "async fn vary_selects_variants_without_fragmenting_the_primary_hash", 1 +)[1] +assert "let plan = plan(" not in revalidation_tests, "cache policy test shadows the plan() function" +cold_tests = (SRC / "cache/tests/cold_tier_tests.rs").read_text(encoding="utf-8") for field in ("scheme:", "authority:", "target:", "tags:", "created_at:"): assert field in cold_tests, f"ColdEntry test fixture is missing current field {field}" -admin_tests = (SRC / "admin/tests/part_02.rs").read_text(encoding="utf-8") +admin_tests = (SRC / "admin/tests.rs").read_text(encoding="utf-8") assert "let configuration = router\n .clone()\n .oneshot(" in admin_tests, ( "admin persistence test must retain Router before Tower::oneshot consumes a clone" ) -web_tests_1 = (SRC / "web_server/tests/part_01.rs").read_text(encoding="utf-8") +web_tests_1 = (SRC / "web_server/tests.rs").read_text(encoding="utf-8") assert "ETAG, EXPIRES," not in web_tests_1, "unused EXPIRES import returned in Web Server tests" -print(f"PASS: Rust modules are <= {MAX_LINES} lines, test code is isolated, include! targets resolve, and split-module/all-target regressions are guarded.") +print(f"PASS: production Rust modules are <= {MAX_LINES} lines (tests exempt), numbered test fragments are absent, include! targets resolve, and module regressions are guarded.") diff --git a/tools/checks/security_logs_0315_ui_test.mjs b/tools/checks/security_logs_0315_ui_test.mjs index 691ced9..f3081a5 100644 --- a/tools/checks/security_logs_0315_ui_test.mjs +++ b/tools/checks/security_logs_0315_ui_test.mjs @@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json'); const en = json('langs/en_US.json').translations; const pl = json('langs/pl_pl.json').translations; -assert.match(manifest, /version = "0\.31\.7"/); +assert.match(manifest, /version = "0\.31\.8"/); assert.match(lib, /pub mod user_agent;/); assert.match(model, /user_agent_blocking: crate::user_agent::Policy/); assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/); @@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/); assert.match(fileLogs, /aggregate_min_count/); assert.match(fileLogs, /file-log-aggregate-copy/); -assert.equal(openapi.info.version, '0.31.7'); +assert.equal(openapi.info.version, '0.31.8'); assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking); assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking); assert.ok(openapi.components.schemas.FileLogSource.properties.group_id); diff --git a/tools/checks/system_info_0301_ui_test.mjs b/tools/checks/system_info_0301_ui_test.mjs index b5cc26c..a742837 100644 --- a/tools/checks/system_info_0301_ui_test.mjs +++ b/tools/checks/system_info_0301_ui_test.mjs @@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || { const elements = new Map(); const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); }; const status = { - version: '0.31.7', uptime_seconds: 4201, + version: '0.31.8', uptime_seconds: 4201, runtime: { platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64', statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3', @@ -63,7 +63,7 @@ const context = { vm.runInNewContext(code, context); assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13'); assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0'); -assert.equal(dollar('runtime-version').textContent, '0.31.7'); +assert.equal(dollar('runtime-version').textContent, '0.31.8'); assert.equal(dollar('runtime-logical-cpus').textContent, '8'); assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%'); assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5'); @@ -124,4 +124,4 @@ for (const key of ['python_version','pip_version','certbot_version']) { assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]); assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true); } -console.log('PASS: 0.31.7 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.'); +console.log('PASS: 0.31.8 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');