From bb025dcf440a8ce15ba9c04e0b55cef916168e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 13 Oct 2026 01:34:42 +0200 Subject: [PATCH] v0.32.2 --- AGENTS.md | 2 +- CHANGELOG.md | 7 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 10 +- ROADMAP.md | 6 + SHA256SUMS | 87 +++++----- dev.sh | 1 + docs/CACHE.md | 18 +- docs/TEST_REPORT.md | 45 +++++ src/admin/openapi.json | 2 +- src/proxy/forward/web_source.rs | 54 +++++- src/proxy/mod.rs | 1 + src/proxy/web_cache_revalidation.rs | 129 ++++++++++++++ src/proxy/web_cache_revalidation/tests.rs | 166 +++++++++++++++++++ tools/checks/backend_cache_0297_ui_test.mjs | 4 +- tools/checks/backend_health_0300_ui_test.mjs | 4 +- tools/checks/certificate_api_0319.py | 4 +- tools/checks/check_source.py | 20 +-- tools/checks/dependency_upgrade_0312.py | 2 +- tools/checks/docs_check.py | 2 +- tools/checks/h3_navigation_0316.py | 2 +- tools/checks/release_0318.py | 6 +- tools/checks/release_smoke.py | 8 +- tools/checks/security_logs_0315_ui_test.mjs | 4 +- tools/checks/web_cache_revalidation_0322.py | 31 ++++ tools/checks/web_server_contracts.py | 7 +- 27 files changed, 541 insertions(+), 85 deletions(-) create mode 100644 src/proxy/web_cache_revalidation.rs create mode 100644 src/proxy/web_cache_revalidation/tests.rs create mode 100755 tools/checks/web_cache_revalidation_0322.py diff --git a/AGENTS.md b/AGENTS.md index 35fae1c..231c045 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.32.1. +compatibility. Current source version: 0.32.2. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index 026ce18..01b094a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ > Operator-facing release history. Historical engineering notes are kept in > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.32.2 + +- Fix Web Server shared-cache revalidation: browser `no-cache`, `max-age=0` and freshness-constrained requests now supply stored origin validators. Honor local origin `304 Not Modified` by refreshing the same cached body and preserving/incrementing `X-Cache-Hits`, matching the existing Reverse Proxy behavior. +- Keep client-facing conditional/HEAD/range semantics distinct from origin validation; do not re-use a stale body if the file changed. Remove old stored copies on changed/uncacheable local responses, and prevent an uncacheable `304` from re-enabling shared storage via vhost response rules. +- Preserve original representation headers when revalidating transformed assets, and guard validation across cache PURGE/config revision changes. Add focused regression tests using the local Web Server's actual static file handler. +- No changes to the database schema, cache format, API, Web Server configuration or Reverse Proxy source. **Rust compilation and real-browser verification are outstanding** until run on a Rust 1.99+ host. + ## 0.32.1 - Restore Overview proportions from 0.31.9 by removing 0.32.0's desktop minimum heights. Increase compact metric typography so labels, values and captions remain readable at 90% UI scaling without large empty bottoms. diff --git a/Cargo.lock b/Cargo.lock index cfe902b..84bd27c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.32.1" +version = "0.32.2" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index c700d11..a51990b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.32.1" +version = "0.32.2" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index f08c499..1d9d6e0 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# RustProxy 0.32.1 +# RustProxy 0.32.2 **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). @@ -7,7 +7,13 @@ streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhos **0.31.9 Certificate API management: inspect and edit credential settings without exposing/rotating the current token, or regenerate one existing token with one-time display. Rust/Cargo and live testing required.** -## What changed in 0.32.1 +## What changed in 0.32.2 + +- **Web Server cache revalidation:** browser refresh (`Cache-Control: no-cache` or `max-age=0`), stale objects and freshness-constrained requests now use stored origin `ETag` / `Last-Modified` validators. An unchanged local static resource returns `304` to RustProxy; it reuses the cached body and increases `X-Cache-Hits` rather than resetting it to a new MISS. +- **Correct conditional delivery:** retain the client's original validators for downstream `304` decisions (or serve a body on an unconditional refresh). HEAD, single byte ranges, transformed-response headers and `Vary` isolation are retained. If the local resource changes, a successful response replaces the previous representation; an uncacheable 304 discards the shared copy. +- Reverse Proxy caching, frontend routing, PHP-FPM, configuration/storage schemas and the administration API remain unchanged. Rust/Cargo and browser-based runtime acceptance are required; see [verification report](docs/TEST_REPORT.md). + +## Previously in 0.32.1 - **Overview readability at 90% UI scale:** compare the supplied 0.31.9 source with 0.32.0, remove the latter's forced desktop card heights and restore content-sized rows. The six-column desktop layout and visible chart legends remain intact. Increase compact Dashboard headings (10 → 11px), values (22 → 25px), secondary text (8.5 → 10px) and the Payload breakdown (9 → 10px). - **Consistent tile spacing:** Overview now uses `--ui-gap`, the same density-aware spacing variable as other Dashboard sections, instead of its own fixed 10/8/6px spacing in normal/compact/ultra Dashboard modes. UI scale (including 90%) affects all gaps equally. diff --git a/ROADMAP.md b/ROADMAP.md index a3b7099..52d334c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,11 @@ # RustProxy roadmap +## 0.32.2 - Web Server conditional cache revalidation (runtime acceptance pending) + +- [x] Reuse local Web Server `ETag` / `Last-Modified` revalidation candidates for forced browser refresh and stale/freshness-constrained requests, keeping hit counts and cache bodies on `304`. +- [x] Preserve client conditional semantics, HEAD, range, response policy and transformed representations; invalidate superseded objects. +- [x] Add Rust regression cases and source-only checks. + ## 0.32.1 - Dashboard Overview typography and tile height (runtime acceptance pending) - Restore content-driven heights for desktop Overview tiles (six columns) seen in 0.31.9, removing 0.32.0's fixed-height whitespace at 90% scale. diff --git a/SHA256SUMS b/SHA256SUMS index c4e17ea..cbe4f18 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -5090b92a2b7a045f90da92c369c16ac5a9704d466f38650f9215124d5e8cf785 AGENTS.md -a17c615a6a46227bc66118f6e557c18c40fc1db0fcbdfe3c23fe339480b66357 CHANGELOG.md -ef41be4589ed16a84279f06232d3a7604beae512db47bd7c0d1be771c4446582 Cargo.lock -5c50094ca7773fbe586db61007783d2c600c5481a03f446e99b862dd724a9dac Cargo.toml +c0f4ff54579d47615a160930b327122ff88cc41e7fa09079c74f484d79a47ed4 AGENTS.md +d11f383dd8b2e00aab378e5d220da42681a7e663ae03351ec8ec70028456b40d CHANGELOG.md +55d6e709442850feab4c00791e511fbffbf69a5687eb3ae6880c619892ef699d Cargo.lock +9458cc57f7787ab61b3bc685761f22e40dde12d313ca3cc7603ae24f39b5e8a7 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -bf10b4944a4cb45449eb3a9ec6a4cd91a9ca44c47a47e015320c2e03b6a67e59 README.md -7a0aacd9db1ea4fa42a59614498fea140f17d2375d5cac4db8819b3eb43786e4 ROADMAP.md +e40a3dc858b85ccf3895b960d0881db1b6576ff41f7afda9696c9cbaaacd591f README.md +025cfe7ef2551d6232108249aadccd4d587eccc66ecc344f7de2f8763000b074 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service -3b7faf0c7f26387c908ed1f9d11bfd00e1fc042680826e73b01d7e01f2af4043 dev.sh +a895f2d1aaa2903ae8960a51145951a6f801da902dbea995375d836f5f610867 dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example @@ -30,7 +30,7 @@ ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2 ed9edea5a28c34aa8bacadf1f6d39257ae1421b1fe907b0cc038fb0de898c67f docs/API.md f27053f8a93d7883025a04922d015e0b697cad653e714be632b757e167297de1 docs/API_ACCESS.md 6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md -e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md +fb99fac0a490af262a0ecdad593ffe5164004c2166fe7ddd607476bcc56a1623 docs/CACHE.md 9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md 3822d72e12f82c37a9e70ab40c7e89e89145ad37f29914e4702db4bf024a5501 docs/CHANGELOG_TECHNICAL_ARCHIVE.md c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md @@ -63,7 +63,7 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY. 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 919aed5a97f8b08aac1a0784672170b49b420012cd8f2159dd3e46e654787dbd docs/SETTINGS.md 8fc112cb45a2f35ef5b0dd4371aa6f43aca4df85db62c6ae02e56d74a3b702ae docs/STREAMS.md -22568f54908dfda2cf830351e9f3394d81564b13473dea8baa9bb7e36646dcdb docs/TEST_REPORT.md +c81b3f1f86b33abb744d27dee8abffd7fc2a3149210522fee9ccf591f88cdf7e docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md @@ -154,7 +154,7 @@ c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs 7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs 33be50109497d0adb23c36a10894acb5508dd38f3c01136bd0438a4ce0ffe74b src/admin/mod.rs -da852198f31d08e3e5f146683d149aef94d48343014a7074308c57aabeb1e3c4 src/admin/openapi.json +bcddb40675acbabd38ce5b1c73d8784052796c91030c9653dce7cc1913c5ce78 src/admin/openapi.json d9120f7dc61d27621c5ac27ea49815dc7945c44d5b99d208da503818c198f649 src/admin/operations.rs 30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs @@ -173,13 +173,13 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js cb9def393b7f57a6cfecbb8edaff4b7e20e7805231d08e354ad0b7deb294a103 src/admin/web/app/core.js -919019e3e71ae7dc2a7c9ba411dad2908ead42942c0702e7349b5ebe12908442 src/admin/web/app/preferences.js -729ed152eb8471c67fb0e55e44fe0669c2708802ce83228df9cb4e53c14cee02 src/admin/web/app/refresh.js -984d092961bfaaa19f982e54c7d4be474e8417b208d814ac88bda63f1ab19f4c src/admin/web/app/runtime.js -db56370db60e514a2241d231cd949bac2c7aa2f413a2c14035a877029a8d864f src/admin/web/app/session-resume.js +40ca254a6c7f4c2697e8ccf43d31fc24cc925be05b6070b974aaf044a928ee3e src/admin/web/app/preferences.js +6a544baaf40763b4b612e53845b3bc33396b9bb6518565fd6b7c128c278b8d5d src/admin/web/app/refresh.js +a3715a35e11b45395897e91003f90d2a4674f84cc93ebb90044cdf32eaddde1f src/admin/web/app/runtime.js +31201586925e6f4792071d7d03fb05dcc4a69a03c02a4e85eb0746f45af68074 src/admin/web/app/session-resume.js 86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js -fc27b21bb6692efccd51351a7b9510f699bad69e51f4ecc4b7bdbf75ad35414b src/admin/web/app/stream-dashboard.js -4fec1344be3cd540d1e6d0b87fb0aa94e1bef2968c4f86366a62c56d4085f452 src/admin/web/cache-panel.js +cc566f8293cc935f5080ebfb8076293c256bb6a87e1e6a69be03512448871ec9 src/admin/web/app/stream-dashboard.js +a854e08f947a4d13fc4b27286a096dcb2773dc0358a28b27703a2320ba7e8bf7 src/admin/web/cache-panel.js 02a9f5a61379e68e1723e64ef4c589500e9a993adcb02fe352f1cf9456c90c52 src/admin/web/certificate-api.js 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html d01a2e48f344fe074c1e63ee0a9bbf7151dfe26140690418535197e13a2a16d7 src/admin/web/docker.js @@ -200,28 +200,28 @@ ff72fc4c6a11907a16234c4b3c7d7f7bcf82eb72868dc3fbf654b04aff806fc8 src/admin/web/ 386b9dc3aecebe7727f7cf7bbd2a02ab036a3abf01315975111a51559128df73 src/admin/web/error_503.html e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/error_504.html 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg -a065af0a331a953dc51c72f0f589023cf8a917cda5baa5308dd0d8c105ec3d91 src/admin/web/file-logs.js +7cbdef9192a9dee1f8eac42b85f52b3190f7f6efae50004aa7ad116a910e4036 src/admin/web/file-logs.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js 0ee5b6ac3cf2022c5b88166dc4816868161351471e81b424b53d0f174eca7ed1 src/admin/web/index.html 03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/login.html -99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js -136c093a907825e0d942c06186fffc834936b60dd6408bcafe81092cff7e3c64 src/admin/web/management.js +203f2b7e960d4cf6b3fc0970e73716e7da998e29c46dcb4a72f7f310aa8d2232 src/admin/web/login.js +36af1f8f83af7e6f9a60b4d9524714e02c077e539a90aac0ce990d0587c0c3ed src/admin/web/management.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js -2efa65f4112ea5523bbb5583c6e320c93f0cfed520a0bc5cf4c22fb51b123649 src/admin/web/settings.js +ba245b331ab045b25c9fe62670d108b5e7cfa1d1cbbcce5dc22ce37521fa92f4 src/admin/web/settings.js 8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html -6e0036e3915de17529f14a7e47fa57d52ed3e03e65a3e4c0aa78cd6486342e96 src/admin/web/streams.js -39f5e489813ae397ab03bbfca0d3daca85244da93ec4e895a3fc75d40234b1e3 src/admin/web/style.css +e3075af59644e42fa5735574d8e9caa1bcebfe8de184b78aff9f4026b91915f1 src/admin/web/streams.js +500e3d77e981ad2fe5f09539b0ed47f0d1f85b3a593dfa2c0d6e193c3955ffe9 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 -f3aded010313a31e3f8599891be2f9bccfcf3d1e7a1649ccf81df95fbee3232f src/admin/web/vhosts/editor.js -30a6344e76de77b60cd7247a33c68f1cb5d972aebfde56469f2ebd44440c7bf9 src/admin/web/vhosts/health.js -137aeabed99cbdf9f122da6350e596e6a4e32244fa23101ccf6378305148d38d src/admin/web/vhosts/rates.js -f2689283cfe43d6aae911b0df778a69335089be230fbfc6de5dcce1801c38427 src/admin/web/vhosts/render.js -91e3417fba7e8f59023cd64813befccc8341741cd4aa53dfa1ec68a03c7f4632 src/admin/web/web-server-profiles.js -de3326d4fae254eb0b8c190f02e762f4094dcba86bfdcb24c5fb472676ebcc4b src/admin/web/web-server.js +22f29bb06cb650b6adefe257f5f706656e36e7fdd5088f7abc10f63247e04165 src/admin/web/vhosts/core.js +b31782c61da3623389b57fdbba5bd41d2287d45fc172e9c14360a9dd7accf061 src/admin/web/vhosts/editor.js +8338ce13b5e3e3f421437631124cb691aeb5b9e17409d256b4b9bcac457b3c59 src/admin/web/vhosts/health.js +2170cfddf5c611672de34b0525d36ade8fcbe459cc21269281d6b615d8601b93 src/admin/web/vhosts/rates.js +dfab53c9b9d4c633db639c37e99a46169aa86f7a28e6731e305c27ac8587a7ef src/admin/web/vhosts/render.js +4dfdeb7c73c2775eacaca65fd8ef1dc80b275421b15522d319d338f5b96288cc src/admin/web/web-server-profiles.js +355684980d1caf8918d1773ce17934b51e3a85a60f38491217d10035bf68ce9f src/admin/web/web-server.js 53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs 775178ee5781e64574bdd7c1fb7d64e7e283977fbd9c98c99896532a7f1df612 src/auth/mod.rs @@ -359,17 +359,19 @@ d3b9aa0c0055fa9a29f180ca8b10c890c0b91127ee603e5d89cce4c99d5a6bd4 src/proxy/forw cea9e47ecb31b0e4c0bbe0eb83e0e53b5e7bdcb9930107b67a8c34eef01a3f49 src/proxy/forward/success_finish.rs 902cbf8dc3c1ec8d25411bc7fe0400d301f3a7cc885afada594e4eaee3bedd08 src/proxy/forward/success_prepare.rs 23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs -8961d8d649d8f4708f443f1b8b8a73c8b5f42811d2cad8cc6f596833511933f4 src/proxy/forward/web_source.rs +b05c4f5c009c5e410f472ceaa999fa3a9c7fb60364d5b62de219f0924c9d524e src/proxy/forward/web_source.rs a9c011061585c067f04f7885215d53e6c40474bdced15d2be5a5ba65190910a0 src/proxy/forward_entry.rs c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs 5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs -632ee37457d9bf1dbcd3211afdf489f4fc9faf018007d5c1036169436e8e5dea src/proxy/mod.rs +8d17b97d90fd6a749ba8d37a8d28c94e6d7302b6c86905f073e2dc595506961a src/proxy/mod.rs 55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs cc682f0d5754e0b806a4eb5950e4cc00611aca5ce4668414fddef1d692489805 src/proxy/tests/authority_tests.rs 0f2eacadf1447db8aa1c1a78005aa262e0f747096bfe7e0a0e41a2cd31a0d17f src/proxy/tests/h3_navigation_tests.rs d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs e80c7fe44abb7607c64ea02d56542753dc25290b3bcf4c4d45b75f2db6ca90d0 src/proxy/types.rs 9999f717492755d62fc05cd5c1aff802ff0da901fe3e6b928bd8a000833c8799 src/proxy/upstream_helpers.rs +2d9142e3ae95895b5271b4009ab48236a3a2074ce51179c021193b35777560e4 src/proxy/web_cache_revalidation.rs +5af991311e4ed22cd8aaff15a4b9d45bb55fcdfcd0ebbfb4879dc1c7edf88ac4 src/proxy/web_cache_revalidation/tests.rs 69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs d866e7e72dee6ff54c2418c34e9899cbcd7e4e027d9cbe2b625044dc9062e4ee src/proxy/websocket/handshake.rs 7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs @@ -482,19 +484,19 @@ b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.m e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/admin_public_origin_0314_config_sim.mjs 0060f8217640403c6af2d5091fdc9087d0fb2fc30cd545a3c58277a396d8121c tools/checks/admin_public_origin_0314_sim.mjs 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 -b25381435bddb9d91ddf057bf5447877080b3bfb6444e8222bffc9034e3c5684 tools/checks/certificate_api_0319.py +a7dfb58212d2172f3fbe0ccdcd23e797a7baadc5627d14615c94427e8ad24b21 tools/checks/backend_cache_0297_ui_test.mjs +8946c2f93915322062fca5a1a406ed5d88efa9a8b1cefc7e2d1c7cafb2ba8e57 tools/checks/backend_health_0300_ui_test.mjs +1255853a2a53db73a2820d9aadad5f30c8e404de95287410fa4f2443f74b2eed tools/checks/certificate_api_0319.py 526d16db9de7be414a64483a1e9276d4a4402e3c7efd2479f79d01cc5b388f46 tools/checks/certificate_api_0319_ui_test.mjs -49829119968828bfbb276ff408716ba6f2706a8463878af3d92ec963e7071b6b tools/checks/check_source.py +4907ec98d4fd64f8670f0ef5783553cfbed86a2882348f07089c6c43ff176b0c tools/checks/check_source.py dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs 259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs -a6f8b7ee2cf0cc212032c2b76588e360143e9b2e20ff87caa8f29675595cd885 tools/checks/dependency_upgrade_0312.py +45981344fb95c2b64e24fa8b20af6ad53c0e101c5511f2182456bc0069e7c2d5 tools/checks/dependency_upgrade_0312.py 38e35bed4faf984932b39bb5a00819fadc664387e8050a803ff4b5348b31369f tools/checks/docker_startup_0318_ui_test.mjs -2f4fecdad966d9898f7a9168c3f6e216ffb3ea165d06b62fe5865f2c3314ed83 tools/checks/docs_check.py +86d0d1674a713f1d10d93be5b920fd3de2ebc4b69ac1259907d96d10233ae176 tools/checks/docs_check.py 6ca66c0998d1da852ad66976c91e4d7d3628195aa49ddb44cea95f531eb50033 tools/checks/file_logs_tabs_0315_ui_test.mjs 72f3c4e3274d33e02d46646395bc0cd18a8f723f8747969fc81997c7a7259e76 tools/checks/h3_cache_mobile_0315_test.mjs -94396cf2fc2a2a683e1198c15d887ed878730ee36101ba980a8b11c5b99db830 tools/checks/h3_navigation_0316.py +e9cdaf709f16760b01389c00cc05974459573fd3ae89e30c21e826781408c191 tools/checks/h3_navigation_0316.py e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py @@ -505,14 +507,14 @@ eea7cdc9c7d8dd0f5fb079cfff657a6feadfc0b856f63265bfbd8257f1beca19 tools/checks/p 021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs -4b4e9e84b21d78dcee9cb8ef4bc40155ebdb3dceb7e064715a93e89345a098ce tools/checks/release_0318.py +43db52365768cf1c40474544c8201ee0993e5cba1b3dd112cca82811cc0e790f tools/checks/release_0318.py e73afc62214e9092c180730f624a2039ac3fc850dcd865b2b117c11f7caaf88b tools/checks/release_0318_ui_test.mjs -d7483d0456ccd9d8b328587b733598020a0a6fa4496be0527c603c90fbd380b4 tools/checks/release_smoke.py +aaf65a5a9ce7494728035ecd7c654778cacd878a9b095be688b9c1491986d654 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 -1c747fd65f48a0ec7650c7b8144ea97d9de8d78014d5ddaef82792d18a026f2b tools/checks/security_logs_0315_ui_test.mjs +4823b7e30d70a9bf7f856907cc9ee74f274d326322df86c367a0909e1da3b922 tools/checks/security_logs_0315_ui_test.mjs 70e5632f36e2387be8638c33f371aacca57bf264ef67846a6e0bd7f2dd19a5e0 tools/checks/source_module_contracts.py 37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py e9f96a2a44712feb090ddb0eb273124ac780cb68e428292f7c7f106de11be050 tools/checks/stream_0320_ui_test.mjs @@ -523,7 +525,8 @@ a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/v 161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs ea98e8ce2bdc841814e486ea4c13f94e284b2c50a5887598b7a288f75a14deaf tools/checks/vhost_www_ui_test.mjs 7aeef339092ab07aa0d906fce223ff34ecc1ee8dbd6b3f173d94c521a79558ac tools/checks/web_assets.py -618f9c6b20c043d151eac319d1712ec50358ae86b2e7fa23418c8e4bbcc45963 tools/checks/web_server_contracts.py +8d2e1837f94302d09a704aca452cc367e6892f289aca3dc2d82c086a2e7de26d tools/checks/web_cache_revalidation_0322.py +8ec055a16198701dd4fa35eac42a71b666d2b9bbe90edb1c797c7ed759615487 tools/checks/web_server_contracts.py 35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs 4c541640a7edcdb69319770d6359e7715617df8f5443f82b29f867a5e820a08e tools/checks/www_redirect_ui_test.mjs 7c30c280d0b0a8bf855eeffab583ca34dc161255f7ef6124148aa979c17c26d3 tools/dev/memory_load.py diff --git a/dev.sh b/dev.sh index b4e5f80..0b6c6bc 100755 --- a/dev.sh +++ b/dev.sh @@ -103,6 +103,7 @@ if [[ "$mode" == "test" ]]; then python3 tools/checks/memory_contracts.py python3 tools/checks/high_traffic_memory_model.py python3 tools/checks/web_server_contracts.py + python3 tools/checks/web_cache_revalidation_0322.py python3 tools/checks/security_contracts.py python3 tools/checks/admin_surface_audit_0310.py python3 tools/checks/security_dependency_contracts.py diff --git a/docs/CACHE.md b/docs/CACHE.md index dfb8955..b8fb4d2 100644 --- a/docs/CACHE.md +++ b/docs/CACHE.md @@ -30,11 +30,19 @@ Vhost response rules run before cache fill, and transformed output is what is stored. Private/`Set-Cookie`/`no-store` origin responses remain ineligible, even if a later response-header rule replaces their visible cache headers. -For Web Server sources, stale entries are refreshed by a new local-source -request rather than the remote-backend revalidation worker. As with reverse -proxy origins, range requests and conditional requests can reuse fresh stored -representations. Do not rely on stale-if-error remote-backend refresh behavior -for local Web Server routes. +For Web Server sources (0.32.2), requests with `Cache-Control: no-cache`, +`max-age=0`, or an expired/inadequately fresh representation check the local +origin using the stored origin `ETag` / `Last-Modified`. When the Web Server +returns `304 Not Modified`, RustProxy refreshes the same shared-cache object, +keeps its body and increments `X-Cache-Hits`. The original browser conditional +headers determine whether the client receives 304 or the cached 200 body. +Changed static files cause a new response/cache fill; `no-store`/`private` +origin policy continues to prevent shared storage. HEAD and single-range +requests can reuse a successfully revalidated full object. A Web Server +source does not run the remote-backend background revalidation worker or its +stale-if-error fallback. A PHP handler that ignores validators may still +return 200, which must be handled as a real origin response rather than an +assumed unchanged object. ## HTTP BAN / PURGE from authorized client IPs diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index e69de29..6352846 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -0,0 +1,45 @@ +# Verification report - RustProxy 0.32.2 + +## Scope + +Source-based patch on the supplied 0.32.1 archive. The Web Server's cache +revalidation now mirrors the existing Reverse Proxy's 304 handling for local +files, while preserving original browser conditionals, cache policy and cached +transformed representations. No backend, PHP-FPM, API or SQLite schema change. + +## Regression cases added (Rust, execution pending) + +- F5-style `Cache-Control: max-age=0` and hard-refresh `no-cache` preserve body + and advance `X-Cache-Hits` when local origin returns 304. +- Browser `If-None-Match` produces downstream 304 only when the client's validator + matches the public stored representation, not merely the origin's validator. +- Unrelated client validators cannot incorrectly validate the stored body. +- `HEAD` and single-range conditional refresh reuse the validated full body. +- A changed asset (different on-disk ETag) returns 200 rather than a false 304. +- Newly private origin revalidation metadata cannot remain in shared storage. + +## Results and limitations + +- Python static/structural checks via `./dev.sh test`: **PASS** (including + version, full source preflight, cache regression contracts, HTTP/cache, + security, documentation, schema-v9/upgrade and release smoke contracts). +- All **24 Node.js UI simulations** configured in `./dev.sh test`: **PASS**. + Three existing fixture assumptions were aligned with current unindented + functions/version (`backend_cache_0297_ui_test.mjs`, + `backend_health_0300_ui_test.mjs`, `security_logs_0315_ui_test.mjs`); + the production panel logic is unchanged. +- Python load-tool selftest against a Python peer: **PASS**; not a RustProxy + server test or a memory profile of the proxy. +- `./dev.sh check`: **unavailable (exit 127)**; Rust/Cargo is not installed. +- `./dev.sh test`: **pre-Cargo checks PASS; entire command exits 127** because + Rust/Cargo is not installed, so no Rust tests executed. +- Rust `cargo check --locked --all-targets`: **not run, Cargo unavailable**. +- Rust `cargo test --locked --all-targets`: **not run, Cargo unavailable**. +- Real SQLite schema migration/update on an installed instance: **not run**; + only source/upgrade contracts were checked. +- Actual RustProxy process, HTTP/1.1, HTTP/2, HTTP/3, PHP-FPM/WordPress and + browser DevTools reproduction: **not run** in this source-only environment. + +**Production acceptance remains open.** Run `./dev.sh check`, `./dev.sh test` +and repeat browser-refresh/GET comparisons on a Rust 1.99+ build host before +rolling the release out. The patched source is delivered, not a tested binary. diff --git a/src/admin/openapi.json b/src/admin/openapi.json index ae6660d..c0b50ce 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.32.1", + "version": "0.32.2", "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": [ diff --git a/src/proxy/forward/web_source.rs b/src/proxy/forward/web_source.rs index 89da405..809ac4f 100644 --- a/src/proxy/forward/web_source.rs +++ b/src/proxy/forward/web_source.rs @@ -68,7 +68,15 @@ let range = crate::cache::request_range(&request_method, &request_headers); let if_range = request_headers.get("if-range").cloned(); let range_requested = !matches!(range, crate::cache::RangeSelection::None); - let needs_validation = policy.revalidation_reason().is_some(); + // Unlike the original Web Server path, retain stale and client-forced + // revalidation candidates. A local 304 can refresh them without replacing + // the cached body or resetting X-Cache-Hits (same as Reverse Proxy). + let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() { + state.cache.revalidation_tiered(ticket).await + } else { None }; + let needs_validation = policy.revalidation_reason().is_some() + || cached_revalidation.as_ref().is_some_and(|cached| + !cached.reusable_for_request(&request_headers)); if let Some(ticket) = &cache_ticket { if !needs_validation { if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await { @@ -114,9 +122,12 @@ // Neither the Request nor the streaming Body is held across a borrowed // await: the filesystem/PHP source owns the body for its full lifetime. let static_body = std::mem::replace(request.body_mut(), Body::empty()); + let (origin_request_headers, revalidation_sent) = + web_cache_revalidation::web_origin_request_headers( + &request_headers, cached_revalidation.as_ref()); let started = Instant::now(); let response = crate::web_server::serve(service, request_method.clone(), - request_headers.clone(), request_uri, &service_path, static_body, + origin_request_headers, request_uri, &service_path, static_body, crate::web_server::RequestContext { client_ip: context.client_ip, client_port: Some(peer.0.port()), @@ -131,6 +142,45 @@ ).await { return fallback; } let mut response = intercept_route_origin_error(response, route, &snapshot); + if revalidation_sent && response.status() == StatusCode::NOT_MODIFIED { + if let (Some(ticket), Some(cached)) = + (cache_ticket.as_ref(), cached_revalidation.as_ref()) + { + let revalidated = web_cache_revalidation::finish_web_revalidation( + &state.cache, ticket, cached, response.headers(), cache_settings, + started.elapsed(), &request_headers, &request_method, + ignore_cookie, force_cache, + |headers| apply_response_rules(headers, &snapshot, + route.frontend.as_deref(), Some(route), false), + ); + if let Some(mut hit) = revalidated { + state.cache.record(scope, &host, "HIT", hit.reason); + if hit.transformed { state.transform_metrics.record_cache_hit(scope); } + if let Some(exchange) = request.extensions().get::>() { + exchange.cache_status.store(1, Ordering::Relaxed); + *exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = hit.reason; + } + hit.response.extensions_mut().insert(ResponseRulesApplied); + hit.response.extensions_mut().insert(StaticSourceResponse); + return intercept_route_origin_error(hit.response, route, &snapshot); + } + // A simultaneous PURGE/config change invalidated the ticket. Do + // not accidentally return an origin 304 without a reusable body. + return error(StatusCode::SERVICE_UNAVAILABLE, + "RustProxy: Web Server cache entry invalidated during revalidation\n"); + } + } + if cached_revalidation.is_some() && response.status() != StatusCode::NOT_MODIFIED + && response.status().as_u16() < 500 + { + // A changed response (including a 206, 404 or an uncacheable 200) + // supersedes the old object. Also discard when the old entry has no + // validators and therefore must be fetched unconditionally. A complete + // cacheable GET below can refill the entry. + if let Some(ticket) = cache_ticket.as_ref() { + state.cache.discard(ticket); + } + } let origin_headers = response.headers().clone(); let original_plan = crate::cache::plan(response.status(), &origin_headers, cache_settings, started.elapsed()); diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index f1375ad..3609b03 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -1,6 +1,7 @@ include!("types.rs"); include!("error_fallback.rs"); include!("cache_helpers.rs"); +mod web_cache_revalidation; include!("cache_http_invalidation.rs"); include!("upstream_helpers.rs"); include!("redirect_helpers.rs"); diff --git a/src/proxy/web_cache_revalidation.rs b/src/proxy/web_cache_revalidation.rs new file mode 100644 index 0000000..4dd743b --- /dev/null +++ b/src/proxy/web_cache_revalidation.rs @@ -0,0 +1,129 @@ +//! Conditional validation for a local Web Server origin. The reverse-proxy +//! source has an equivalent 304 path; both must preserve the stored body and +//! its hit counter when the origin reports that the representation is unchanged. + +use crate::cache::{Cache, RangeSelection, Revalidation, Settings, Ticket}; +use axum::{body::Body, response::Response}; +use http::{HeaderMap, HeaderValue, Method, StatusCode}; +use std::time::Duration; + +pub(super) struct WebRevalidated { + pub response: Response, + pub transformed: bool, + pub reason: &'static str, +} + +/// The client's validators describe the *public* representation (possibly +/// transformed). Only validators belonging to our stored origin representation +/// may be sent to the Web Server. Never allow an unrelated client ETag to turn +/// into a 304 that would incorrectly validate our cached body. +pub(super) fn web_origin_request_headers( + client: &HeaderMap, + cached: Option<&Revalidation>, +) -> (HeaderMap, bool) { + let mut headers = client.clone(); + let Some(cached) = cached else { return (headers, false) }; + headers.remove("if-none-match"); + headers.remove("if-modified-since"); + let sent = cached.apply_validators(&mut headers); + (headers, sent) +} + +/// A transformed body uses public representation-specific headers. A local +/// origin's 304 can describe its own encoding/ranges, which must not be copied +/// onto the cached, already transformed body. +fn web_merged_validation_headers(cached: &Revalidation, validation: &HeaderMap) -> HeaderMap { + let mut merged = cached.merge_validation_headers(validation); + if cached.transformed() { + for name in [ + "content-encoding", "vary", "accept-ranges", "content-md5", "digest", "content-digest", + ] { + merged.remove(name); + for value in cached.headers().get_all(name) { + merged.append(name, value.clone()); + } + } + } + merged +} + +/// Handle a real 304 from the local origin, *after* matching it to a stored +/// object using our own validator. A successful validation increases HITs and +/// refreshes freshness metadata; an uncacheable 304 is served once and purged. +/// `apply_rules` keeps vhost response rules identical to the ordinary Web +/// Server 200/fill path. Invalidated tickets are never resurrected. +pub(super) fn finish_web_revalidation( + cache: &Cache, + ticket: &Ticket, + cached: &Revalidation, + validation: &HeaderMap, + settings: &Settings, + elapsed: Duration, + client: &HeaderMap, + method: &Method, + ignore_cookie: bool, + force_cache: bool, + apply_rules: impl FnOnce(&mut HeaderMap), +) -> Option { + let origin_merged = web_merged_validation_headers(cached, validation); + // Vhost response rules must never turn a newly private/no-store origin + // into a cacheable shared representation. + let origin_safe = crate::cache::plan(StatusCode::OK, &origin_merged, settings, elapsed).is_some(); + let mut merged = origin_merged; + apply_rules(&mut merged); + if force_cache { + if client.contains_key("cookie") { + merged.append("vary", HeaderValue::from_static("Cookie")); + } + if client.contains_key("authorization") { + merged.append("vary", HeaderValue::from_static("Authorization")); + } + } + let can_store = origin_safe && !(ignore_cookie && crate::cache::varies_on(&merged, "cookie")); + let client_not_modified = crate::cache::conditional_not_modified(client, &merged); + let range = crate::cache::request_range(method, client); + let if_range = client.get("if-range"); + let published = if can_store { + cache.publish_revalidated(ticket.clone(), cached, merged.clone(), validation, settings, elapsed) + } else { None }; + let (mut response, hits, ttl, resident_age, transformed, reason) = + if let Some(mut hit) = published { + if !client_not_modified { + if let RangeSelection::Single(selection) = range { + hit = hit.apply_range(selection, if_range); + } + } + ( + hit.response, hit.hits, hit.remaining_ttl, hit.resident_age_seconds, + hit.transformed, "cache_revalidated", + ) + } else { + if !cache.ticket_current(ticket) { + return None; + } + cache.discard(ticket); + let response = if !client_not_modified { + if let RangeSelection::Single(selection) = range { + cached.response_with_range(merged, selection, if_range) + } else { + cached.response(merged) + } + } else { + cached.response(merged) + }; + (response, 0, 0, 0, cached.transformed(), "cache_revalidated_uncached") + }; + if client_not_modified { + response = crate::cache::make_not_modified(response); + } + if *method == Method::HEAD { + *response.body_mut() = Body::empty(); + } + settings.annotate_reason(response.headers_mut(), "HIT", hits, ttl, reason); + settings.annotate_transformed(response.headers_mut(), transformed); + settings.annotate_resident_age(response.headers_mut(), resident_age); + Some(WebRevalidated { response, transformed, reason }) +} + +#[cfg(test)] +mod tests; diff --git a/src/proxy/web_cache_revalidation/tests.rs b/src/proxy/web_cache_revalidation/tests.rs new file mode 100644 index 0000000..0232c26 --- /dev/null +++ b/src/proxy/web_cache_revalidation/tests.rs @@ -0,0 +1,166 @@ +use super::*; +use crate::{cache, web_server}; +use http_body_util::BodyExt; +use tempfile::TempDir; +use std::sync::Arc; + +fn cache_settings() -> Settings { + Settings { + enabled: true, + expose_status: true, + expose_hits: true, + expose_cache_status: true, + ..Default::default() + } +} + +async fn fixture() -> (TempDir, web_server::CompiledService, Arc, Ticket, Settings) { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("shCore.js"), b"before").unwrap(); + let service = web_server::Service { + id: "wordpress".into(), + name: "WordPress".into(), + document_root: dir.path().to_path_buf(), + cache_control: Some("public, max-age=604800".into()), + ..Default::default() + }.compile().unwrap(); + let cache = Cache::new(1); + let settings = cache_settings(); + let ticket = cache.ticket( + 1, "https", "blog.example.test", "wordpress", + "/shCore.js?ver=3.0.9b", &HeaderMap::new(), + ).unwrap(); + let initial = serve_file(&service, Method::GET, HeaderMap::new()).await; + assert_eq!(initial.status(), StatusCode::OK); + let plan = cache::plan(initial.status(), initial.headers(), &settings, Duration::ZERO) + .expect("static origin response is cacheable"); + cache.wrap(initial, ticket.clone(), plan, &settings) + .into_body().collect().await.unwrap(); + (dir, service, cache, ticket, settings) +} + +async fn serve_file(service: &web_server::CompiledService, method: Method, headers: HeaderMap) -> Response { + web_server::serve( + service, method, headers, + "/shCore.js?ver=3.0.9b".parse().unwrap(), "/shCore.js", Body::empty(), + web_server::RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: Some(12345), + server_name: "blog.example.test".into(), + server_port: 443, + tls: true, + version: http::Version::HTTP_2, + }, + ).await +} + +async fn validate( + service: &web_server::CompiledService, cache: &Cache, ticket: &Ticket, + settings: &Settings, method: Method, client: HeaderMap, +) -> Option { + let cached = cache.revalidation_tiered(ticket).await.unwrap(); + let (headers, sent) = web_origin_request_headers(&client, Some(&cached)); + assert!(sent, "stored validator must be used for local source revalidation"); + let origin = serve_file(service, method.clone(), headers).await; + if origin.status() != StatusCode::NOT_MODIFIED { + return None; + } + finish_web_revalidation(cache, ticket, &cached, origin.headers(), settings, + Duration::ZERO, &client, &method, false, false, |_| {}) +} + +#[tokio::test] +async fn browser_refresh_304_preserves_web_server_hit_counter() { + let (_dir, service, cache, ticket, settings) = fixture().await; + assert_eq!(cache.get_tiered(&ticket, &settings).await.unwrap().hits, 1); + for directive in ["max-age=0", "no-cache"] { + let mut client = HeaderMap::new(); + client.insert("cache-control", directive.parse().unwrap()); + let response = validate(&service, &cache, &ticket, &settings, Method::GET, client) + .await.unwrap().response; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["x-cache"], "HIT"); + let hits = response.headers()["x-cache-hits"].to_str().unwrap().parse::().unwrap(); + assert!(hits >= 2); + assert_eq!(response.headers()["x-cache-reason"], "cache_revalidated"); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "before"); + } + assert_eq!(cache.get_tiered(&ticket, &settings).await.unwrap().hits, 4); +} + +#[tokio::test] +async fn browser_conditional_refresh_returns_304_without_resetting_hits() { + let (_dir, service, cache, ticket, settings) = fixture().await; + let etag = cache.revalidation_tiered(&ticket).await.unwrap().headers()["etag"].clone(); + let mut client = HeaderMap::new(); + client.insert("cache-control", HeaderValue::from_static("no-cache")); + client.insert("if-none-match", etag); + let response = validate(&service, &cache, &ticket, &settings, Method::GET, client) + .await.unwrap().response; + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + assert!(!response.headers().contains_key("content-length")); + assert_eq!(response.headers()["x-cache-hits"], "1"); + assert!(response.into_body().collect().await.unwrap().to_bytes().is_empty()); + assert_eq!(cache.get_tiered(&ticket, &settings).await.unwrap().hits, 2); +} + +#[tokio::test] +async fn mismatched_browser_validator_cannot_validate_different_cached_object() { + let (_dir, service, cache, ticket, settings) = fixture().await; + let mut client = HeaderMap::new(); + client.insert("cache-control", HeaderValue::from_static("no-cache")); + client.insert("if-none-match", HeaderValue::from_static("\"unrelated\"")); + let response = validate(&service, &cache, &ticket, &settings, Method::GET, client) + .await.unwrap().response; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "before"); +} + +#[tokio::test] +async fn range_and_head_requests_use_validated_cached_body() { + let (_dir, service, cache, ticket, settings) = fixture().await; + let mut range = HeaderMap::new(); + range.insert("cache-control", HeaderValue::from_static("max-age=0")); + range.insert("range", HeaderValue::from_static("bytes=0-2")); + let partial = validate(&service, &cache, &ticket, &settings, Method::GET, range) + .await.unwrap().response; + assert_eq!(partial.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(partial.into_body().collect().await.unwrap().to_bytes(), "bef"); + let head = validate(&service, &cache, &ticket, &settings, Method::HEAD, HeaderMap::new()) + .await.unwrap().response; + assert_eq!(head.status(), StatusCode::OK); + assert!(head.into_body().collect().await.unwrap().to_bytes().is_empty()); +} + +#[tokio::test] +async fn changed_local_file_is_not_falsely_revalidated() { + let (dir, service, cache, ticket, settings) = fixture().await; + // A size change guarantees different ETag even on filesystems with + // coarse modification-time resolution. + std::fs::write(dir.path().join("shCore.js"), b"new file content").unwrap(); + let cached = cache.revalidation_tiered(&ticket).await.unwrap(); + let (headers, sent) = web_origin_request_headers(&HeaderMap::new(), Some(&cached)); + assert!(sent); + let origin = serve_file(&service, Method::GET, headers).await; + assert_eq!(origin.status(), StatusCode::OK); + let plan = cache::plan(origin.status(), origin.headers(), &settings, Duration::ZERO).unwrap(); + cache.discard(&ticket); + cache.wrap(origin, ticket.clone(), plan, &settings) + .into_body().collect().await.unwrap(); + assert_eq!(cache.get_tiered(&ticket, &settings).await.unwrap().hits, 1); +} + +#[tokio::test] +async fn 304_new_private_policy_discards_cached_representation() { + let (_dir, _service, cache, ticket, settings) = fixture().await; + let cached = cache.revalidation_tiered(&ticket).await.unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("cache-control", HeaderValue::from_static("private, max-age=300")); + headers.insert("etag", cached.headers()["etag"].clone()); + let outcome = finish_web_revalidation(&cache, &ticket, &cached, &headers, + &settings, Duration::ZERO, &HeaderMap::new(), &Method::GET, + false, false, |_| {}).unwrap(); + assert_eq!(outcome.reason, "cache_revalidated_uncached"); + assert_eq!(outcome.response.into_body().collect().await.unwrap().to_bytes(), "before"); + assert!(cache.revalidation_tiered(&ticket).await.is_none()); +} diff --git a/tools/checks/backend_cache_0297_ui_test.mjs b/tools/checks/backend_cache_0297_ui_test.mjs index 4bfecab..264685d 100644 --- a/tools/checks/backend_cache_0297_ui_test.mjs +++ b/tools/checks/backend_cache_0297_ui_test.mjs @@ -9,8 +9,8 @@ const core = fs.readFileSync(`${root}/src/admin/web/vhosts/core.js`, 'utf8'); const editor = fs.readFileSync(`${root}/src/admin/web/vhosts/editor.js`, 'utf8'); const settings = fs.readFileSync(`${root}/src/admin/web/app/settings.js`, 'utf8'); const html = fs.readFileSync(`${root}/src/admin/web/index.html`, 'utf8'); -const start = core.indexOf(' function memberRow('); -const end = core.indexOf(' function countryRateEditor(', start); +const start = core.indexOf('function memberRow('); +const end = core.indexOf('function countryRateEditor(', start); if (start < 0 || end <= start) throw new Error('Backend editor code not found'); const check = (condition, reason) => { if (!condition) throw new Error(reason); }; diff --git a/tools/checks/backend_health_0300_ui_test.mjs b/tools/checks/backend_health_0300_ui_test.mjs index a7c326a..086ea3c 100644 --- a/tools/checks/backend_health_0300_ui_test.mjs +++ b/tools/checks/backend_health_0300_ui_test.mjs @@ -8,8 +8,8 @@ const core = fs.readFileSync('src/admin/web/vhosts/core.js', 'utf8'); const health = fs.readFileSync('src/admin/web/vhosts/health.js', 'utf8'); const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8'); const render = fs.readFileSync('src/admin/web/vhosts/render.js', 'utf8'); -const first = core.indexOf(' function memberRow('); -const last = core.indexOf(' function countryRateEditor(', first); +const first = core.indexOf('function memberRow('); +const last = core.indexOf('function countryRateEditor(', first); const assert = (condition, message) => { if (!condition) throw new Error(message); }; assert(first >= 0 && last > first, 'Vhost member editor missing'); class Element { diff --git a/tools/checks/certificate_api_0319.py b/tools/checks/certificate_api_0319.py index fe47455..928310c 100755 --- a/tools/checks/certificate_api_0319.py +++ b/tools/checks/certificate_api_0319.py @@ -7,7 +7,7 @@ import tomllib root = Path(__file__).resolve().parents[2] read = lambda path: (root / path).read_text(encoding='utf-8') version = tomllib.loads(read('Cargo.toml'))['package']['version'] -assert version == '0.32.1', version +assert version == '0.32.2', version spec = json.loads(read('src/admin/openapi.json')) assert spec['info']['version'] == version paths = spec['paths'] @@ -64,5 +64,5 @@ for language in ('en_US', 'pl_pl'): for key in ('certificateApiView', 'certificateApiEdit', 'certificateApiSave', 'certificateApiRegenerate', 'certificateApiRegenerateConfirm', 'certificateApiTokenHidden', 'certificateApiCreatedAt'): assert strings.get(key), (language, key) -assert '0.32.1' in read('docs/TEST_REPORT.md') +assert '0.32.2' in read('docs/TEST_REPORT.md') print('PASS: 0.31.9 per-credential API routes, separated metadata editing/token regeneration, OpenAPI security, UI and locales (source-only).') diff --git a/tools/checks/check_source.py b/tools/checks/check_source.py index 7f030ea..9f95309 100755 --- a/tools/checks/check_source.py +++ b/tools/checks/check_source.py @@ -24,7 +24,7 @@ def read_docker_agent_source() -> str: preflight(root) manifest = tomllib.loads((root / "Cargo.toml").read_text()) -assert manifest["package"]["version"] == "0.32.1" +assert manifest["package"]["version"] == "0.32.2" 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.32.1"' in lock + assert 'name = "rust-proxy"\nversion = "0.32.2"' 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.32.1", - "CHANGELOG.md": "## 0.32.1", - "ROADMAP.md": "## 0.32.1", - "AGENTS.md": "Current source version: 0.32.1.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.1", + "README.md": "# RustProxy 0.32.2", + "CHANGELOG.md": "## 0.32.2", + "ROADMAP.md": "## 0.32.2", + "AGENTS.md": "Current source version: 0.32.2.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.32.2", }.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.32.1"', '"/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.32.2"', '"/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 @@ -894,7 +894,7 @@ assert 'id="web-service-compression-options"' in html for marker in [ 'function syncCompression()', '$("web-service-compression-options").hidden = disabled', - '$( `web-service-compression-${name}` ).disabled = disabled', + '$(`web-service-compression-${name}`).disabled = disabled', '$("web-service-compression").addEventListener("change", syncCompression)', ]: assert marker in web_service_ui_0270, marker @@ -1396,7 +1396,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.32.1"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.32.2"' in openapi # 0.24.1 compiler and hostname-cardinality regressions. tls_0241 = rust_source(root, "src/tls.rs") diff --git a/tools/checks/dependency_upgrade_0312.py b/tools/checks/dependency_upgrade_0312.py index f85b360..c30d2a7 100755 --- a/tools/checks/dependency_upgrade_0312.py +++ b/tools/checks/dependency_upgrade_0312.py @@ -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.32.1' +assert manifest['package']['version'] == '0.32.2' assert manifest['package']['rust-version'] == '1.99' deps = manifest['dependencies'] expected_requirements = { diff --git a/tools/checks/docs_check.py b/tools/checks/docs_check.py index 3d56f04..284073d 100755 --- a/tools/checks/docs_check.py +++ b/tools/checks/docs_check.py @@ -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.32.1") +assert readme.startswith("# RustProxy 0.32.2") 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 b05e3bc..295055a 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.32.1' in (ROOT / "Cargo.toml").read_text() +assert '0.32.2' in (ROOT / "Cargo.toml").read_text() assert '',1)[0] body = (ROOT / 'src/proxy/cache_helpers.rs').read_text() diff --git a/tools/checks/release_0318.py b/tools/checks/release_0318.py index 7b74da8..e820f4e 100755 --- a/tools/checks/release_0318.py +++ b/tools/checks/release_0318.py @@ -8,9 +8,9 @@ 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.32.1' -assert 'name = "rust-proxy"\nversion = "0.32.1"' in read('Cargo.lock') -assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.1' +assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.32.2' +assert 'name = "rust-proxy"\nversion = "0.32.2"' in read('Cargo.lock') +assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.32.2' pages = read('src/pages.rs') for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504): diff --git a/tools/checks/release_smoke.py b/tools/checks/release_smoke.py index 56ced0c..312ac2a 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.32.1"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert 'version = "0.32.2"' 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.32.1" + assert manifest["to_version"] == "0.32.2" 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.32.1"' in openapi_0241 + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.32.2"' 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.32.1"', '"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.32.2"', '"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"']: diff --git a/tools/checks/security_logs_0315_ui_test.mjs b/tools/checks/security_logs_0315_ui_test.mjs index 2341d83..7f62bd9 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\.32\.1"/); +assert.match(manifest, /version = "0\.32\.2"/); 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.32.1'); +assert.equal(openapi.info.version, '0.32.2'); 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/web_cache_revalidation_0322.py b/tools/checks/web_cache_revalidation_0322.py new file mode 100755 index 0000000..0e22d4e --- /dev/null +++ b/tools/checks/web_cache_revalidation_0322.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Rust-independent structural checks; the Rust tests require Cargo.""" +from pathlib import Path +ROOT = Path(__file__).resolve().parents[2] +source = (ROOT / 'src/proxy/forward/web_source.rs').read_text() +helper = (ROOT / 'src/proxy/web_cache_revalidation.rs').read_text() +tests = (ROOT / 'src/proxy/web_cache_revalidation/tests.rs').read_text() +assert 'mod web_cache_revalidation;' in (ROOT / 'src/proxy/mod.rs').read_text() +assert 'state.cache.revalidation_tiered(ticket).await' in source +assert '!cached.reusable_for_request(&request_headers)' in source +assert 'web_origin_request_headers(' in source +assert 'revalidation_sent && response.status() == StatusCode::NOT_MODIFIED' in source +assert 'finish_web_revalidation(' in source +assert 'state.cache.discard(ticket)' in source +assert 'cache.publish_revalidated(' in helper +assert 'cache.ticket_current(ticket)' in helper +assert 'cache.discard(ticket)' in helper +assert 'origin_safe' in helper +assert 'cached.apply_validators(&mut headers)' in helper +assert 'headers.remove("if-none-match")' in helper +assert 'headers.remove("if-modified-since")' in helper +for name in ( + 'browser_refresh_304_preserves_web_server_hit_counter', + 'browser_conditional_refresh_returns_304_without_resetting_hits', + 'mismatched_browser_validator_cannot_validate_different_cached_object', + 'range_and_head_requests_use_validated_cached_body', + 'changed_local_file_is_not_falsely_revalidated', + '304_new_private_policy_discards_cached_representation', +): + assert f'async fn {name}()' in tests +print('PASS: RustProxy 0.32.2 Web Server revalidation wiring and Rust regression contracts (static only)') diff --git a/tools/checks/web_server_contracts.py b/tools/checks/web_server_contracts.py index bb4643b..f4ef4bb 100755 --- a/tools/checks/web_server_contracts.py +++ b/tools/checks/web_server_contracts.py @@ -136,8 +136,11 @@ assert basic_identity_pos < web_source_pos assert identity_pos < web_source_pos assert web_source_pos < proxy.index("let (backend, backend_lease, affinity_cookie)") web_source = rust_source(ROOT, "src/proxy/forward/web_source.rs") -assert web_source.index("apply_response_rules(") > web_source.index("let origin_headers =") -assert web_source.index("crate::transform::apply(") > web_source.index("apply_response_rules(") +# The 0.32.2 local-304 path also applies response rules, before the normal +# 200-origin processing. Enforce order using the latter's distinct call site. +normal_origin_rules = "apply_response_rules(response.headers_mut()," +assert web_source.index(normal_origin_rules) > web_source.index("let origin_headers =") +assert web_source.index("crate::transform::apply(") > web_source.index(normal_origin_rules) need(acl, "ambiguous encoded path character", '"/%2e%2e/secret"', '"/a%2fb"', '"/a%5cb"', '"/bad%"') need(admin, '"/web-server"', '"web-services"', '"vhost_metrics"') need((ROOT / "src/admin/panel_assets.rs").read_text(), '"/assets/web-server.js"')