Skip to main content

yah_object_store/
http_ro.rs

1//! Unauthenticated, read-only object store over plain HTTPS (R599-T5).
2//!
3//! The read leg of a **content-addressed** store needs no credential, and
4//! giving it one is a net loss. Integrity comes from the content address, not
5//! from the transport: `yah_mesofact_bundle::materialize_bundle` verifies the
6//! manifest hashes to the requested digest and that every blob hashes to its
7//! recorded blake3 before writing anything to disk. A hostile origin, a
8//! compromised CDN, or a corrupted cache cannot inject bytes — the hash check
9//! fails. Authentication would add only confidentiality, which published
10//! bundles do not need.
11//!
12//! What that buys, and why it is the posture rather than a shortcut:
13//!
14//! - **Nodes hold no secrets.** A node bootstraps with nothing to provision,
15//!   rotate, or leak. Compare the alternative: a write-capable R2 key on every
16//!   box in the fleet, which would let any compromised node overwrite the
17//!   release store it reads from.
18//! - **Anything can serve it** — an R2 custom domain, a CDN edge, an nginx on
19//!   the LAN, a peer node's cache (W272 §2's peer-to-peer mirroring), a USB
20//!   stick in an air-gapped room. The bytes are self-verifying, so the
21//!   transport is interchangeable.
22//! - **It caches.** Immutable, content-addressed keys are the ideal CDN object:
23//!   infinite TTL, no invalidation protocol, free cold-start acceleration.
24//!
25//! This is the same split OCI registries (anonymous pull / authenticated push),
26//! Nix binary caches, and the Go module proxy all landed on: public immutable
27//! bytes, credentialed publish, trust anchored in the digest. The write half
28//! stays in [`crate::R2ObjectStore`] and lives only on the publisher.
29//!
30//! Accordingly the mutating half of [`ObjectStore`] is not emulated here — it
31//! returns [`Error::Backend`] rather than pretending. A caller that needs to
32//! write wants the credentialed store and should say so.
33
34use std::time::Duration;
35
36use reqwest::blocking::Client;
37use reqwest::StatusCode;
38
39use crate::{Error, ObjectStore};
40
41/// Read-only [`ObjectStore`] backed by a public HTTPS origin.
42///
43/// Keys are appended to the origin as path segments: origin
44/// `https://cdn.yah.dev` + key `blobs/abc…` → `https://cdn.yah.dev/blobs/abc…`.
45pub struct HttpReadOnlyObjectStore {
46    /// Base URL with no trailing slash.
47    origin: String,
48    client: Client,
49}
50
51impl HttpReadOnlyObjectStore {
52    /// Build a store against `origin` (e.g. `https://cdn.yah.dev`).
53    ///
54    /// A trailing slash on `origin` is trimmed so key joining stays
55    /// single-slashed.
56    pub fn new(origin: impl Into<String>) -> Result<Self, Error> {
57        let origin = origin.into().trim_end_matches('/').to_string();
58        if origin.is_empty() {
59            return Err(Error::Backend("bundle origin must not be empty".into()));
60        }
61        let client = Client::builder()
62            // A cold bundle fetch pulls many small blobs; keep-alive matters
63            // more than any single request's ceiling.
64            .timeout(Duration::from_secs(60))
65            .connect_timeout(Duration::from_secs(10))
66            .build()
67            .map_err(|e| Error::Backend(format!("building http client: {e}")))?;
68        Ok(Self { origin, client })
69    }
70
71    /// The origin this store reads from.
72    pub fn origin(&self) -> &str {
73        &self.origin
74    }
75
76    fn url(&self, key: &str) -> String {
77        format!("{}/{}", self.origin, key.trim_start_matches('/'))
78    }
79
80    /// Shared error text for the write half. Emulating a write over a read-only
81    /// origin would silently diverge from the store a publisher actually wrote
82    /// to, so every mutating method routes here instead.
83    fn read_only(op: &str) -> Error {
84        Error::Backend(format!(
85            "{op} is not supported by the read-only bundle origin — publishing \
86             goes through the credentialed R2 store on the publisher, never a node"
87        ))
88    }
89}
90
91impl ObjectStore for HttpReadOnlyObjectStore {
92    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
93        let url = self.url(key);
94        let resp = self
95            .client
96            .get(&url)
97            .send()
98            .map_err(|e| Error::Io(format!("GET {url}: {e}")))?;
99
100        match resp.status() {
101            StatusCode::NOT_FOUND | StatusCode::FORBIDDEN => {
102                // R2/S3 origins answer a missing key with 403 when listing is
103                // denied, which is the common public-bucket configuration.
104                // Treat both as a clean miss — the caller (materialize) turns
105                // that into MissingBlob with the key in hand.
106                Ok(None)
107            }
108            s if s.is_success() => {
109                let bytes = resp
110                    .bytes()
111                    .map_err(|e| Error::Io(format!("reading body of {url}: {e}")))?;
112                Ok(Some(bytes.to_vec()))
113            }
114            s => Err(Error::Backend(format!("GET {url}: unexpected status {s}"))),
115        }
116    }
117
118    fn head(&self, key: &str) -> Result<bool, Error> {
119        let url = self.url(key);
120        let resp = self
121            .client
122            .head(&url)
123            .send()
124            .map_err(|e| Error::Io(format!("HEAD {url}: {e}")))?;
125        match resp.status() {
126            StatusCode::NOT_FOUND | StatusCode::FORBIDDEN => Ok(false),
127            s if s.is_success() => Ok(true),
128            s => Err(Error::Backend(format!("HEAD {url}: unexpected status {s}"))),
129        }
130    }
131
132    fn put(&self, _key: &str, _data: Vec<u8>) -> Result<(), Error> {
133        Err(Self::read_only("put"))
134    }
135
136    fn delete(&self, _key: &str) -> Result<(), Error> {
137        Err(Self::read_only("delete"))
138    }
139
140    fn list_prefix(&self, _prefix: &str) -> Result<Vec<String>, Error> {
141        // A plain HTTPS origin exposes no listing protocol. Materialize is
142        // manifest-driven (it knows every key it needs), so nothing on the node
143        // path calls this.
144        Err(Self::read_only("list_prefix"))
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn origin_trailing_slash_is_trimmed() {
154        let s = HttpReadOnlyObjectStore::new("https://cdn.yah.dev/").unwrap();
155        assert_eq!(s.origin(), "https://cdn.yah.dev");
156        assert_eq!(s.url("blobs/abc"), "https://cdn.yah.dev/blobs/abc");
157    }
158
159    #[test]
160    fn key_leading_slash_does_not_double_up() {
161        let s = HttpReadOnlyObjectStore::new("https://cdn.yah.dev").unwrap();
162        assert_eq!(s.url("/blobs/abc"), "https://cdn.yah.dev/blobs/abc");
163    }
164
165    #[test]
166    fn empty_origin_is_rejected() {
167        assert!(HttpReadOnlyObjectStore::new("").is_err());
168        assert!(HttpReadOnlyObjectStore::new("///").is_err());
169    }
170
171    /// The write half must fail loudly rather than emulate. A node that could
172    /// "succeed" at a put would diverge from the real store silently.
173    #[test]
174    fn mutating_ops_are_refused() {
175        let s = HttpReadOnlyObjectStore::new("https://cdn.yah.dev").unwrap();
176        assert!(s.put("blobs/x", vec![1]).is_err());
177        assert!(s.delete("blobs/x").is_err());
178        assert!(s.list_prefix("blobs/").is_err());
179        // put_if / etag inherit the trait defaults, which also refuse.
180        assert!(s.etag("blobs/x").is_err());
181    }
182}