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}