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 locate(&self, key: &str) -> String {
93        self.url(key)
94    }
95
96    fn get(&self, key: &str) -> Result<Option<Vec<u8>>, Error> {
97        let url = self.url(key);
98        let resp = self
99            .client
100            .get(&url)
101            .send()
102            .map_err(|e| Error::Io(format!("GET {url}: {e}")))?;
103
104        match resp.status() {
105            StatusCode::NOT_FOUND | StatusCode::FORBIDDEN => {
106                // R2/S3 origins answer a missing key with 403 when listing is
107                // denied, which is the common public-bucket configuration.
108                // Treat both as a clean miss — the caller (materialize) turns
109                // that into MissingBlob with the key in hand.
110                Ok(None)
111            }
112            s if s.is_success() => {
113                let bytes = resp
114                    .bytes()
115                    .map_err(|e| Error::Io(format!("reading body of {url}: {e}")))?;
116                Ok(Some(bytes.to_vec()))
117            }
118            s => Err(Error::Backend(format!("GET {url}: unexpected status {s}"))),
119        }
120    }
121
122    fn head(&self, key: &str) -> Result<bool, Error> {
123        let url = self.url(key);
124        let resp = self
125            .client
126            .head(&url)
127            .send()
128            .map_err(|e| Error::Io(format!("HEAD {url}: {e}")))?;
129        match resp.status() {
130            StatusCode::NOT_FOUND | StatusCode::FORBIDDEN => Ok(false),
131            s if s.is_success() => Ok(true),
132            s => Err(Error::Backend(format!("HEAD {url}: unexpected status {s}"))),
133        }
134    }
135
136    fn put(&self, _key: &str, _data: Vec<u8>) -> Result<(), Error> {
137        Err(Self::read_only("put"))
138    }
139
140    fn delete(&self, _key: &str) -> Result<(), Error> {
141        Err(Self::read_only("delete"))
142    }
143
144    fn list_prefix(&self, _prefix: &str) -> Result<Vec<String>, Error> {
145        // A plain HTTPS origin exposes no listing protocol. Materialize is
146        // manifest-driven (it knows every key it needs), so nothing on the node
147        // path calls this.
148        Err(Self::read_only("list_prefix"))
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn origin_trailing_slash_is_trimmed() {
158        let s = HttpReadOnlyObjectStore::new("https://cdn.yah.dev/").unwrap();
159        assert_eq!(s.origin(), "https://cdn.yah.dev");
160        assert_eq!(s.url("blobs/abc"), "https://cdn.yah.dev/blobs/abc");
161    }
162
163    #[test]
164    fn key_leading_slash_does_not_double_up() {
165        let s = HttpReadOnlyObjectStore::new("https://cdn.yah.dev").unwrap();
166        assert_eq!(s.url("/blobs/abc"), "https://cdn.yah.dev/blobs/abc");
167    }
168
169    #[test]
170    fn empty_origin_is_rejected() {
171        assert!(HttpReadOnlyObjectStore::new("").is_err());
172        assert!(HttpReadOnlyObjectStore::new("///").is_err());
173    }
174
175    /// The write half must fail loudly rather than emulate. A node that could
176    /// "succeed" at a put would diverge from the real store silently.
177    #[test]
178    fn mutating_ops_are_refused() {
179        let s = HttpReadOnlyObjectStore::new("https://cdn.yah.dev").unwrap();
180        assert!(s.put("blobs/x", vec![1]).is_err());
181        assert!(s.delete("blobs/x").is_err());
182        assert!(s.list_prefix("blobs/").is_err());
183        // put_if / etag inherit the trait defaults, which also refuse.
184        assert!(s.etag("blobs/x").is_err());
185    }
186}