Skip to main content

holger_handler_bundle_core/
lib.rs

1//! **The sealed-bundle repository machinery, written once.**
2//!
3//! holger's two airgap handlers — Skidbladnir deploy bundles and Rust-toolchain
4//! dev bundles — are the same repository: one sealed `.znippy` per
5//! `(name, version)`, served whole, with a classifier that can say what any
6//! path *inside* it is. Only three things differ between them, so only those
7//! three are a [`BundleKind`]:
8//!
9//! * the handler's name,
10//! * whether a filename looks like one of its bundles,
11//! * the member classifier.
12//!
13//! Everything else — the store key, the coordinate parse, the HTTP routes,
14//! `fetch`/`put`/`list` — lives here and is reached by both. Writing it twice
15//! would have been ~90% duplication across two crates (LAW 5), and the two
16//! copies would then need a guard to watch them agree; one writer needs none.
17//!
18//! This crate is dependency-free apart from [`holger_plugin_abi`], so it
19//! compiles for `wasm32-unknown-unknown` unchanged and each handler's wasm shim
20//! reaches exactly the same code the native backend does.
21//!
22//! # The path scheme, shared by every bundle handler
23//!
24//! ```text
25//! GET  /{repo}/                                   → newline list of bundle files
26//! GET  /{repo}/{name}/{version}                   → the sealed bundle bytes
27//! GET  /{repo}/{name}/{version}/classify/{path…}  → what that member IS
28//! PUT  /{repo}/{name}/{version}                   → store a bundle
29//! ```
30//!
31//! `classify` is the route that puts the shared classifier **on the wire**: it
32//! is pure path logic, it needs no index read, and it is what the native/wasm
33//! agreement guard compares field by field. A `/members` route that enumerated a
34//! bundle's contents would need to parse the znippy Arrow index — storage, which
35//! a sandboxed module has no business doing and which would have had to answer
36//! with an empty list on the wasm side. An endpoint that can only ever answer
37//! "nothing" is the `NOT RUN → true` shape; it is deliberately absent.
38
39use core::marker::PhantomData;
40
41use holger_plugin_abi::{
42    BlobStore, PackageHandler, WireArtifactEntry, WireArtifactId, WireHttpRequest,
43    WireHttpResponse, WireManifest, ABI_VERSION,
44};
45
46/// Extension of a sealed bundle.
47pub const BUNDLE_EXT: &str = ".znippy";
48
49/// Content type a sealed bundle is served with.
50pub const BUNDLE_CONTENT_TYPE: &str = "application/vnd.znippy.bundle";
51
52/// What one entry inside a bundle is. Owned strings because the two classifiers
53/// disagree on lifetime — Skidbladnir's components are a fixed set (`&'static
54/// str`), a Rust-toolchain bundle's are derived from the path (a crate name, a
55/// target triple), so only an owned form covers both.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct MemberDescription {
58    /// The stable `member_kind` string — `s3-blob`, `vendor-crate`, …
59    pub kind: String,
60    /// The logical component, or `-` when the member is bundle-level.
61    pub component: String,
62    /// Whether the member is encrypted at rest inside the bundle.
63    pub encrypted: bool,
64}
65
66impl MemberDescription {
67    /// The single serialization of a member, used by the HTTP route **and** by
68    /// the agreement guard. One writer, so the native and wasm surfaces cannot
69    /// render the same member differently.
70    pub fn to_line(&self) -> String {
71        format!("kind\t{}\ncomponent\t{}\nencrypted\t{}", self.kind, self.component, self.encrypted)
72    }
73}
74
75/// The three things that differ between one bundle handler and another.
76pub trait BundleKind {
77    /// Stable handler name, reported in the plugin manifest.
78    const HANDLER: &'static str;
79
80    /// The `holger_traits::ArtifactFormat` spelling this handler reports.
81    const FORMAT: &'static str;
82
83    /// Whether `put` is accepted.
84    const WRITABLE: bool;
85
86    /// Classify one bundle-relative path. `None` = not a member of this kind of
87    /// bundle, which the handler reports as a 404 rather than guessing.
88    fn describe_member(path: &str) -> Option<MemberDescription>;
89
90    /// Does this bare filename look like one of this kind's sealed bundles?
91    /// Used to filter a store listing that may hold other files.
92    fn is_bundle_name(file_name: &str) -> bool;
93}
94
95/// A bundle repository handler, parameterised by which bundle kind it serves.
96pub struct BundleHandler<K: BundleKind>(PhantomData<K>);
97
98impl<K: BundleKind> BundleHandler<K> {
99    pub const fn new() -> Self {
100        BundleHandler(PhantomData)
101    }
102}
103
104impl<K: BundleKind> Default for BundleHandler<K> {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl<K: BundleKind> Clone for BundleHandler<K> {
111    fn clone(&self) -> Self {
112        Self::new()
113    }
114}
115
116/// Store key for a coordinate: `{name}-{version}.znippy` — the sealed name
117/// znippy itself writes (`tillsynia-20260706.znippy`,
118/// `rust-dev-rhel8-1.97.1.znippy`).
119///
120/// The namespace is deliberately ignored: a sealed bundle has no group
121/// coordinate, and folding one into the key would silently collide two different
122/// ids onto one blob.
123pub fn store_key(id: &WireArtifactId) -> String {
124    format!("{}-{}{}", id.name, id.version, BUNDLE_EXT)
125}
126
127/// The inverse of [`store_key`].
128///
129/// Splits at the **last** `-`, so a hyphenated bundle name survives:
130/// `rust-dev-rhel8-1.97.1.znippy` → `("rust-dev-rhel8", "1.97.1")`, not
131/// `("rust", "dev-rhel8-1.97.1")`. Returns `None` for a name with no `-` or no
132/// `.znippy` rather than inventing a coordinate.
133pub fn coordinate_from_key(key: &str) -> Option<WireArtifactId> {
134    let base = key.rsplit(['/', '\\']).next().unwrap_or(key);
135    let stem = base.strip_suffix(BUNDLE_EXT)?;
136    let (name, version) = stem.rsplit_once('-')?;
137    if name.is_empty() || version.is_empty() {
138        return None;
139    }
140    Some(WireArtifactId { namespace: None, name: name.to_string(), version: version.to_string() })
141}
142
143/// Drop the leading `/{repo}` and return the remaining path segments.
144///
145/// `RepositoryBackendTrait::handle_http2_request` is documented to receive a
146/// suburl *including* the leading `/{repo}/…`, so the first segment is always the
147/// repository name and never part of a coordinate.
148fn path_segments(suburl: &str) -> Vec<&str> {
149    let mut segs = suburl.split('/').filter(|s| !s.is_empty());
150    segs.next();
151    segs.collect()
152}
153
154fn id_of(name: &str, version: &str) -> WireArtifactId {
155    WireArtifactId { namespace: None, name: name.to_string(), version: version.to_string() }
156}
157
158impl<K: BundleKind> PackageHandler for BundleHandler<K> {
159    fn manifest(&self) -> WireManifest {
160        WireManifest {
161            abi_version: ABI_VERSION,
162            handler: K::HANDLER.to_string(),
163            format: K::FORMAT.to_string(),
164            writable: K::WRITABLE,
165        }
166    }
167
168    fn fetch(&self, store: &dyn BlobStore, id: &WireArtifactId) -> Result<Option<Vec<u8>>, String> {
169        store.get(&store_key(id))
170    }
171
172    fn put(&self, store: &dyn BlobStore, id: &WireArtifactId, data: &[u8]) -> Result<(), String> {
173        if !K::WRITABLE {
174            return Err(format!("{}: repository is read-only", K::HANDLER));
175        }
176        // An empty half would produce the key "-1.0.znippy" or "x-.znippy",
177        // which `coordinate_from_key` then reads back as a different coordinate
178        // (or none at all). Refuse rather than store something unfetchable.
179        if id.name.is_empty() || id.version.is_empty() {
180            return Err(format!(
181                "{}: refusing to store a bundle with an empty name or version \
182                 (name={:?}, version={:?})",
183                K::HANDLER,
184                id.name,
185                id.version
186            ));
187        }
188        store.put(&store_key(id), data)
189    }
190
191    fn list(
192        &self,
193        store: &dyn BlobStore,
194        name_filter: Option<&str>,
195        limit: usize,
196    ) -> Result<Vec<WireArtifactEntry>, String> {
197        let mut out = Vec::new();
198        let mut rows = store.list("")?;
199        // A store's iteration order is its own business; the listing is sorted so
200        // `limit` truncates the same set on every call and on both surfaces.
201        rows.sort_by(|a, b| a.0.cmp(&b.0));
202        for (key, size) in rows {
203            if out.len() >= limit {
204                break;
205            }
206            let base = key.rsplit(['/', '\\']).next().unwrap_or(&key);
207            if !K::is_bundle_name(base) {
208                continue;
209            }
210            let Some(id) = coordinate_from_key(&key) else {
211                continue;
212            };
213            if let Some(f) = name_filter {
214                if !id.name.contains(f) {
215                    continue;
216                }
217            }
218            out.push(WireArtifactEntry {
219                id,
220                // A bundle bigger than i64::MAX cannot exist; saturating keeps the
221                // cast total instead of wrapping to a negative size.
222                size_bytes: size.min(i64::MAX as u64) as i64,
223                content_type: BUNDLE_CONTENT_TYPE.to_string(),
224            });
225        }
226        Ok(out)
227    }
228
229    fn coordinate_for_path(&self, suburl: &str) -> Option<WireArtifactId> {
230        let segs = path_segments(suburl);
231        match segs.as_slice() {
232            [name, version] => Some(id_of(name, version)),
233            // The classify route addresses the SAME artifact, so the serve-time
234            // quarantine gate sees a coordinate here too. A route that resolved
235            // to `None` would slip past the gate.
236            [name, version, "classify", ..] => Some(id_of(name, version)),
237            _ => None,
238        }
239    }
240
241    fn http(&self, store: &dyn BlobStore, req: &WireHttpRequest) -> Result<WireHttpResponse, String> {
242        let segs = path_segments(&req.suburl);
243
244        match (req.method.as_str(), segs.as_slice()) {
245            ("GET", []) => {
246                let mut names: Vec<String> = store
247                    .list("")?
248                    .into_iter()
249                    .map(|(k, _)| k)
250                    .filter(|k| K::is_bundle_name(k.rsplit(['/', '\\']).next().unwrap_or(k)))
251                    .collect();
252                names.sort();
253                Ok(text(200, names.join("\n")))
254            }
255
256            ("GET", [name, version]) => {
257                let id = id_of(name, version);
258                match store.get(&store_key(&id))? {
259                    Some(body) => Ok(WireHttpResponse {
260                        status: 200,
261                        headers: vec![
262                            ("content-type".into(), BUNDLE_CONTENT_TYPE.into()),
263                            ("content-length".into(), body.len().to_string()),
264                        ],
265                        body,
266                    }),
267                    None => Ok(text(404, format!("no such bundle: {}", store_key(&id)))),
268                }
269            }
270
271            // The route that carries the shared classifier over the wire. Pure —
272            // it never touches the store, so it answers identically whether the
273            // handler is linked in or loaded from a module.
274            ("GET", [_name, _version, "classify", rest @ ..]) => {
275                if rest.is_empty() {
276                    return Ok(text(400, "classify needs a bundle-relative path"));
277                }
278                let member_path = rest.join("/");
279                match K::describe_member(&member_path) {
280                    Some(d) => Ok(text(200, d.to_line())),
281                    None => Ok(text(
282                        404,
283                        format!("{}: '{member_path}' is not a recognised bundle member", K::HANDLER),
284                    )),
285                }
286            }
287
288            ("PUT", [name, version]) => {
289                let id = id_of(name, version);
290                self.put(store, &id, &req.body)?;
291                Ok(text(201, format!("stored {}", store_key(&id))))
292            }
293
294            (m, _) => Ok(text(405, format!("{}: {m} {} is not a bundle route", K::HANDLER, req.suburl))),
295        }
296    }
297}
298
299fn text(status: u16, body: impl Into<String>) -> WireHttpResponse {
300    let body = body.into().into_bytes();
301    WireHttpResponse {
302        status,
303        headers: vec![
304            ("content-type".into(), "text/plain; charset=utf-8".into()),
305            ("content-length".into(), body.len().to_string()),
306        ],
307        body,
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn a_hyphenated_bundle_name_keeps_its_hyphens() {
317        let id = coordinate_from_key("rust-dev-rhel8-1.97.1.znippy").expect("parsed");
318        assert_eq!(id.name, "rust-dev-rhel8", "split at the FIRST hyphen instead of the last");
319        assert_eq!(id.version, "1.97.1");
320    }
321
322    #[test]
323    fn store_key_and_coordinate_are_inverses() {
324        for (name, version) in
325            [("tillsynia", "20260706"), ("rust-dev-rhel8", "1.97.1"), ("a", "0")]
326        {
327            let id = id_of(name, version);
328            let back = coordinate_from_key(&store_key(&id)).expect("round trip");
329            assert_eq!(back, id, "store_key/coordinate_from_key are not inverses for {name}");
330        }
331    }
332
333    #[test]
334    fn a_key_that_is_not_a_bundle_yields_no_coordinate() {
335        assert_eq!(coordinate_from_key("README.md"), None);
336        assert_eq!(coordinate_from_key("noversion.znippy"), None);
337        assert_eq!(coordinate_from_key("-1.0.znippy"), None, "empty name accepted");
338        assert_eq!(coordinate_from_key("x-.znippy"), None, "empty version accepted");
339    }
340
341    #[test]
342    fn the_repo_segment_is_never_part_of_a_coordinate() {
343        assert_eq!(path_segments("/bundles/tillsynia/20260706"), vec!["tillsynia", "20260706"]);
344        assert_eq!(path_segments("/bundles/"), Vec::<&str>::new());
345    }
346}