Skip to main content

tapes_client/cassettes/
cache.rs

1//! On-disk cache of a server's cassette surface.
2//!
3//! # Why a cache is not optional here
4//!
5//! The generated nouns *are* the cassette listing, so they have to be present
6//! for `--help` — which means the surface is needed on essentially
7//! every invocation, including the ones that never make a request. Discovering
8//! it costs one call to `/v1/cassettes` plus one per cassette; paying that to
9//! print a help screen would make a CLI feel broken on a slow link and unusable
10//! on a plane.
11//!
12//! So the surface is cached per server and revalidated on a timer. Inside
13//! [`CacheConfig::revalidate_after`] nothing touches the network at all. After
14//! it, discovery is re-fetched and each spec is revalidated with
15//! `If-None-Match` — the route answers a match with a 304 and no body, which is
16//! what the `ETag` is there for. A cassette whose document has not changed
17//! costs one conditional request and no parsing.
18//!
19//! The cache is keyed by base URL, because two servers have two different
20//! cassette sets and a shared cache would offer one server's nouns for the
21//! other's data.
22//!
23//! # The consumer names the cache
24//!
25//! Extracted from tapesctl, whose cache lives at `<cache>/tapesctl/cassettes`
26//! and is overridden by `TAPESCTL_CACHE_DIR`. Both names — and the
27//! revalidation window and the key — are the consumer's, carried in
28//! [`CacheConfig`], so the on-disk paths, environment contract, and file
29//! format of an existing install do not move when the machinery does.
30//!
31//! # Failure is always survivable
32//!
33//! Nothing in this module returns an error. An unreadable cache is a cache miss,
34//! an unwritable one is a lost optimization, and an unreachable server falls
35//! back to whatever is on disk *regardless of age* — a stale surface is far more
36//! useful than none when the network is the thing that is broken. Only when
37//! there is neither a server nor a cache does the CLI go without cassette nouns,
38//! and even then a consumer's hand-written surface is untouched.
39
40use std::collections::BTreeMap;
41use std::path::PathBuf;
42use std::time::{Duration, SystemTime, UNIX_EPOCH};
43
44use serde::{Deserialize, Serialize};
45use serde_json::Value;
46
47use crate::cassettes::discovery::Discovery;
48use crate::cassettes::spec::{self, ReducerConfig, Surface};
49use crate::transport::{SpecFetch, SpecTransport};
50
51/// How one consumer's cache is named, keyed, and aged.
52#[derive(Debug, Clone, Copy)]
53pub struct CacheConfig<'a> {
54    /// Path under the platform cache directory (e.g. `tapesctl/cassettes`).
55    pub app_dir_name: &'a str,
56    /// Environment variable that overrides where the cache lives. Set by
57    /// tests, and useful for pinning the location in CI.
58    pub env_override_var: &'a str,
59    /// How long a cached surface is used without asking the server about it.
60    ///
61    /// Cassette sets change when an operator redeploys, which is rare next to
62    /// how often a CLI runs; tapesctl passes ten minutes, which keeps `--help`
63    /// instant through a working session while still picking up a new cassette
64    /// without anyone clearing a cache.
65    pub revalidate_after: Duration,
66    /// The cache key: the server's base URL, as the consumer's client renders
67    /// it.
68    pub key: &'a str,
69}
70
71/// One cassette's cached document and the validator to revalidate it with.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CachedSpec {
74    /// The `ETag` the document arrived with, when the server sent one.
75    #[serde(default)]
76    pub etag: Option<String>,
77    /// The OpenAPI document, stored verbatim.
78    pub document: Value,
79}
80
81/// A server's cached surface.
82///
83/// The raw documents are stored rather than the reduced surface: the reduction
84/// is one build's interpretation, and a newer build that reads more of the
85/// document would otherwise keep serving the older build's reading of it until
86/// the entry expired.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct Cached {
89    /// The server this was discovered from, so a hash collision cannot serve
90    /// one server's cassettes for another's.
91    pub base: String,
92    /// When it was last revalidated, in seconds since the epoch.
93    pub revalidated_at: u64,
94    /// The discovery document.
95    pub discovery: Discovery,
96    /// Each cassette's document, keyed by cassette name.
97    pub specs: BTreeMap<String, CachedSpec>,
98}
99
100impl Cached {
101    /// Reduce to the command surface.
102    #[must_use]
103    pub fn surface(&self, reducer: &ReducerConfig<'_>) -> Surface {
104        let cassettes = self
105            .discovery
106            .cassettes
107            .iter()
108            .filter_map(|entry| {
109                let cached = self.specs.get(&entry.name)?;
110                Some(spec::reduce(
111                    &entry.name,
112                    entry.description.clone(),
113                    &cached.document,
114                    reducer,
115                ))
116            })
117            .collect();
118        Surface { cassettes }
119    }
120
121    /// Whether this entry is inside its revalidation window.
122    #[must_use]
123    pub fn is_fresh(&self, now: u64, revalidate_after: Duration) -> bool {
124        // A `revalidated_at` in the future means the clock moved backwards
125        // between runs. Treating that as "fresh forever" would pin a stale
126        // surface until the clock caught up, so it counts as expired.
127        now >= self.revalidated_at && now - self.revalidated_at < revalidate_after.as_secs()
128    }
129}
130
131/// Seconds since the epoch, or 0 if the clock is before it.
132fn now() -> u64 {
133    SystemTime::now()
134        .duration_since(UNIX_EPOCH)
135        .map_or(0, |d| d.as_secs())
136}
137
138/// Where cached surfaces live.
139fn cache_dir(config: &CacheConfig<'_>) -> Option<PathBuf> {
140    if let Ok(raw) = std::env::var(config.env_override_var) {
141        if !raw.trim().is_empty() {
142            return Some(PathBuf::from(raw));
143        }
144    }
145    Some(dirs::cache_dir()?.join(config.app_dir_name))
146}
147
148/// The cache file for one base URL.
149///
150/// The URL is both sanitized (so the name is readable when someone looks in the
151/// directory) and hashed (so two URLs that sanitize alike cannot share a file).
152fn cache_path(config: &CacheConfig<'_>) -> Option<PathBuf> {
153    let readable: String = config
154        .key
155        .chars()
156        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
157        .collect();
158    let trimmed: String = readable.chars().take(48).collect();
159    Some(cache_dir(config)?.join(format!("{trimmed}-{:016x}.json", fnv1a(config.key))))
160}
161
162/// FNV-1a, written out rather than taken from `DefaultHasher` because this value
163/// names a file that outlives the process: `DefaultHasher` makes no stability
164/// promise across toolchains, and a hash that moved would silently orphan every
165/// cached surface on a compiler upgrade.
166fn fnv1a(input: &str) -> u64 {
167    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
168    for byte in input.as_bytes() {
169        hash ^= u64::from(*byte);
170        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
171    }
172    hash
173}
174
175/// Read the cached surface for the configured base URL, if there is a usable
176/// one.
177#[must_use]
178pub fn read(config: &CacheConfig<'_>) -> Option<Cached> {
179    let path = cache_path(config)?;
180    let raw = std::fs::read(&path).ok()?;
181    let cached: Cached = serde_json::from_slice(&raw).ok()?;
182    // A file written by a different base URL under the same name is not this
183    // server's surface, whatever the hash says.
184    (cached.base == config.key).then_some(cached)
185}
186
187/// Write a surface to the cache, best effort.
188///
189/// Written to a temporary file and renamed, so a process that dies mid-write
190/// leaves the previous entry intact rather than a truncated one that every later
191/// run has to fail to parse.
192pub fn write(config: &CacheConfig<'_>, cached: &Cached) {
193    let Some(path) = cache_path(config) else {
194        return;
195    };
196    let Some(parent) = path.parent() else {
197        return;
198    };
199    if let Err(error) = std::fs::create_dir_all(parent) {
200        tracing::debug!(%error, "could not create the cassette cache directory");
201        return;
202    }
203    let Ok(encoded) = serde_json::to_vec(cached) else {
204        return;
205    };
206
207    let temporary = path.with_extension(format!("{}.tmp", std::process::id()));
208    if let Err(error) = std::fs::write(&temporary, &encoded) {
209        tracing::debug!(%error, "could not write the cassette cache");
210        return;
211    }
212    if let Err(error) = std::fs::rename(&temporary, &path) {
213        tracing::debug!(%error, "could not install the cassette cache");
214        let _ = std::fs::remove_file(&temporary);
215    }
216}
217
218/// Get the cassette surface for a server, from cache or from the network.
219///
220/// Never fails. See the module docs for the degradation ladder.
221pub async fn load<T: SpecTransport>(
222    transport: &T,
223    config: &CacheConfig<'_>,
224    reducer: &ReducerConfig<'_>,
225) -> Surface {
226    let existing = read(config);
227
228    if let Some(cached) = &existing {
229        if cached.is_fresh(now(), config.revalidate_after) {
230            return cached.surface(reducer);
231        }
232    }
233
234    match revalidate(transport, config, existing.as_ref()).await {
235        Some(fresh) => {
236            write(config, &fresh);
237            fresh.surface(reducer)
238        }
239        None => {
240            // The server could not be reached or did not answer with a document
241            // we understand. Whatever is on disk is better than nothing, however
242            // old it is.
243            existing
244                .map(|cached| cached.surface(reducer))
245                .unwrap_or_default()
246        }
247    }
248}
249
250/// Re-fetch discovery and revalidate each cassette's document against it.
251async fn revalidate<T: SpecTransport>(
252    transport: &T,
253    config: &CacheConfig<'_>,
254    existing: Option<&Cached>,
255) -> Option<Cached> {
256    let document = match transport.fetch_discovery().await {
257        Ok(document) => document,
258        Err(error) => {
259            tracing::debug!(%error, "could not reach cassette discovery");
260            return None;
261        }
262    };
263    let discovery: Discovery = match serde_json::from_value(document) {
264        Ok(discovery) => discovery,
265        Err(error) => {
266            tracing::debug!(%error, "could not read the cassette discovery document");
267            return None;
268        }
269    };
270
271    for problem in &discovery.problems {
272        // An operator's broken cassette URL is otherwise indistinguishable from
273        // the cassette not existing, and the user running the CLI is often the
274        // one who can fix it.
275        tracing::debug!(
276            subject = %problem.subject,
277            reason = %problem.reason,
278            "the server refused a configured cassette",
279        );
280    }
281
282    let mut specs: BTreeMap<String, CachedSpec> = BTreeMap::new();
283    for entry in &discovery.cassettes {
284        if !entry.has_spec() {
285            continue;
286        }
287        let previous = existing.and_then(|cached| cached.specs.get(&entry.name));
288        let etag = previous.and_then(|spec| spec.etag.as_deref());
289
290        match transport.fetch_spec(&entry.openapi_path, etag).await {
291            Ok(SpecFetch::Unchanged) => {
292                if let Some(previous) = previous {
293                    specs.insert(entry.name.clone(), previous.clone());
294                }
295            }
296            Ok(SpecFetch::Fetched { document, etag }) => {
297                specs.insert(entry.name.clone(), CachedSpec { etag, document });
298            }
299            Err(error) => {
300                // One cassette being down must not cost the others their
301                // commands, so keep whatever was cached for it and move on.
302                tracing::debug!(
303                    cassette = %entry.name,
304                    %error,
305                    "could not fetch a cassette's OpenAPI document",
306                );
307                if let Some(previous) = previous {
308                    specs.insert(entry.name.clone(), previous.clone());
309                }
310            }
311        }
312    }
313
314    Some(Cached {
315        base: config.key.to_owned(),
316        revalidated_at: now(),
317        discovery,
318        specs,
319    })
320}
321
322#[cfg(test)]
323#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
324mod tests {
325    use super::*;
326    use crate::cassettes::discovery::DiscoveryEntry;
327    use serde_json::json;
328
329    /// tapesctl's parameters, which these moved tests were written against.
330    const REVALIDATE_AFTER: Duration = Duration::from_secs(600);
331
332    const RESERVED: ReducerConfig<'static> = ReducerConfig {
333        reserved_flags: &["tapes-url", "body", "help", "verbose"],
334    };
335
336    fn config(key: &str) -> CacheConfig<'_> {
337        CacheConfig {
338            app_dir_name: "tapesctl/cassettes",
339            env_override_var: "TAPESCTL_CACHE_DIR",
340            revalidate_after: REVALIDATE_AFTER,
341            key,
342        }
343    }
344
345    fn entry(name: &str) -> DiscoveryEntry {
346        DiscoveryEntry {
347            name: name.to_owned(),
348            route_prefix: format!("/v1/cassettes/{name}"),
349            openapi_path: format!("/v1/cassettes/{name}/openapi.json"),
350            openapi_status: "fresh".to_owned(),
351            ..Default::default()
352        }
353    }
354
355    fn hello_document(name: &str) -> Value {
356        json!({"paths": {format!("/v1/cassettes/{name}/hello"): {
357            "get": {"operationId": "getHello"}
358        }}})
359    }
360
361    fn cached(base: &str, name: &str, at: u64) -> Cached {
362        Cached {
363            base: base.to_owned(),
364            revalidated_at: at,
365            discovery: Discovery {
366                contract_version: "v1".to_owned(),
367                cassettes: vec![entry(name)],
368                problems: Vec::new(),
369            },
370            specs: BTreeMap::from([(
371                name.to_owned(),
372                CachedSpec {
373                    etag: Some("\"sha256:abc\"".to_owned()),
374                    document: hello_document(name),
375                },
376            )]),
377        }
378    }
379
380    #[test]
381    fn a_cached_entry_reduces_to_the_generated_surface() {
382        let surface = cached("http://a", "hello-world", 0).surface(&RESERVED);
383        assert_eq!(surface.cassettes.len(), 1);
384        assert_eq!(surface.cassettes[0].methods[0].name, "get-hello");
385    }
386
387    #[test]
388    fn a_cassette_with_no_cached_document_generates_no_noun() {
389        // Rather than an empty noun whose every method is missing.
390        let mut entry = cached("http://a", "hello-world", 0);
391        entry.specs.clear();
392        assert!(entry.surface(&RESERVED).is_empty());
393    }
394
395    #[test]
396    fn freshness_expires_after_the_revalidation_window() {
397        let entry = cached("http://a", "hello-world", 1_000);
398        assert!(entry.is_fresh(1_000, REVALIDATE_AFTER));
399        assert!(entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs() - 1, REVALIDATE_AFTER));
400        assert!(!entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs(), REVALIDATE_AFTER));
401    }
402
403    #[test]
404    fn a_clock_that_moved_backwards_expires_rather_than_pinning_the_surface() {
405        let entry = cached("http://a", "hello-world", 5_000);
406        assert!(!entry.is_fresh(1_000, REVALIDATE_AFTER));
407    }
408
409    #[test]
410    fn two_base_urls_get_two_cache_files() {
411        // A shared file would offer one server's nouns for another's data.
412        let a = cache_path(&config("http://one.example")).unwrap();
413        let b = cache_path(&config("http://two.example")).unwrap();
414        assert_ne!(a, b);
415    }
416
417    #[test]
418    fn urls_that_sanitize_alike_still_get_different_files() {
419        // Every non-alphanumeric becomes `_`, so the readable part collides;
420        // the hash is what keeps them apart.
421        let a = cache_path(&config("http://a-b.example")).unwrap();
422        let b = cache_path(&config("http://a.b-example")).unwrap();
423        assert_ne!(a, b);
424    }
425
426    #[test]
427    fn the_file_name_hash_is_stable_across_builds() {
428        // Pinned so a toolchain upgrade cannot silently orphan every cached
429        // surface by changing where they are looked up.
430        assert_eq!(fnv1a(""), 0xcbf2_9ce4_8422_2325);
431        assert_eq!(
432            fnv1a("http://127.0.0.1:8081/"),
433            fnv1a("http://127.0.0.1:8081/")
434        );
435        assert_ne!(fnv1a("a"), fnv1a("b"));
436    }
437
438    #[test]
439    fn the_file_name_is_byte_identical_to_the_pre_extraction_layout() {
440        // The extraction moved the machinery, not the cache: a file tapesctl
441        // wrote before the split must resolve to the same path after it, or
442        // every user's cached surface is silently orphaned. The literal
443        // expected name is pinned here rather than recomputed, so a change to
444        // the sanitizer, the truncation, or the hash all fail loudly.
445        // A private env-var name, so an exported TAPESCTL_CACHE_DIR on the
446        // machine running this suite cannot move the path under the pin.
447        let path = cache_path(&CacheConfig {
448            env_override_var: "CASSETTE_CLIENT_TEST_UNSET_VAR",
449            ..config("http://127.0.0.1:8081/")
450        })
451        .unwrap();
452        assert_eq!(
453            path.file_name().unwrap().to_str().unwrap(),
454            "http___127_0_0_1_8081_-709aba2490ce417e.json",
455        );
456        assert!(path.parent().unwrap().ends_with("tapesctl/cassettes"));
457    }
458
459    #[test]
460    fn the_cached_serde_shape_is_byte_compatible_with_the_pre_extraction_format() {
461        // Existing cache files must both decode and re-encode identically.
462        let cached = cached("http://a", "hello-world", 42);
463        let encoded = serde_json::to_value(&cached).unwrap();
464        assert_eq!(
465            encoded,
466            json!({
467                "base": "http://a",
468                "revalidated_at": 42,
469                "discovery": {
470                    "contract_version": "v1",
471                    "cassettes": [{
472                        "name": "hello-world",
473                        "version": null,
474                        "display_name": null,
475                        "description": null,
476                        "route_prefix": "/v1/cassettes/hello-world",
477                        "openapi_path": "/v1/cassettes/hello-world/openapi.json",
478                        "openapi_status": "fresh",
479                        "manifest_digest": ""
480                    }],
481                    "problems": []
482                },
483                "specs": {
484                    "hello-world": {
485                        "etag": "\"sha256:abc\"",
486                        "document": hello_document("hello-world")
487                    }
488                }
489            }),
490        );
491        let decoded: Cached = serde_json::from_value(encoded).unwrap();
492        assert_eq!(decoded.base, cached.base);
493    }
494}