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}