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/// How the surface [`load_live`] returned was obtained — so a consumer's
219/// `--help` can label a listing that is not the server's current truth, and
220/// warn when a live answer was attempted and gave out.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Provenance {
223    /// Discovery answered; the listing is the server's current truth.
224    Live,
225    /// Discovery did not finish inside the deadline — a server that is
226    /// slow, or a host that swallows packets instead of refusing them.
227    TimedOut {
228        /// Whether a previously discovered surface stood in.
229        cached: bool,
230    },
231    /// Discovery failed outright — a refused or unroutable connection (the
232    /// fast spelling of "offline"), or a response that did not decode. The
233    /// transport's own tracing has the specifics.
234    FetchFailed {
235        /// Whether a previously discovered surface stood in.
236        cached: bool,
237    },
238}
239
240/// Get the cassette surface for a server, live-first.
241///
242/// [`load`] prefers a fresh-enough cache to avoid network on every run. This
243/// entry inverts that for the shapes where the listing *is* the product — a
244/// user reading `--help` to validate that a cassette is being vended wants
245/// the server's answer, not last session's. Discovery runs under `deadline`,
246/// with ETag revalidation keeping the common case to one cheap request per
247/// document; the cache stands in only when the server cannot answer, always
248/// labeled through the returned [`Provenance`].
249///
250/// Reachability is judged by the transport and nothing else. An earlier
251/// design probed the URL's host with a raw TCP connect to bail faster when
252/// offline, and was wrong twice in the same way: the transport may reach the
253/// server through routing the probe knows nothing about (a proxy, a
254/// consumer's own transport), so a direct-connect verdict — whether it
255/// skipped the fetch or merely shortened its budget — could declare a
256/// reachable server offline. An unreachable host still answers quickly in
257/// the common case (a refused or unroutable connection errors immediately);
258/// only a black-holed host costs the full deadline, which is the bounded
259/// wait this entry promises anyway.
260///
261/// Never fails; an empty [`Surface`] with an honest provenance is the floor.
262#[cfg(feature = "direct-http")]
263pub async fn load_live<T: SpecTransport>(
264    transport: &T,
265    config: &CacheConfig<'_>,
266    reducer: &ReducerConfig<'_>,
267    deadline: Duration,
268) -> (Surface, Provenance) {
269    let existing = read(config);
270    let cached = existing.is_some();
271    let fallback = |existing: Option<Cached>| {
272        existing
273            .map(|cached| cached.surface(reducer))
274            .unwrap_or_default()
275    };
276
277    match tokio::time::timeout(deadline, revalidate(transport, config, existing.as_ref())).await {
278        Ok(Some(fresh)) => {
279            write(config, &fresh);
280            (fresh.surface(reducer), Provenance::Live)
281        }
282        Ok(None) => (fallback(existing), Provenance::FetchFailed { cached }),
283        Err(_elapsed) => (fallback(existing), Provenance::TimedOut { cached }),
284    }
285}
286
287/// Get the cassette surface for a server, from cache or from the network.
288///
289/// Never fails. See the module docs for the degradation ladder.
290pub async fn load<T: SpecTransport>(
291    transport: &T,
292    config: &CacheConfig<'_>,
293    reducer: &ReducerConfig<'_>,
294) -> Surface {
295    let existing = read(config);
296
297    if let Some(cached) = &existing {
298        if cached.is_fresh(now(), config.revalidate_after) {
299            return cached.surface(reducer);
300        }
301    }
302
303    match revalidate(transport, config, existing.as_ref()).await {
304        Some(fresh) => {
305            write(config, &fresh);
306            fresh.surface(reducer)
307        }
308        None => {
309            // The server could not be reached or did not answer with a document
310            // we understand. Whatever is on disk is better than nothing, however
311            // old it is.
312            existing
313                .map(|cached| cached.surface(reducer))
314                .unwrap_or_default()
315        }
316    }
317}
318
319/// Re-fetch discovery and revalidate each cassette's document against it.
320async fn revalidate<T: SpecTransport>(
321    transport: &T,
322    config: &CacheConfig<'_>,
323    existing: Option<&Cached>,
324) -> Option<Cached> {
325    let document = match transport.fetch_discovery().await {
326        Ok(document) => document,
327        Err(error) => {
328            tracing::debug!(%error, "could not reach cassette discovery");
329            return None;
330        }
331    };
332    let discovery: Discovery = match serde_json::from_value(document) {
333        Ok(discovery) => discovery,
334        Err(error) => {
335            tracing::debug!(%error, "could not read the cassette discovery document");
336            return None;
337        }
338    };
339
340    for problem in &discovery.problems {
341        // An operator's broken cassette URL is otherwise indistinguishable from
342        // the cassette not existing, and the user running the CLI is often the
343        // one who can fix it.
344        tracing::debug!(
345            subject = %problem.subject,
346            reason = %problem.reason,
347            "the server refused a configured cassette",
348        );
349    }
350
351    let mut specs: BTreeMap<String, CachedSpec> = BTreeMap::new();
352    for entry in &discovery.cassettes {
353        if !entry.has_spec() {
354            continue;
355        }
356        let previous = existing.and_then(|cached| cached.specs.get(&entry.name));
357        let etag = previous.and_then(|spec| spec.etag.as_deref());
358
359        match transport.fetch_spec(&entry.openapi_path, etag).await {
360            Ok(SpecFetch::Unchanged) => {
361                if let Some(previous) = previous {
362                    specs.insert(entry.name.clone(), previous.clone());
363                }
364            }
365            Ok(SpecFetch::Fetched { document, etag }) => {
366                specs.insert(entry.name.clone(), CachedSpec { etag, document });
367            }
368            Err(error) => {
369                // One cassette being down must not cost the others their
370                // commands, so keep whatever was cached for it and move on.
371                tracing::debug!(
372                    cassette = %entry.name,
373                    %error,
374                    "could not fetch a cassette's OpenAPI document",
375                );
376                if let Some(previous) = previous {
377                    specs.insert(entry.name.clone(), previous.clone());
378                }
379            }
380        }
381    }
382
383    Some(Cached {
384        base: config.key.to_owned(),
385        revalidated_at: now(),
386        discovery,
387        specs,
388    })
389}
390
391#[cfg(test)]
392#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
393mod tests {
394    use super::*;
395    use crate::cassettes::discovery::DiscoveryEntry;
396    use serde_json::json;
397
398    /// tapesctl's parameters, which these moved tests were written against.
399    const REVALIDATE_AFTER: Duration = Duration::from_secs(600);
400
401    const RESERVED: ReducerConfig<'static> = ReducerConfig {
402        reserved_flags: &["tapes-url", "body", "help", "verbose"],
403    };
404
405    fn config(key: &str) -> CacheConfig<'_> {
406        CacheConfig {
407            app_dir_name: "tapesctl/cassettes",
408            env_override_var: "TAPESCTL_CACHE_DIR",
409            revalidate_after: REVALIDATE_AFTER,
410            key,
411        }
412    }
413
414    /// A transport whose requests never finish, like a host that is truly
415    /// unreachable through every route: the demoted attempt after a failed
416    /// probe must time out against it, not hang.
417    struct NeverAnswers;
418
419    impl crate::transport::SpecTransport for NeverAnswers {
420        type Error = std::convert::Infallible;
421
422        async fn fetch_discovery(&self) -> Result<Value, Self::Error> {
423            std::future::pending().await
424        }
425
426        async fn fetch_spec(
427            &self,
428            _path: &str,
429            _etag: Option<&str>,
430        ) -> Result<crate::transport::SpecFetch, Self::Error> {
431            unreachable!("the probe failed; no request may be made");
432        }
433
434        async fn execute(&self, _call: &crate::transport::Call<'_>) -> Result<Value, Self::Error> {
435            unreachable!("the probe failed; no request may be made");
436        }
437    }
438
439    #[tokio::test]
440    async fn load_live_times_out_against_a_transport_that_never_answers() {
441        // A black-holed host: the transport hangs, the deadline ends the
442        // wait, and with no cache on disk the floor is an empty surface with
443        // an honest provenance. The deadline is the whole of the bound —
444        // there is no probe to bail earlier, because only the transport can
445        // say what it can reach.
446        let unique = format!("live-timeout-{}", std::process::id());
447        let started = std::time::Instant::now();
448        let (surface, provenance) = load_live(
449            &NeverAnswers,
450            &config(&unique),
451            &RESERVED,
452            Duration::from_millis(300),
453        )
454        .await;
455        assert!(surface.is_empty());
456        assert_eq!(provenance, Provenance::TimedOut { cached: false });
457        assert!(
458            started.elapsed() < Duration::from_secs(5),
459            "the deadline must actually bound the wait"
460        );
461    }
462
463    fn entry(name: &str) -> DiscoveryEntry {
464        DiscoveryEntry {
465            name: name.to_owned(),
466            route_prefix: format!("/v1/cassettes/{name}"),
467            openapi_path: format!("/v1/cassettes/{name}/openapi.json"),
468            openapi_status: "fresh".to_owned(),
469            ..Default::default()
470        }
471    }
472
473    fn hello_document(name: &str) -> Value {
474        json!({"paths": {format!("/v1/cassettes/{name}/hello"): {
475            "get": {"operationId": "getHello"}
476        }}})
477    }
478
479    fn cached(base: &str, name: &str, at: u64) -> Cached {
480        Cached {
481            base: base.to_owned(),
482            revalidated_at: at,
483            discovery: Discovery {
484                contract_version: "v1".to_owned(),
485                cassettes: vec![entry(name)],
486                problems: Vec::new(),
487            },
488            specs: BTreeMap::from([(
489                name.to_owned(),
490                CachedSpec {
491                    etag: Some("\"sha256:abc\"".to_owned()),
492                    document: hello_document(name),
493                },
494            )]),
495        }
496    }
497
498    #[test]
499    fn a_cached_entry_reduces_to_the_generated_surface() {
500        let surface = cached("http://a", "hello-world", 0).surface(&RESERVED);
501        assert_eq!(surface.cassettes.len(), 1);
502        assert_eq!(surface.cassettes[0].methods[0].name, "get-hello");
503    }
504
505    #[test]
506    fn a_cassette_with_no_cached_document_generates_no_noun() {
507        // Rather than an empty noun whose every method is missing.
508        let mut entry = cached("http://a", "hello-world", 0);
509        entry.specs.clear();
510        assert!(entry.surface(&RESERVED).is_empty());
511    }
512
513    #[test]
514    fn freshness_expires_after_the_revalidation_window() {
515        let entry = cached("http://a", "hello-world", 1_000);
516        assert!(entry.is_fresh(1_000, REVALIDATE_AFTER));
517        assert!(entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs() - 1, REVALIDATE_AFTER));
518        assert!(!entry.is_fresh(1_000 + REVALIDATE_AFTER.as_secs(), REVALIDATE_AFTER));
519    }
520
521    #[test]
522    fn a_clock_that_moved_backwards_expires_rather_than_pinning_the_surface() {
523        let entry = cached("http://a", "hello-world", 5_000);
524        assert!(!entry.is_fresh(1_000, REVALIDATE_AFTER));
525    }
526
527    #[test]
528    fn two_base_urls_get_two_cache_files() {
529        // A shared file would offer one server's nouns for another's data.
530        let a = cache_path(&config("http://one.example")).unwrap();
531        let b = cache_path(&config("http://two.example")).unwrap();
532        assert_ne!(a, b);
533    }
534
535    #[test]
536    fn urls_that_sanitize_alike_still_get_different_files() {
537        // Every non-alphanumeric becomes `_`, so the readable part collides;
538        // the hash is what keeps them apart.
539        let a = cache_path(&config("http://a-b.example")).unwrap();
540        let b = cache_path(&config("http://a.b-example")).unwrap();
541        assert_ne!(a, b);
542    }
543
544    #[test]
545    fn the_file_name_hash_is_stable_across_builds() {
546        // Pinned so a toolchain upgrade cannot silently orphan every cached
547        // surface by changing where they are looked up.
548        assert_eq!(fnv1a(""), 0xcbf2_9ce4_8422_2325);
549        assert_eq!(
550            fnv1a("http://127.0.0.1:8081/"),
551            fnv1a("http://127.0.0.1:8081/")
552        );
553        assert_ne!(fnv1a("a"), fnv1a("b"));
554    }
555
556    #[test]
557    fn the_file_name_is_byte_identical_to_the_pre_extraction_layout() {
558        // The extraction moved the machinery, not the cache: a file tapesctl
559        // wrote before the split must resolve to the same path after it, or
560        // every user's cached surface is silently orphaned. The literal
561        // expected name is pinned here rather than recomputed, so a change to
562        // the sanitizer, the truncation, or the hash all fail loudly.
563        // A private env-var name, so an exported TAPESCTL_CACHE_DIR on the
564        // machine running this suite cannot move the path under the pin.
565        let path = cache_path(&CacheConfig {
566            env_override_var: "CASSETTE_CLIENT_TEST_UNSET_VAR",
567            ..config("http://127.0.0.1:8081/")
568        })
569        .unwrap();
570        assert_eq!(
571            path.file_name().unwrap().to_str().unwrap(),
572            "http___127_0_0_1_8081_-709aba2490ce417e.json",
573        );
574        assert!(path.parent().unwrap().ends_with("tapesctl/cassettes"));
575    }
576
577    #[test]
578    fn the_cached_serde_shape_is_byte_compatible_with_the_pre_extraction_format() {
579        // Existing cache files must both decode and re-encode identically.
580        let cached = cached("http://a", "hello-world", 42);
581        let encoded = serde_json::to_value(&cached).unwrap();
582        assert_eq!(
583            encoded,
584            json!({
585                "base": "http://a",
586                "revalidated_at": 42,
587                "discovery": {
588                    "contract_version": "v1",
589                    "cassettes": [{
590                        "name": "hello-world",
591                        "version": null,
592                        "display_name": null,
593                        "description": null,
594                        "route_prefix": "/v1/cassettes/hello-world",
595                        "openapi_path": "/v1/cassettes/hello-world/openapi.json",
596                        "openapi_status": "fresh",
597                        "manifest_digest": ""
598                    }],
599                    "problems": []
600                },
601                "specs": {
602                    "hello-world": {
603                        "etag": "\"sha256:abc\"",
604                        "document": hello_document("hello-world")
605                    }
606                }
607            }),
608        );
609        let decoded: Cached = serde_json::from_value(encoded).unwrap();
610        assert_eq!(decoded.base, cached.base);
611    }
612}