From 1750c417ed6d8a90a808730c0ac4d05ec721864e Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Tue, 29 Sep 2026 02:30:29 -0500 Subject: [PATCH] =?UTF-8?q?ariadne:=20app-wide=20HTTP(s)=20cache=20in=20th?= =?UTF-8?q?e=20state=20tree=20(=C2=A713.9,=20PR2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a caching http(s):// READ handler backed by the cvc::app state tree, over the cvc::net facade (PR1). register_cached_http_uri_handler(app) parks entries at sys.net.http_cache.entries. (key = cvc::sha256_hex of the normalized URL): the body on the entry node's data() channel (a raw std::string blob -- reads back as `bytes` in state_exec and serves via state://...?data, per the precursor), and metadata (status/etag/last_modified/content_type/effective_url/fetched_at/ freshness_ttl) as child value nodes. Freshness (SOFT) serves with no network while now < fetched_at + freshness_ttl (Cache-Control max-age, anchored at now-Age; else a validator-present-=>revalidate heuristic; no-cache => revalidate; no-store => bypass). A stale entry or a miss does a conditional GET (If-None-Match / If-Modified-Since): a 304 refreshes the entry (honoring the 304's own directives + any refreshed validators) and serves the cached body, a 200 replaces body + validators + TTL. Retention (HARD) rides the state tree's node expiry: expireAt = fetched_at + max(retention, freshness), swept on access (entries.sweepExpired()), which frees the body blob; a successful store invalidates the entry. Concurrent requests for one URL coalesce (single-flight). A credentialed request -- provider auth headers OR URL-embedded userinfo -- BYPASSES the cache (no cross-principal reuse in a process-global cache). Prerequisite refactor: the shared request-build / response-map / store policy moves out of uri_http.cpp's anonymous namespace into a private (non-installed) uri_http_detail.h -- consult_http_provider, http_header_present, map_http_fetch_result, http_store_uncached -- so the cache and the plain handler apply identical policy. map_http_fetch_result now also maps a 304 to an error, so a bodyless 304 can never surface as an empty-body success on any path. Concurrency: cvc::state's findDescendant()/operator() hand back a bare pointer/ref whose only owner is the parent's child map, so a concurrent sweepExpired() can free a node mid-access. All access to the entries subtree (sweep + snapshot read + write + invalidation) is serialized under one process-wide cache_mutex, held only for the brief tree work and released across the (blocking) network send; cache_mutex and the single-flight flight_mutex are never held simultaneously. Reviewed adversarially (15 findings, all fixed here). Tests: uri_http_cache_test (12, all offline via PR1's set_http_client fake) -- fresh-hit-no-network, 304-serve-cached, 200-replace, no-store bypass, retention eviction, credentialed + userinfo bypass, unsolicited-304 error, 304 no-store eviction, Age anchoring, single-flight coalescing, concurrent distinct keys. The existing 9 http handler tests + 159 loader + 7 net facade tests stay green (the uri_http refactor is behavior-preserving). No platform-specific code -- rides PR1's validated wasm backend. Deferred to PR3: sys.net.http_cache.policy.* knobs incl. a max-entries/max-bytes LRU budget (today bounded only by per-entry 64 MiB + retention), full Vary/private, Expires-based freshness, richer URL normalization. The transport stays synchronous (§13.8's fetch_sync); an async/awaitable chunked (http-get) intrinsic is a later PR the cache rides unchanged. --- docs/roadmap/CVCGL-UI-DSL-ROADMAP.md | 23 ++ inc/cvc/ariadne/uri_http_cache.h | 42 ++ src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/uri_http.cpp | 122 +++--- src/cvc/ariadne/uri_http_cache.cpp | 529 ++++++++++++++++++++++++++ src/cvc/ariadne/uri_http_detail.h | 50 +++ src/cvc/tests/CMakeLists.txt | 10 + src/cvc/tests/uri_http_cache_test.cpp | 319 ++++++++++++++++ 8 files changed, 1041 insertions(+), 57 deletions(-) create mode 100644 inc/cvc/ariadne/uri_http_cache.h create mode 100644 src/cvc/ariadne/uri_http_cache.cpp create mode 100644 src/cvc/ariadne/uri_http_detail.h create mode 100644 src/cvc/tests/uri_http_cache_test.cpp diff --git a/docs/roadmap/CVCGL-UI-DSL-ROADMAP.md b/docs/roadmap/CVCGL-UI-DSL-ROADMAP.md index 4d14d818..d7a93efc 100644 --- a/docs/roadmap/CVCGL-UI-DSL-ROADMAP.md +++ b/docs/roadmap/CVCGL-UI-DSL-ROADMAP.md @@ -3290,6 +3290,29 @@ instead of a full transfer. demand) the distributed state system already sketches — the cache is a first, local consumer of that path. +> **LANDED (PR2 of the §13.9 work) — the state-tree cache.** `ariadne/uri_http_cache.cpp` + +> `register_cached_http_uri_handler(cvc::app&)` (inc/cvc/ariadne/uri_http_cache.h). Entries at +> `sys.net.http_cache.entries.` (`key = cvc::sha256_hex(normalized-URL)`): body on the entry +> node's `data()` (raw `std::string` → shows as `bytes` in state_exec, serves via `state://…?data`), +> metadata (status/etag/last_modified/content_type/effective_url/fetched_at/freshness_ttl) as child +> value nodes. Freshness (Cache-Control `max-age`, anchored at `now - Age`; else `0`-with-validator +> heuristic; `no-cache`→revalidate; `no-store`→bypass) serves with no network; a stale/miss does a +> conditional GET (`If-None-Match`/`If-Modified-Since`) — 304 refreshes + serves cached, 200 replaces. +> Retention rides node `expireAt` (>= freshness) + `sweepExpired()` on access; a successful store +> invalidates. Concurrency: one process-wide `cache_mutex` serializes all `entries`-subtree work +> (sweep+read+write+invalidate) — held only briefly, released across the network send — because +> `findDescendant`/`operator()` hand back a bare pointer a concurrent `sweepExpired()` can free; +> single-flight coalesces concurrent same-URL fetches. A credentialed request (provider headers or +> URL userinfo) BYPASSES the cache (no cross-principal reuse in a process-global cache). Reviewed +> adversarially (15 findings fixed). Tests: `uri_http_cache_test` (12, offline via the PR1 +> `set_http_client` fake). **Deferred to PR3:** the `sys.net.http_cache.policy.*` knobs + a +> max-entries/max-bytes **LRU budget** (today bounded only by per-entry 64 MiB + retention, so a flood +> of distinct fresh URLs grows unbounded), full `Vary`/`private`, `Expires`-based freshness, and +> richer URL normalization (percent-encoding, query-key order). **The transport stays synchronous +> (§13.8's `fetch_sync`); an async/awaitable `(http-get)` intrinsic — chunked, off-thread on the +> compute/I/O pool, parking the state_exec process via the `compute_async → post_message → msg-recv` +> path and returning `bytes` — is a natural later PR the cache rides unchanged.** + ### 13.10 The WRITE side — a `store`/PUT capability + URI-aware `state::save`/`restore` > **Status — core LANDED** (`cvc::ariadne`): `store(uri, content, base)` + diff --git a/inc/cvc/ariadne/uri_http_cache.h b/inc/cvc/ariadne/uri_http_cache.h new file mode 100644 index 00000000..5ce78aae --- /dev/null +++ b/inc/cvc/ariadne/uri_http_cache.h @@ -0,0 +1,42 @@ +#ifndef CVC_ARIADNE_URI_HTTP_CACHE_H +#define CVC_ARIADNE_URI_HTTP_CACHE_H + +// Ariadne — an app-wide caching http(s):// URI handler (roadmap §13.9). It replaces the plain +// http(s) READ handler with one backed by the cvc::app's state tree: repeated import:/load:/source: +// of the same URL is served from a cache under `sys.net.http_cache.entries.` instead of +// re-fetching. Freshness (Cache-Control max-age / Expires) serves with NO network; a stale entry +// does a conditional GET (If-None-Match / If-Modified-Since) — a 304 revalidates cheaply, a 200 +// replaces. Retention (>= freshness) rides the state tree's own node-expiry (expireAt + +// sweepExpired, swept on access), and concurrent requests for the same URL coalesce +// (single-flight). +// +// It builds on the plain handler's transport (cvc::net) and policy (uri_http.cpp): the auth-header +// provider, the response cap, and the redirect-safety + >=400 mapping are shared. A CREDENTIALED +// request (the host provider returns headers) BYPASSES the cache entirely — a process-global cache +// must not serve one principal's authorized body to another; full Vary/`private` handling is later +// work. The WRITE side stays the plain PUT/POST store, but a successful store INVALIDATES the +// cached entry for that URL. +// +// Like state://, the cached handler needs the app for its state tree, so this is the app-bound +// variant of register_http_uri_handler(): a host calls it during setup (before load_*). It holds +// the app's root state BY POINTER — call unregister_cached_http_uri_handler() before the app is +// destroyed. A no-op (leaves the scheme unregistered) when no cvc::net backend is compiled. + +namespace cvc { +class app; +namespace ariadne { + +// Register the caching http/https READ handler and an invalidating PUT/POST WRITE handler against +// `app`'s root state (cvc::state::instance(app)). Replaces any existing http/https handlers. No-op +// if no HTTP backend is compiled (cvc::net::have_http_backend() == false). +void register_cached_http_uri_handler(cvc::app &app); + +// Tear down the cached http/https read + write handlers (leaves the schemes unregistered; a host +// that wants the plain handler back calls register_http_uri_handler()). Call before the app/root is +// destroyed. The cached data under sys.net.http_cache stays in the state tree. +void unregister_cached_http_uri_handler(); + +} // namespace ariadne +} // namespace cvc + +#endif // CVC_ARIADNE_URI_HTTP_CACHE_H diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index 4226ce75..c72b0697 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1118,7 +1118,7 @@ list(APPEND INCLUDE_FILES ../../inc/cvc/net/http_client.h) # the retained Widget tree, the Runtime + reconcile boundary, and direct # cvc::state binding, walking a pluggable Backend. It touches NO VTK/ImGui/GL — # the ImGui-over-VTK backend lives in cvcGL (src/cvcGL/ariadne). Roadmap §16.1. -list(APPEND SOURCE_FILES ariadne/ariadne.cpp ariadne/loader.cpp ariadne/uri.cpp ariadne/uri_state.cpp ariadne/uri_http.cpp ariadne/state_io.cpp ariadne/ftxui_backend.cpp) +list(APPEND SOURCE_FILES ariadne/ariadne.cpp ariadne/loader.cpp ariadne/uri.cpp ariadne/uri_state.cpp ariadne/uri_http.cpp ariadne/uri_http_cache.cpp ariadne/state_io.cpp ariadne/ftxui_backend.cpp) list(APPEND INCLUDE_FILES ../../inc/cvc/ariadne/widget.h ../../inc/cvc/ariadne/backend.h @@ -1128,6 +1128,7 @@ list(APPEND INCLUDE_FILES ../../inc/cvc/ariadne/uri.h ../../inc/cvc/ariadne/uri_state.h ../../inc/cvc/ariadne/uri_http.h + ../../inc/cvc/ariadne/uri_http_cache.h ../../inc/cvc/ariadne/state_io.h ../../inc/cvc/ariadne/scene.h ../../inc/cvc/ariadne/value.h diff --git a/src/cvc/ariadne/uri_http.cpp b/src/cvc/ariadne/uri_http.cpp index ec093a1a..86338f65 100644 --- a/src/cvc/ariadne/uri_http.cpp +++ b/src/cvc/ariadne/uri_http.cpp @@ -2,15 +2,14 @@ // // A THIN ADAPTER over cvc::net (inc/cvc/net/http_client.h). The transport — libcurl on native, // Emscripten fetch on wasm — lives behind cvc::net, so this handler works identically on both -// targets. What stays HERE is the Ariadne-specific policy the transport must not bake in: -// - the host auth-header/method provider (credentials come from the host, never the .ari doc), -// - the response-size cap, -// - the redirect-safety rules (a credentialed read does not follow; a write never follows), -// - the store Content-Type default, -// - mapping a cvc::net::HttpResponse (which reports a >= 400 as ok=true + status) to a -// UriResult/StoreResult, re-imposing ">= 400 is an error" to preserve resolve()/store() output. -// It is opt-in: a host must call register_http_uri_handler(), which registers only when a real net -// backend is compiled — otherwise the scheme is left unresolved, exactly as before. +// targets. The Ariadne-specific policy the transport must not bake in — the host auth-header/method +// provider, the response-size cap, the redirect-safety rules, the store Content-Type default, and +// the HttpResponse -> UriResult/StoreResult mapping — lives in the `detail` helpers here +// (uri_http_detail.h) so the §13.9 caching handler (uri_http_cache.cpp) reuses exactly the same +// policy. The handler is opt-in: a host must call register_http_uri_handler(), which registers only +// when a real net backend is compiled — otherwise the scheme is left unresolved, as before. + +#include "uri_http_detail.h" #include #include @@ -23,12 +22,8 @@ namespace cvc { namespace ariadne { -namespace { -// A remote fragment/library is DSL text or a modest asset; a response past this is refused rather -// than buffered whole (an availability guard, mirroring the file handler's cap). Also bounds the -// (discarded) response body of a store. -constexpr std::size_t kMaxHttpBytes = 64u * 1024u * 1024u; // 64 MiB +namespace { // A store body of unknown media type gets a neutral Content-Type so a strict server does not // form-parse or 415 it (a host that knows better passes its own via the provider). @@ -44,15 +39,23 @@ HttpOptionsProvider &provider_cell() { static HttpOptionsProvider p; return p; } -HttpOptionsProvider current_provider() { - std::lock_guard lock(provider_mutex()); - return provider_cell(); + +} // namespace + +namespace detail { + +HttpRequestOptions consult_http_provider(const std::string &url, bool for_write) { + HttpOptionsProvider p; + { + std::lock_guard lock(provider_mutex()); + p = provider_cell(); + } + if (p) + return p(url, for_write); + return HttpRequestOptions{}; } -// True if `lines` already carries a header whose name (the text before the first ':') equals `name` -// case-insensitively. Also matches libcurl's suppression form (`Name:` with an empty value), so an -// explicit suppression by the provider is respected rather than overridden by a default. -bool has_header(const std::vector &lines, const std::string &name) { +bool http_header_present(const std::vector &lines, const std::string &name) { for (const std::string &line : lines) { if (line.find(':') != name.size()) continue; // name-length must match exactly (":" right after the name) @@ -69,54 +72,37 @@ bool has_header(const std::vector &lines, const std::string &name) return false; } -UriResult http_fetch(const Uri &u, const std::string & /*base*/) { - // Consult the provider FIRST. No provider -> default GET, no extra headers. A throwing provider - // (host code) unwinds to resolve()'s barrier. - HttpRequestOptions opts; - if (const HttpOptionsProvider p = current_provider()) - opts = p(u.raw, /*for_write=*/false); - - cvc::net::HttpRequest req; - req.url = u.raw; - req.max_bytes = kMaxHttpBytes; - req.headers = opts.headers; - // libcurl re-sends custom headers (X-Api-Key, …) VERBATIM on every redirect hop and only strips - // Authorization/Cookie/Proxy-Authorization on a cross-origin redirect. So follow a redirect only - // when NO provider headers are attached (nothing to leak across an origin the untrusted .ari - // document chose); a credentialed read stops at the redirect instead of carrying the token - // onward. - const bool follow = opts.headers.empty(); - req.follow_redirects = follow; - // A method override on a read is a bodyless custom verb (default stays GET). - if (!opts.method.empty() && opts.method != "GET") - req.method = opts.method; - - const cvc::net::HttpResponse r = cvc::net::send(req); +UriResult map_http_fetch_result(const std::string &raw_url, bool follow, + const cvc::net::HttpResponse &r) { if (!r.ok) return {false, std::string(), std::string(), - "ari: http fetch of '" + u.raw + "' failed: " + r.error}; + "ari: http fetch of '" + raw_url + "' failed: " + r.error}; + // A 304 reaching the plain mapper is unexpected (only the cache sends conditional requests, and + // it serves the cached body on its own path); a bodyless 304 must never surface as an empty-body + // OK. + if (r.status == 304) + return {false, std::string(), std::string(), + "ari: http fetch of '" + raw_url + + "' returned 304 Not Modified with no cached entry to revalidate"}; // The facade does not fail a >= 400 (so a cache can see a 304); the resolver treats it as an // error. if (r.status >= 400) return {false, std::string(), std::string(), - "ari: http fetch of '" + u.raw + "' failed: HTTP status " + std::to_string(r.status)}; + "ari: http fetch of '" + raw_url + "' failed: HTTP status " + std::to_string(r.status)}; // Not following (credentialed): a 3xx would otherwise be returned as OK with the redirect PAGE as // the body. Surface it instead of silently handing back the wrong bytes. if (!follow && r.status >= 300 && r.status < 400) return {false, std::string(), std::string(), - "ari: http fetch of '" + u.raw + "' returned redirect status " + + "ari: http fetch of '" + raw_url + "' returned redirect status " + std::to_string(r.status) + " to '" + r.canonical_url + "'; a credentialed read does not follow redirects (the auth header would cross an " "origin). Resolve the target directly."}; return {true, r.body, r.canonical_url, std::string()}; } -// §13.10 the write analogue: PUT (default) or POST/… the `content` bytes to the URL. Method + auth -// headers come from the same provider as the reader (for_write = true). -StoreResult http_store(const Uri &u, const std::string &content, const std::string & /*base*/) { - HttpRequestOptions opts; - if (const HttpOptionsProvider p = current_provider()) - opts = p(u.raw, /*for_write=*/true); +StoreResult http_store_uncached(const Uri &u, const std::string &content, + const std::string & /*base*/) { + const HttpRequestOptions opts = consult_http_provider(u.raw, /*for_write=*/true); const std::string method = opts.method.empty() ? std::string("PUT") : opts.method; cvc::net::HttpRequest req; @@ -128,10 +114,9 @@ StoreResult http_store(const Uri &u, const std::string &content, const std::stri // to a host the untrusted .ari document chose. A 3xx on a store is an error the host must // resolve. req.follow_redirects = false; - // Default a neutral Content-Type when the provider supplied none (its own wins) — otherwise a - // server might form-parse opaque store bytes. + // Default a neutral Content-Type when the provider supplied none (its own wins). std::vector header_lines = opts.headers; - if (!has_header(header_lines, "Content-Type")) + if (!http_header_present(header_lines, "Content-Type")) header_lines.push_back(kDefaultStoreContentType); req.headers = std::move(header_lines); @@ -151,6 +136,31 @@ StoreResult http_store(const Uri &u, const std::string &content, const std::stri return {true, r.canonical_url, std::string()}; } +} // namespace detail + +namespace { + +UriResult http_fetch(const Uri &u, const std::string & /*base*/) { + const HttpRequestOptions opts = detail::consult_http_provider(u.raw, /*for_write=*/false); + cvc::net::HttpRequest req; + req.url = u.raw; + req.max_bytes = detail::kMaxHttpBytes; + req.headers = opts.headers; + // Follow a redirect only when uncredentialed (nothing to leak across an origin the untrusted .ari + // document chose); a credentialed read stops at the redirect instead of carrying the token + // onward. + const bool follow = opts.headers.empty(); + req.follow_redirects = follow; + // A method override on a read is a bodyless custom verb (default stays GET). + if (!opts.method.empty() && opts.method != "GET") + req.method = opts.method; + return detail::map_http_fetch_result(u.raw, follow, cvc::net::send(req)); +} + +StoreResult http_store(const Uri &u, const std::string &content, const std::string &base) { + return detail::http_store_uncached(u, content, base); +} + } // namespace void set_http_options_provider(HttpOptionsProvider provider) { diff --git a/src/cvc/ariadne/uri_http_cache.cpp b/src/cvc/ariadne/uri_http_cache.cpp new file mode 100644 index 00000000..d7201f3a --- /dev/null +++ b/src/cvc/ariadne/uri_http_cache.cpp @@ -0,0 +1,529 @@ +// Ariadne — the app-wide caching http(s):// URI handler (roadmap §13.9). See uri_http_cache.h. +// +// It sits in front of the plain handler's transport + policy (cvc::net + uri_http_detail.h) and +// adds a state-tree cache under `sys.net.http_cache.entries.`: +// - key = cvc::sha256_hex(normalized URL) — a clean 64-hex path segment. +// - body = the entry node's data() channel (a raw std::string blob; state_exec reads it +// back as `bytes`, and it serves via state://…?data — the precursor bridge). +// - metadata = child value nodes (status/etag/last_modified/content_type/effective_url/ +// fetched_at/freshness_ttl) — replication-light strings. +// - freshness = SOFT: now < fetched_at + freshness_ttl → serve with NO network; else a +// conditional GET revalidates (304 bump / 200 replace). +// - retention = HARD: the node's expireAt (>= freshness) + sweepExpired() (swept on access) +// evicts and frees the body blob — one mechanism, the state tree's own expiry. +// - single-flight = concurrent requests for one URL coalesce to a single transfer. +// +// CONCURRENCY: cvc::state uses per-node locks and findDescendant()/operator() hand back a bare +// pointer/reference whose only owner is the parent's child map, so a concurrent sweepExpired() can +// free a node mid-access. Every touch of the `entries` subtree (sweep + snapshot read + write + +// invalidation) is therefore serialized under one process-wide `cache_mutex`, held ONLY for the +// brief tree work and released across the (blocking) network send — so reads never overlap eviction +// and a multi-node entry is written/read as a unit. (Assumes nothing subscribes to the cache +// subtree and re-enters a resolve from sweepExpired()'s `expiring`/`childChanged` signals — the +// cache nodes are internal.) `cache_mutex` and the single-flight `flight_mutex` are never held +// simultaneously. +// +// Clock is UTC wall time (boost::posix_time::microsec_clock::universal_time()) — the same clock the +// node-expiry path uses, and correct for HTTP freshness (never the sim-time world_clock). A +// CREDENTIALED request (provider headers, or URL-embedded userinfo) bypasses the cache entirely. + +#include "uri_http_detail.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // cvc::sha256_hex +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { + +namespace { + +namespace pt = boost::posix_time; + +// PR3 moves these to sys.net.http_cache.policy.* (default_freshness_ttl / retention_ttl / a +// max-entries + max-bytes LRU budget — until then the cache is bounded only by per-entry cap and +// retention, so a flood of distinct fresh URLs can grow it; see the PR notes). +constexpr long kDefaultFreshnessSecs = 60; // fresh window when no Cache-Control and no validator +constexpr long kRetentionSecs = 3600; // hard-eviction floor (retention >= freshness) +constexpr char kCacheRoot[] = "sys.net.http_cache.entries"; + +// One process-wide lock serializing all access to the cache subtree (see the CONCURRENCY note). +std::mutex &cache_mutex() { + static std::mutex m; + return m; +} + +// --- URL normalization + key ------------------------------------------------------------------- + +// True if the URL authority carries a `user[:pass]@` userinfo component. Such a URL is credential- +// bearing, so (like a provider-injected auth header) it bypasses the shared cache. +bool url_has_userinfo(const std::string &raw) { + const std::size_t scheme_end = raw.find("://"); + if (scheme_end == std::string::npos) + return false; + const std::size_t auth_start = scheme_end + 3; + std::size_t auth_end = raw.find_first_of("/?", auth_start); + if (auth_end == std::string::npos) + auth_end = raw.size(); + return raw.find('@', auth_start) < auth_end; +} + +// Minimal, defensible normalization for PR2: lowercase the scheme + authority, strip a default +// :80/:443, drop any #fragment; path + query stay byte-exact. Percent-encoding and query-key +// ordering are deferred to PR3 — two spellings of the same resource key differently (a duplicate +// entry), never wrong bytes. Userinfo URLs never reach here (they bypass the cache). +std::string normalize_http_url(const std::string &raw) { + std::string s = raw; + const std::size_t hash = s.find('#'); + if (hash != std::string::npos) + s.erase(hash); + const std::size_t scheme_end = s.find("://"); + if (scheme_end == std::string::npos) + return s; // not a scheme://authority URL — hash it as-is + for (std::size_t i = 0; i < scheme_end; ++i) + s[i] = static_cast(std::tolower(static_cast(s[i]))); + const std::string scheme = s.substr(0, scheme_end); + const std::size_t auth_start = scheme_end + 3; + std::size_t auth_end = s.find_first_of("/?", auth_start); + if (auth_end == std::string::npos) + auth_end = s.size(); + std::string authority = s.substr(auth_start, auth_end - auth_start); + for (char &c : authority) + c = static_cast(std::tolower(static_cast(c))); + if (scheme == "http" && authority.size() >= 3 && + authority.compare(authority.size() - 3, 3, ":80") == 0) + authority.erase(authority.size() - 3); + else if (scheme == "https" && authority.size() >= 4 && + authority.compare(authority.size() - 4, 4, ":443") == 0) + authority.erase(authority.size() - 4); + return scheme + "://" + authority + s.substr(auth_end); +} + +std::string cache_key(const std::string &raw) { + const std::string n = normalize_http_url(raw); + return cvc::sha256_hex(reinterpret_cast(n.data()), n.size()); +} + +// --- header + Cache-Control parsing ------------------------------------------------------------ + +// Find a response header value by case-insensitive name (from "Name: value" lines). Leading spaces +// in the value are trimmed. Leaves `out` untouched and returns false if absent (so a caller can +// seed `out` with a fallback before calling). +bool find_header(const std::vector &headers, const char *name, std::string &out) { + const std::size_t nlen = std::char_traits::length(name); + for (const std::string &line : headers) { + if (line.find(':') != nlen) + continue; + bool eq = true; + for (std::size_t i = 0; i < nlen; ++i) + if (std::tolower(static_cast(line[i])) != + std::tolower(static_cast(name[i]))) { + eq = false; + break; + } + if (!eq) + continue; + const std::string v = line.substr(nlen + 1); + const std::size_t s = v.find_first_not_of(' '); + out = (s == std::string::npos) ? std::string() : v.substr(s); + return true; + } + return false; +} + +struct CacheControl { + bool no_store = false; + bool no_cache = false; + bool has_max_age = false; + long max_age = 0; +}; + +std::string trim(const std::string &s) { + const std::size_t a = s.find_first_not_of(" \t"); + if (a == std::string::npos) + return std::string(); + const std::size_t b = s.find_last_not_of(" \t"); + return s.substr(a, b - a + 1); +} + +// Parse Cache-Control by TOKENIZING on ',' and matching each directive by its NAME (the text before +// an optional '='), not by substring — so a quoted form like no-cache="Set-Cookie" or an unrelated +// value substring cannot mis-trigger a directive. +CacheControl parse_cache_control(const std::vector &headers) { + CacheControl cc; + std::string v; + if (!find_header(headers, "Cache-Control", v)) + return cc; + std::size_t start = 0; + while (start <= v.size()) { + const std::size_t comma = v.find(',', start); + const std::string tok = + trim(v.substr(start, comma == std::string::npos ? std::string::npos : comma - start)); + if (!tok.empty()) { + const std::size_t eq = tok.find('='); + std::string name = tok.substr(0, eq); + for (char &c : name) + c = static_cast(std::tolower(static_cast(c))); + if (name == "no-store") + cc.no_store = true; + else if (name == "no-cache") + cc.no_cache = true; + else if (name == "max-age" && eq != std::string::npos) { + std::string val = tok.substr(eq + 1); + const std::size_t va = val.find_first_not_of(" \t\""); + const std::size_t vb = val.find_last_not_of(" \t\""); + if (va != std::string::npos) { + val = val.substr(va, vb - va + 1); + try { + cc.max_age = std::stol(val); + cc.has_max_age = true; + } catch (const std::exception &) { + // leave has_max_age false + } + } + } + } + if (comma == std::string::npos) + break; + start = comma + 1; + } + return cc; +} + +// The response's Age (seconds since the origin generated it, per an intermediary cache); 0 if +// absent or unparseable. Anchoring freshness at `now - age` keeps a shared/CDN-cached response from +// being treated as fresh for a full max-age past when WE received it. +long parse_age(const std::vector &headers) { + std::string v; + if (!find_header(headers, "Age", v)) + return 0; + try { + const long a = std::stol(v); + return a < 0 ? 0 : a; + } catch (const std::exception &) { + return 0; + } +} + +long parse_long_or(const std::string &s, long def) { + if (s.empty()) + return def; + try { + return std::stol(s); + } catch (const std::exception &) { + return def; + } +} + +// The soft freshness window in seconds. no-cache => 0 (always revalidate); max-age wins when +// present; otherwise 0 when the response carries a validator (revalidate cheaply via a 304) or a +// small default when it carries none. (Expires-based freshness is deferred to PR3.) +long derive_freshness_ttl(const std::vector &headers, const CacheControl &cc) { + if (cc.no_cache) + return 0; + if (cc.has_max_age) + return cc.max_age < 0 ? 0 : cc.max_age; + std::string v; + const bool has_validator = + find_header(headers, "ETag", v) || find_header(headers, "Last-Modified", v); + return has_validator ? 0 : kDefaultFreshnessSecs; +} + +// --- entry read/write (all callers hold cache_mutex) ------------------------------------------- + +// A snapshot of a cached entry, captured as a value copy so nothing downstream holds a bare node +// pointer past the lock. +struct EntrySnapshot { + bool present = false; + std::string body; + std::string etag; + std::string last_modified; + std::string content_type; + std::string effective_url; + std::string fetched_at; + std::string freshness_ttl; +}; + +std::string child_value(cvc::state *e, const char *name) { + cvc::state *c = e->findDescendant(name); + return c ? c->value() : std::string(); +} + +// Read the entry at `key`; present == a node WITH a committed body blob on data(). A metadata-only +// node (a write still in flight elsewhere — though cache_mutex serializes writes, so this is belt- +// and-braces) reads as absent. CALLER MUST HOLD cache_mutex (the bare node pointer is used only +// within the critical section). +EntrySnapshot read_entry(cvc::state &entries, const std::string &key) { + EntrySnapshot snap; + cvc::state *e = entries.findDescendant(key); + if (!e) + return snap; + const boost::any d = e->data(); // by value — bind before casting a pointer into it + const std::string *body = boost::any_cast(&d); + if (!body) + return snap; // no committed body → miss + snap.present = true; + snap.body = *body; + snap.etag = child_value(e, "etag"); + snap.last_modified = child_value(e, "last_modified"); + snap.content_type = child_value(e, "content_type"); + snap.effective_url = child_value(e, "effective_url"); + snap.fetched_at = child_value(e, "fetched_at"); + snap.freshness_ttl = child_value(e, "freshness_ttl"); + return snap; +} + +bool snapshot_fresh(const EntrySnapshot &s, const pt::ptime &now) { + const long ttl = parse_long_or(s.freshness_ttl, 0); + if (ttl <= 0 || s.fetched_at.empty()) + return false; // ttl 0 (no-cache / validator-only) → always revalidate + pt::ptime fetched; + try { + fetched = pt::from_iso_string(s.fetched_at); + } catch (const std::exception &) { + return false; + } + return now < fetched + pt::seconds(ttl); +} + +// Write (create or replace) the full entry. Metadata children first, BODY LAST, then retention. +// `fetched_at` is the FRESHNESS anchor (receipt time minus the response Age). CALLER HOLDS +// cache_mutex, so a concurrent reader/sweeper cannot observe a torn entry or free the node +// mid-write. +void write_entry(cvc::state &entries, const std::string &key, long status, const std::string &etag, + const std::string &last_modified, const std::string &content_type, + const std::string &effective_url, const std::string &body, + const pt::ptime &fetched_at, long freshness_ttl) { + cvc::state &e = entries(key); // create-or-get + e("status").value(std::to_string(status)); + e("etag").value(etag); + e("last_modified").value(last_modified); + e("content_type").value(content_type); + e("effective_url").value(effective_url); + e("fetched_at").value(pt::to_iso_string(fetched_at)); + e("freshness_ttl").value(std::to_string(freshness_ttl)); + e.data(boost::any(body)); // body LAST — the commit signal for a reader + const long retention = std::max(kRetentionSecs, freshness_ttl); + e.expireAt(fetched_at + pt::seconds(retention)); +} + +// Force-expire + sweep the entry for `key` (cache invalidation). CALLER HOLDS cache_mutex. +void evict_locked(cvc::state &entries, const std::string &key, const pt::ptime &now) { + if (cvc::state *e = entries.findDescendant(key)) + e->expireAt(now - pt::seconds(1)); + entries.sweepExpired(); +} + +// --- single-flight ----------------------------------------------------------------------------- + +struct Inflight { + std::condition_variable cv; + bool done = false; + UriResult result; +}; + +std::mutex &flight_mutex() { + static std::mutex m; + return m; +} +std::unordered_map> &flight_map() { + static std::unordered_map> m; + return m; +} + +// --- fetch + cache ----------------------------------------------------------------------------- + +// The leader body: do the (conditional) network fetch and update the cache; return the UriResult. +UriResult do_fetch_and_store(cvc::state &root, const std::string &key, const Uri &u, + const EntrySnapshot &stale, const pt::ptime &now) { + cvc::net::HttpRequest req; + req.url = u.raw; + req.max_bytes = detail::kMaxHttpBytes; + req.follow_redirects = true; // only the uncredentialed path reaches here (matches http_fetch) + if (stale.present) { + if (!stale.etag.empty()) + req.headers.push_back("If-None-Match: " + stale.etag); + if (!stale.last_modified.empty()) + req.headers.push_back("If-Modified-Since: " + stale.last_modified); + } + + const cvc::net::HttpResponse r = cvc::net::send(req); // NOT under cache_mutex + if (!r.ok) + return {false, std::string(), std::string(), + "ari: http fetch of '" + u.raw + "' failed: " + r.error}; + + // 304 Not Modified: the stale body is still valid. Honor the revalidation response's own + // directives, merge any validators it carries (falling back to the stale ones), and serve the + // cached body. We only send conditional headers when we had a body, so stale.present holds here. + if (r.status == 304 && stale.present) { + const CacheControl cc = parse_cache_control(r.headers); + if (cc.no_store) { + std::lock_guard lk(cache_mutex()); + evict_locked(root(kCacheRoot), key, now); // must not retain; serve this body once + return {true, stale.body, stale.effective_url, std::string()}; + } + std::string etag = stale.etag, last_modified = stale.last_modified, + content_type = stale.content_type; + find_header(r.headers, "ETag", etag); // a 304 may refresh the validators + find_header(r.headers, "Last-Modified", last_modified); + find_header(r.headers, "Content-Type", content_type); + const long ttl = cc.no_cache ? 0 + : cc.has_max_age ? std::max(0, cc.max_age) + : parse_long_or(stale.freshness_ttl, kDefaultFreshnessSecs); + const long age = parse_age(r.headers); + { + std::lock_guard lk(cache_mutex()); + write_entry(root(kCacheRoot), key, 200, etag, last_modified, content_type, + stale.effective_url, stale.body, now - pt::seconds(age), ttl); + } + return {true, stale.body, stale.effective_url, std::string()}; + } + + // 200 OK: replace body + validators + TTL (unless no-store), and serve the new body. + if (r.status == 200) { + const CacheControl cc = parse_cache_control(r.headers); + if (!cc.no_store) { + std::string etag, last_modified, content_type; + find_header(r.headers, "ETag", etag); + find_header(r.headers, "Last-Modified", last_modified); + find_header(r.headers, "Content-Type", content_type); + const long age = parse_age(r.headers); + std::lock_guard lk(cache_mutex()); + write_entry(root(kCacheRoot), key, 200, etag, last_modified, content_type, r.canonical_url, + r.body, now - pt::seconds(age), derive_freshness_ttl(r.headers, cc)); + } + return {true, r.body, r.canonical_url, std::string()}; + } + + // Any other status (>= 400, or an unsolicited 304 with no usable stale body) → the plain mapper, + // which now treats a 304 as an error and a >= 400 as an error (follow == true, so no 3xx here). + return detail::map_http_fetch_result(u.raw, /*follow=*/true, r); +} + +// single-flight wrapper: the first caller for a key becomes the leader (does the fetch + store); +// the rest wait and receive the leader's result. Coalesces only the network-bound paths (a fresh +// hit returns before this is called). cache_mutex is NEVER held here (the leader takes it inside +// do_fetch_and_store, after releasing flight_mutex). +UriResult fetch_and_cache(cvc::state &root, const std::string &key, const Uri &u, + const EntrySnapshot &stale, const pt::ptime &now) { + std::shared_ptr mine; + { + std::unique_lock lk(flight_mutex()); + auto it = flight_map().find(key); + if (it != flight_map().end()) { + std::shared_ptr in = it->second; // follower: wait for the leader + in->cv.wait(lk, [&] { return in->done; }); + return in->result; + } + mine = std::make_shared(); + flight_map()[key] = mine; + } // release the flight lock before the (blocking) network fetch + + UriResult result; + try { + result = do_fetch_and_store(root, key, u, stale, now); + } catch (const std::exception &e) { + result = {false, std::string(), std::string(), + "ari: http fetch of '" + u.raw + "' failed: " + e.what()}; + } catch (...) { + result = {false, std::string(), std::string(), + "ari: http fetch of '" + u.raw + "' failed: unknown error"}; + } + + { + std::lock_guard lk(flight_mutex()); + mine->result = result; + mine->done = true; + flight_map().erase(key); + } + mine->cv.notify_all(); // wake followers even on error (result carries the error) + return result; +} + +UriResult cached_http_fetch(cvc::state &root, const Uri &u, const std::string & /*base*/) { + const HttpRequestOptions opts = detail::consult_http_provider(u.raw, /*for_write=*/false); + if (!opts.headers.empty() || url_has_userinfo(u.raw)) { + // CREDENTIALED (provider headers or URL userinfo) → bypass the cache entirely (never serve one + // principal's authorized body to another from a process-global cache). Plain un-followed fetch. + cvc::net::HttpRequest req; + req.url = u.raw; + req.max_bytes = detail::kMaxHttpBytes; + req.headers = opts.headers; + req.follow_redirects = false; + if (!opts.method.empty() && opts.method != "GET") + req.method = opts.method; + return detail::map_http_fetch_result(u.raw, /*follow=*/false, cvc::net::send(req)); + } + + const std::string key = cache_key(u.raw); + const pt::ptime now = pt::microsec_clock::universal_time(); + EntrySnapshot snap; + { + std::lock_guard lk(cache_mutex()); // sweep + snapshot atomically vs eviction + cvc::state &entries = root(kCacheRoot); + entries.sweepExpired(); // access-time eviction (frees expired bodies) + snap = read_entry(entries, key); + } + if (snap.present && snapshot_fresh(snap, now)) + return {true, snap.body, snap.effective_url, std::string()}; // FRESH: no network + + // stale (conditional GET) or miss (unconditional) — coalesced. + return fetch_and_cache(root, key, u, snap, now); +} + +StoreResult invalidating_store(cvc::state &root, const Uri &u, const std::string &content, + const std::string &base) { + const StoreResult r = detail::http_store_uncached(u, content, base); + if (r.ok) { + std::lock_guard lk(cache_mutex()); + evict_locked(root(kCacheRoot), cache_key(u.raw), pt::microsec_clock::universal_time()); + } + return r; +} + +} // namespace + +void register_cached_http_uri_handler(cvc::app &app) { + if (!cvc::net::have_http_backend()) + return; // no transport compiled — leave the scheme unresolved + cvc::state *root = &cvc::state::instance(app); + register_uri_handler("http", [root](const Uri &u, const std::string &b) { + return cached_http_fetch(*root, u, b); + }); + register_uri_handler("https", [root](const Uri &u, const std::string &b) { + return cached_http_fetch(*root, u, b); + }); + register_uri_store_handler("http", + [root](const Uri &u, const std::string &c, const std::string &b) { + return invalidating_store(*root, u, c, b); + }); + register_uri_store_handler("https", + [root](const Uri &u, const std::string &c, const std::string &b) { + return invalidating_store(*root, u, c, b); + }); +} + +void unregister_cached_http_uri_handler() { + unregister_uri_handler("http"); + unregister_uri_handler("https"); + unregister_uri_store_handler("http"); + unregister_uri_store_handler("https"); +} + +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/ariadne/uri_http_detail.h b/src/cvc/ariadne/uri_http_detail.h new file mode 100644 index 00000000..94f0dbc8 --- /dev/null +++ b/src/cvc/ariadne/uri_http_detail.h @@ -0,0 +1,50 @@ +#ifndef CVC_ARIADNE_URI_HTTP_DETAIL_H +#define CVC_ARIADNE_URI_HTTP_DETAIL_H + +// Ariadne — internal shared pieces of the http(s):// handler, used by BOTH the plain handler +// (uri_http.cpp) and the §13.9 caching handler (uri_http_cache.cpp) so the request-building, +// response-mapping, and store policy live in ONE place. NOT a shipped header (lives under src/, not +// inc/): it is included only by those two sibling TUs. The public API stays in uri_http.h. + +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { +namespace detail { + +// A remote fragment/library is DSL text or a modest asset; a response past this is refused rather +// than buffered whole (an availability guard). Shared by the read + store + cache paths. +constexpr std::size_t kMaxHttpBytes = 64u * 1024u * 1024u; // 64 MiB + +// Consult the host-installed HttpOptionsProvider (auth headers + method override) for `url`. +// Returns a default-constructed options (no headers, empty method) when no provider is installed. +// Thread-safe: the provider is copied under a lock before the call (set/clear never races). +HttpRequestOptions consult_http_provider(const std::string &url, bool for_write); + +// Case-insensitive test for a header whose name (text before the first ':') equals `name`. Also +// matches libcurl's suppression form (`Name:` with an empty value). +bool http_header_present(const std::vector &lines, const std::string &name); + +// Map a cvc::net READ response to a UriResult with Ariadne's policy: a transport failure, an HTTP +// status >= 400, and (when not following) a 3xx are all errors; otherwise the body + effective URL. +// `follow` is whether redirects were followed (governs the 3xx-is-an-error rule) and is used only +// for the error text. Shared so the plain fetch and the cache's miss/replace paths map identically. +UriResult map_http_fetch_result(const std::string &raw_url, bool follow, + const cvc::net::HttpResponse &r); + +// The UNCACHED store (roadmap §13.10): PUT (default) / POST/… the bytes to the URL via cvc::net, +// consulting the provider for method + auth headers, defaulting a neutral Content-Type, never +// following a redirect. This is the plain http_store body; the cache's write wrapper delegates here +// and then invalidates the entry. +StoreResult http_store_uncached(const Uri &u, const std::string &content, const std::string &base); + +} // namespace detail +} // namespace ariadne +} // namespace cvc + +#endif // CVC_ARIADNE_URI_HTTP_DETAIL_H diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index a1e8f9f0..bcffa2d1 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -9,6 +9,7 @@ add_executable(ariadne_loader_test ariadne_loader_test.cpp) add_executable(ariadne_runtime_test ariadne_runtime_test.cpp) add_executable(ariadne_bind_test ariadne_bind_test.cpp) add_executable(net_http_client_test net_http_client_test.cpp) +add_executable(uri_http_cache_test uri_http_cache_test.cpp) add_executable(state_test state_test.cpp) add_executable(state_change_journal_test state_change_journal_test.cpp) add_executable(state_subscription_router_test state_subscription_router_test.cpp) @@ -164,6 +165,7 @@ set(TEST_TARGETS ariadne_runtime_test ariadne_bind_test net_http_client_test + uri_http_cache_test state_test state_change_journal_test state_subscription_router_test @@ -402,6 +404,13 @@ target_link_libraries(net_http_client_test GTest::gtest_main ) +target_link_libraries(uri_http_cache_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(ariadne_bind_test PRIVATE cvc @@ -1553,6 +1562,7 @@ cvc_discover_tests(ariadne_loader_test) cvc_discover_tests(ariadne_runtime_test) cvc_discover_tests(ariadne_bind_test) cvc_discover_tests(net_http_client_test) +cvc_discover_tests(uri_http_cache_test) cvc_discover_tests(state_test) cvc_discover_tests(voxels_test) cvc_discover_tests(volume_test) diff --git a/src/cvc/tests/uri_http_cache_test.cpp b/src/cvc/tests/uri_http_cache_test.cpp new file mode 100644 index 00000000..fbdfa1a3 --- /dev/null +++ b/src/cvc/tests/uri_http_cache_test.cpp @@ -0,0 +1,319 @@ +// §13.9 app-wide HTTP cache tests. All offline: the cvc::net transport is swapped for a scripted +// fake (PR1's set_http_client seam), so no test touches the network. Each test registers the +// caching handler against a fresh cvc::app and drives cvc::ariadne::resolve()/store(). + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // cvc::sha256_hex (to locate an entry node by key) +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +namespace pt = boost::posix_time; +using cvc::net::HttpRequest; +using cvc::net::HttpResponse; + +HttpResponse ok_resp(long status, std::vector headers, std::string body, + std::string url = "http://ex/x") { + HttpResponse r; + r.ok = true; + r.status = status; + r.headers = std::move(headers); + r.body = std::move(body); + r.canonical_url = std::move(url); + return r; +} + +// A scripted transport: pops one canned response per call (the last one repeats), and records every +// request so a test can assert the conditional headers that were sent. +class ScriptedClient : public cvc::net::HttpClient { +public: + std::deque responses; + std::vector requests; + int calls = 0; + HttpResponse send(const HttpRequest &req) override { + requests.push_back(req); + ++calls; + if (responses.empty()) { + HttpResponse r; + r.ok = false; + r.error = "scripted: no more responses"; + return r; + } + HttpResponse r = responses.front(); + if (responses.size() > 1) + responses.pop_front(); // keep the last so a surprise extra call still answers + return r; + } +}; + +bool req_has_header(const HttpRequest &r, const std::string &needle) { + for (const std::string &h : r.headers) + if (h.find(needle) != std::string::npos) + return true; + return false; +} + +// The cache key derivation, mirrored for tests that need to locate the entry node. The test URLs +// are already normalized (lowercase, no default port, no fragment), so normalization is identity +// here. +std::string key_of(const std::string &normalized_url) { + return cvc::sha256_hex(reinterpret_cast(normalized_url.data()), + normalized_url.size()); +} + +class HttpCacheTest : public ::testing::Test { +protected: + cvc::app app; + void SetUp() override { + if (!cvc::ariadne::have_http_uri_handler()) + GTEST_SKIP() << "built without an HTTP backend"; + cvc::ariadne::register_cached_http_uri_handler(app); + } + void TearDown() override { + cvc::ariadne::unregister_cached_http_uri_handler(); + cvc::net::set_http_client(nullptr); + cvc::ariadne::set_http_options_provider(nullptr); + } + ScriptedClient *install() { + auto *f = new ScriptedClient(); + cvc::net::set_http_client(std::unique_ptr(f)); + return f; + } + cvc::state &root() { return cvc::state::instance(app); } + cvc::state *entry(const std::string &url) { + return root().findDescendant("sys.net.http_cache.entries." + key_of(url)); + } +}; + +TEST_F(HttpCacheTest, FreshHitServesWithoutNetwork) { + auto *fake = install(); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=300"}, "A")); + auto r1 = cvc::ariadne::resolve("http://ex/x"); + ASSERT_TRUE(r1.ok) << r1.error; + EXPECT_EQ(r1.content, "A"); + auto r2 = cvc::ariadne::resolve("http://ex/x"); + ASSERT_TRUE(r2.ok) << r2.error; + EXPECT_EQ(r2.content, "A"); + EXPECT_EQ(fake->calls, 1) << "the second resolve must be served from the cache, no network"; +} + +TEST_F(HttpCacheTest, StaleRevalidation304ServesCachedBody) { + auto *fake = install(); + // max-age=0 => always stale => the next resolve revalidates; the server answers 304. + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=0", "ETag: \"v1\""}, "A")); + fake->responses.push_back(ok_resp(304, {}, "")); + auto r1 = cvc::ariadne::resolve("http://ex/x"); + ASSERT_TRUE(r1.ok) << r1.error; + EXPECT_EQ(r1.content, "A"); + auto r2 = cvc::ariadne::resolve("http://ex/x"); + ASSERT_TRUE(r2.ok) << r2.error; + EXPECT_EQ(r2.content, "A") << "a 304 serves the cached body"; + EXPECT_EQ(fake->calls, 2); + ASSERT_EQ(fake->requests.size(), 2u); + EXPECT_TRUE(req_has_header(fake->requests[1], "If-None-Match: \"v1\"")) + << "the revalidation must send the stored ETag"; +} + +TEST_F(HttpCacheTest, StaleRevalidation200ReplacesBody) { + auto *fake = install(); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=0", "ETag: \"v1\""}, "A")); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=0", "ETag: \"v2\""}, "B")); + fake->responses.push_back(ok_resp(304, {}, "")); + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "A"); // miss -> store + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "B"); // stale -> 200 replaces + cvc::ariadne::resolve("http://ex/x"); // stale -> revalidate with v2 + ASSERT_EQ(fake->requests.size(), 3u); + EXPECT_TRUE(req_has_header(fake->requests[1], "If-None-Match: \"v1\"")); + EXPECT_TRUE(req_has_header(fake->requests[2], "If-None-Match: \"v2\"")) + << "the replaced entry must revalidate with the NEW ETag"; +} + +TEST_F(HttpCacheTest, NoStoreBypassesTheCache) { + auto *fake = install(); + fake->responses.push_back(ok_resp(200, {"Cache-Control: no-store"}, "X")); + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "X"); + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "X"); + EXPECT_EQ(fake->calls, 2) << "no-store must not cache, so every resolve hits the network"; + ASSERT_EQ(fake->requests.size(), 2u); + EXPECT_FALSE(req_has_header(fake->requests[1], "If-None-Match")) + << "a no-store re-fetch is a fresh miss, not a revalidation"; + EXPECT_EQ(entry("http://ex/x"), nullptr) << "nothing was stored"; +} + +TEST_F(HttpCacheTest, RetentionExpiryEvictsAndReFetches) { + auto *fake = install(); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=300"}, "A")); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=300"}, "A2")); + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "A"); // stored fresh + cvc::state *e = entry("http://ex/x"); + ASSERT_NE(e, nullptr); + // Force the node past its retention; the next resolve's access-time sweep must drop it. + e->expireAt(pt::microsec_clock::universal_time() - pt::seconds(1)); + auto r = cvc::ariadne::resolve("http://ex/x"); + ASSERT_TRUE(r.ok) << r.error; + EXPECT_EQ(r.content, "A2") << "the evicted entry is re-fetched, not served stale"; + EXPECT_EQ(fake->calls, 2); +} + +TEST_F(HttpCacheTest, CredentialedRequestBypassesTheCache) { + auto *fake = install(); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=300"}, "S")); + cvc::ariadne::set_http_options_provider([](const std::string &, bool) { + cvc::ariadne::HttpRequestOptions o; + o.headers.push_back("Authorization: Bearer tok"); + return o; + }); + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "S"); + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "S"); + EXPECT_EQ(fake->calls, 2) + << "a credentialed request must bypass the cache (no cross-principal reuse)"; + EXPECT_EQ(entry("http://ex/x"), nullptr) << "a credentialed response is never stored"; +} + +TEST_F(HttpCacheTest, UnsolicitedNotModifiedIsAnError) { + auto *fake = install(); + fake->responses.push_back(ok_resp(304, {}, "")); // a 304 to our unconditional (miss) GET + auto r = cvc::ariadne::resolve("http://ex/x"); + EXPECT_FALSE(r.ok) << "a 304 with no cached entry must be an error, not an empty-body success"; + EXPECT_NE(r.error.find("304"), std::string::npos); + EXPECT_EQ(entry("http://ex/x"), nullptr); +} + +TEST_F(HttpCacheTest, Revalidation304NoStoreEvicts) { + auto *fake = install(); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=0", "ETag: \"v1\""}, "A")); + fake->responses.push_back(ok_resp(304, {"Cache-Control: no-store"}, "")); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=300"}, "A3")); + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "A"); // stored (stale, ttl 0) + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, + "A"); // 304 no-store: served once, evicted + EXPECT_EQ(entry("http://ex/x"), nullptr) << "a no-store revalidation must evict the entry"; + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "A3"); // gone -> re-fetched + EXPECT_EQ(fake->calls, 3); +} + +TEST_F(HttpCacheTest, UserinfoUrlBypassesTheCache) { + auto *fake = install(); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=300"}, "U")); + EXPECT_EQ(cvc::ariadne::resolve("http://user:pass@ex/x").content, "U"); + EXPECT_EQ(cvc::ariadne::resolve("http://user:pass@ex/x").content, "U"); + EXPECT_EQ(fake->calls, 2) << "a userinfo URL is credential-bearing and must bypass the cache"; + ASSERT_EQ(fake->requests.size(), 2u); + EXPECT_FALSE(req_has_header(fake->requests[1], "If-None-Match")) << "bypass is a fresh miss"; +} + +TEST_F(HttpCacheTest, AgeAnchorsFreshnessBeforeReceipt) { + auto *fake = install(); + // max-age=100 but the response is already Age=100s old (a CDN hit): the fresh window is 0, so the + // next resolve must revalidate rather than serve it as fresh. + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=100", "Age: 100"}, "A")); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=100", "Age: 0"}, "A2")); + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "A"); + EXPECT_EQ(cvc::ariadne::resolve("http://ex/x").content, "A2") + << "an Age==max-age response is already stale on arrival, so it must re-fetch"; + EXPECT_EQ(fake->calls, 2); +} + +TEST_F(HttpCacheTest, ConcurrentDistinctKeysAreServedSafely) { + auto *fake = install(); + fake->responses.push_back(ok_resp(200, {"Cache-Control: max-age=300"}, "OK")); // repeats + constexpr int kN = 8; + std::vector ts; + std::vector bodies(kN); + std::vector oks(kN, 0); + for (int i = 0; i < kN; ++i) + ts.emplace_back([&, i] { + auto r = cvc::ariadne::resolve("http://ex/" + std::to_string(i)); // distinct keys + oks[i] = r.ok ? 1 : 0; + bodies[i] = r.content; + }); + for (auto &t : ts) + t.join(); + // Distinct keys don't coalesce, so each is one transfer; the point is no crash/deadlock/UAF under + // concurrent stores + sweeps of the shared entries subtree (cache_mutex serializes the tree + // work). + for (int i = 0; i < kN; ++i) { + EXPECT_EQ(oks[i], 1) << "resolve " << i << " failed"; + EXPECT_EQ(bodies[i], "OK"); + } + EXPECT_EQ(fake->calls, kN); +} + +// A transport that blocks in send() until released, so a test can drive concurrent resolves. +class LatchClient : public cvc::net::HttpClient { +public: + explicit LatchClient(HttpResponse r) : resp_(std::move(r)) {} + std::atomic calls{0}; + HttpResponse send(const HttpRequest &) override { + { + std::unique_lock lk(m_); + in_send_ = true; + cv_.notify_all(); + cv_.wait(lk, [&] { return release_; }); + } + ++calls; + return resp_; + } + void wait_until_in_send() { + std::unique_lock lk(m_); + cv_.wait(lk, [&] { return in_send_; }); + } + void release() { + { + std::lock_guard lk(m_); + release_ = true; + } + cv_.notify_all(); + } + +private: + std::mutex m_; + std::condition_variable cv_; + bool in_send_ = false; + bool release_ = false; + HttpResponse resp_; +}; + +TEST_F(HttpCacheTest, SingleFlightCoalescesConcurrentResolves) { + auto *fake = new LatchClient(ok_resp(200, {"Cache-Control: max-age=300"}, "C")); + cvc::net::set_http_client(std::unique_ptr(fake)); + + // Start the leader and wait until it is inside send() — by then it has registered the in-flight + // marker, so every follower launched now either waits on it or hits the (soon) fresh cache. + std::string leader_body; + std::thread leader([&] { leader_body = cvc::ariadne::resolve("http://ex/x").content; }); + fake->wait_until_in_send(); + + constexpr int kFollowers = 5; + std::vector followers; + std::vector bodies(kFollowers); + for (int i = 0; i < kFollowers; ++i) + followers.emplace_back([&, i] { bodies[i] = cvc::ariadne::resolve("http://ex/x").content; }); + + fake->release(); + leader.join(); + for (auto &t : followers) + t.join(); + + EXPECT_EQ(fake->calls.load(), 1) << "concurrent resolves must coalesce to one transfer"; + EXPECT_EQ(leader_body, "C"); + for (int i = 0; i < kFollowers; ++i) + EXPECT_EQ(bodies[i], "C") << "follower " << i << " got the wrong body"; +} + +} // namespace