holger-handler-bundle-core 0.1.0

The sealed-.znippy bundle repository machinery shared by holger's airgap package handlers
Documentation
//! **The sealed-bundle repository machinery, written once.**
//!
//! holger's two airgap handlers — Skidbladnir deploy bundles and Rust-toolchain
//! dev bundles — are the same repository: one sealed `.znippy` per
//! `(name, version)`, served whole, with a classifier that can say what any
//! path *inside* it is. Only three things differ between them, so only those
//! three are a [`BundleKind`]:
//!
//! * the handler's name,
//! * whether a filename looks like one of its bundles,
//! * the member classifier.
//!
//! Everything else — the store key, the coordinate parse, the HTTP routes,
//! `fetch`/`put`/`list` — lives here and is reached by both. Writing it twice
//! would have been ~90% duplication across two crates (LAW 5), and the two
//! copies would then need a guard to watch them agree; one writer needs none.
//!
//! This crate is dependency-free apart from [`holger_plugin_abi`], so it
//! compiles for `wasm32-unknown-unknown` unchanged and each handler's wasm shim
//! reaches exactly the same code the native backend does.
//!
//! # The path scheme, shared by every bundle handler
//!
//! ```text
//! GET  /{repo}/                                   → newline list of bundle files
//! GET  /{repo}/{name}/{version}                   → the sealed bundle bytes
//! GET  /{repo}/{name}/{version}/classify/{path…}  → what that member IS
//! PUT  /{repo}/{name}/{version}                   → store a bundle
//! ```
//!
//! `classify` is the route that puts the shared classifier **on the wire**: it
//! is pure path logic, it needs no index read, and it is what the native/wasm
//! agreement guard compares field by field. A `/members` route that enumerated a
//! bundle's contents would need to parse the znippy Arrow index — storage, which
//! a sandboxed module has no business doing and which would have had to answer
//! with an empty list on the wasm side. An endpoint that can only ever answer
//! "nothing" is the `NOT RUN → true` shape; it is deliberately absent.

use core::marker::PhantomData;

use holger_plugin_abi::{
    BlobStore, PackageHandler, WireArtifactEntry, WireArtifactId, WireHttpRequest,
    WireHttpResponse, WireManifest, ABI_VERSION,
};

/// Extension of a sealed bundle.
pub const BUNDLE_EXT: &str = ".znippy";

/// Content type a sealed bundle is served with.
pub const BUNDLE_CONTENT_TYPE: &str = "application/vnd.znippy.bundle";

/// What one entry inside a bundle is. Owned strings because the two classifiers
/// disagree on lifetime — Skidbladnir's components are a fixed set (`&'static
/// str`), a Rust-toolchain bundle's are derived from the path (a crate name, a
/// target triple), so only an owned form covers both.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberDescription {
    /// The stable `member_kind` string — `s3-blob`, `vendor-crate`, …
    pub kind: String,
    /// The logical component, or `-` when the member is bundle-level.
    pub component: String,
    /// Whether the member is encrypted at rest inside the bundle.
    pub encrypted: bool,
}

impl MemberDescription {
    /// The single serialization of a member, used by the HTTP route **and** by
    /// the agreement guard. One writer, so the native and wasm surfaces cannot
    /// render the same member differently.
    pub fn to_line(&self) -> String {
        format!("kind\t{}\ncomponent\t{}\nencrypted\t{}", self.kind, self.component, self.encrypted)
    }
}

/// The three things that differ between one bundle handler and another.
pub trait BundleKind {
    /// Stable handler name, reported in the plugin manifest.
    const HANDLER: &'static str;

    /// The `holger_traits::ArtifactFormat` spelling this handler reports.
    const FORMAT: &'static str;

    /// Whether `put` is accepted.
    const WRITABLE: bool;

    /// Classify one bundle-relative path. `None` = not a member of this kind of
    /// bundle, which the handler reports as a 404 rather than guessing.
    fn describe_member(path: &str) -> Option<MemberDescription>;

    /// Does this bare filename look like one of this kind's sealed bundles?
    /// Used to filter a store listing that may hold other files.
    fn is_bundle_name(file_name: &str) -> bool;
}

/// A bundle repository handler, parameterised by which bundle kind it serves.
pub struct BundleHandler<K: BundleKind>(PhantomData<K>);

impl<K: BundleKind> BundleHandler<K> {
    pub const fn new() -> Self {
        BundleHandler(PhantomData)
    }
}

impl<K: BundleKind> Default for BundleHandler<K> {
    fn default() -> Self {
        Self::new()
    }
}

impl<K: BundleKind> Clone for BundleHandler<K> {
    fn clone(&self) -> Self {
        Self::new()
    }
}

/// Store key for a coordinate: `{name}-{version}.znippy` — the sealed name
/// znippy itself writes (`tillsynia-20260706.znippy`,
/// `rust-dev-rhel8-1.97.1.znippy`).
///
/// The namespace is deliberately ignored: a sealed bundle has no group
/// coordinate, and folding one into the key would silently collide two different
/// ids onto one blob.
pub fn store_key(id: &WireArtifactId) -> String {
    format!("{}-{}{}", id.name, id.version, BUNDLE_EXT)
}

/// The inverse of [`store_key`].
///
/// Splits at the **last** `-`, so a hyphenated bundle name survives:
/// `rust-dev-rhel8-1.97.1.znippy` → `("rust-dev-rhel8", "1.97.1")`, not
/// `("rust", "dev-rhel8-1.97.1")`. Returns `None` for a name with no `-` or no
/// `.znippy` rather than inventing a coordinate.
pub fn coordinate_from_key(key: &str) -> Option<WireArtifactId> {
    let base = key.rsplit(['/', '\\']).next().unwrap_or(key);
    let stem = base.strip_suffix(BUNDLE_EXT)?;
    let (name, version) = stem.rsplit_once('-')?;
    if name.is_empty() || version.is_empty() {
        return None;
    }
    Some(WireArtifactId { namespace: None, name: name.to_string(), version: version.to_string() })
}

/// Drop the leading `/{repo}` and return the remaining path segments.
///
/// `RepositoryBackendTrait::handle_http2_request` is documented to receive a
/// suburl *including* the leading `/{repo}/…`, so the first segment is always the
/// repository name and never part of a coordinate.
fn path_segments(suburl: &str) -> Vec<&str> {
    let mut segs = suburl.split('/').filter(|s| !s.is_empty());
    segs.next();
    segs.collect()
}

fn id_of(name: &str, version: &str) -> WireArtifactId {
    WireArtifactId { namespace: None, name: name.to_string(), version: version.to_string() }
}

impl<K: BundleKind> PackageHandler for BundleHandler<K> {
    fn manifest(&self) -> WireManifest {
        WireManifest {
            abi_version: ABI_VERSION,
            handler: K::HANDLER.to_string(),
            format: K::FORMAT.to_string(),
            writable: K::WRITABLE,
        }
    }

    fn fetch(&self, store: &dyn BlobStore, id: &WireArtifactId) -> Result<Option<Vec<u8>>, String> {
        store.get(&store_key(id))
    }

    fn put(&self, store: &dyn BlobStore, id: &WireArtifactId, data: &[u8]) -> Result<(), String> {
        if !K::WRITABLE {
            return Err(format!("{}: repository is read-only", K::HANDLER));
        }
        // An empty half would produce the key "-1.0.znippy" or "x-.znippy",
        // which `coordinate_from_key` then reads back as a different coordinate
        // (or none at all). Refuse rather than store something unfetchable.
        if id.name.is_empty() || id.version.is_empty() {
            return Err(format!(
                "{}: refusing to store a bundle with an empty name or version \
                 (name={:?}, version={:?})",
                K::HANDLER,
                id.name,
                id.version
            ));
        }
        store.put(&store_key(id), data)
    }

    fn list(
        &self,
        store: &dyn BlobStore,
        name_filter: Option<&str>,
        limit: usize,
    ) -> Result<Vec<WireArtifactEntry>, String> {
        let mut out = Vec::new();
        let mut rows = store.list("")?;
        // A store's iteration order is its own business; the listing is sorted so
        // `limit` truncates the same set on every call and on both surfaces.
        rows.sort_by(|a, b| a.0.cmp(&b.0));
        for (key, size) in rows {
            if out.len() >= limit {
                break;
            }
            let base = key.rsplit(['/', '\\']).next().unwrap_or(&key);
            if !K::is_bundle_name(base) {
                continue;
            }
            let Some(id) = coordinate_from_key(&key) else {
                continue;
            };
            if let Some(f) = name_filter {
                if !id.name.contains(f) {
                    continue;
                }
            }
            out.push(WireArtifactEntry {
                id,
                // A bundle bigger than i64::MAX cannot exist; saturating keeps the
                // cast total instead of wrapping to a negative size.
                size_bytes: size.min(i64::MAX as u64) as i64,
                content_type: BUNDLE_CONTENT_TYPE.to_string(),
            });
        }
        Ok(out)
    }

    fn coordinate_for_path(&self, suburl: &str) -> Option<WireArtifactId> {
        let segs = path_segments(suburl);
        match segs.as_slice() {
            [name, version] => Some(id_of(name, version)),
            // The classify route addresses the SAME artifact, so the serve-time
            // quarantine gate sees a coordinate here too. A route that resolved
            // to `None` would slip past the gate.
            [name, version, "classify", ..] => Some(id_of(name, version)),
            _ => None,
        }
    }

    fn http(&self, store: &dyn BlobStore, req: &WireHttpRequest) -> Result<WireHttpResponse, String> {
        let segs = path_segments(&req.suburl);

        match (req.method.as_str(), segs.as_slice()) {
            ("GET", []) => {
                let mut names: Vec<String> = store
                    .list("")?
                    .into_iter()
                    .map(|(k, _)| k)
                    .filter(|k| K::is_bundle_name(k.rsplit(['/', '\\']).next().unwrap_or(k)))
                    .collect();
                names.sort();
                Ok(text(200, names.join("\n")))
            }

            ("GET", [name, version]) => {
                let id = id_of(name, version);
                match store.get(&store_key(&id))? {
                    Some(body) => Ok(WireHttpResponse {
                        status: 200,
                        headers: vec![
                            ("content-type".into(), BUNDLE_CONTENT_TYPE.into()),
                            ("content-length".into(), body.len().to_string()),
                        ],
                        body,
                    }),
                    None => Ok(text(404, format!("no such bundle: {}", store_key(&id)))),
                }
            }

            // The route that carries the shared classifier over the wire. Pure —
            // it never touches the store, so it answers identically whether the
            // handler is linked in or loaded from a module.
            ("GET", [_name, _version, "classify", rest @ ..]) => {
                if rest.is_empty() {
                    return Ok(text(400, "classify needs a bundle-relative path"));
                }
                let member_path = rest.join("/");
                match K::describe_member(&member_path) {
                    Some(d) => Ok(text(200, d.to_line())),
                    None => Ok(text(
                        404,
                        format!("{}: '{member_path}' is not a recognised bundle member", K::HANDLER),
                    )),
                }
            }

            ("PUT", [name, version]) => {
                let id = id_of(name, version);
                self.put(store, &id, &req.body)?;
                Ok(text(201, format!("stored {}", store_key(&id))))
            }

            (m, _) => Ok(text(405, format!("{}: {m} {} is not a bundle route", K::HANDLER, req.suburl))),
        }
    }
}

fn text(status: u16, body: impl Into<String>) -> WireHttpResponse {
    let body = body.into().into_bytes();
    WireHttpResponse {
        status,
        headers: vec![
            ("content-type".into(), "text/plain; charset=utf-8".into()),
            ("content-length".into(), body.len().to_string()),
        ],
        body,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_hyphenated_bundle_name_keeps_its_hyphens() {
        let id = coordinate_from_key("rust-dev-rhel8-1.97.1.znippy").expect("parsed");
        assert_eq!(id.name, "rust-dev-rhel8", "split at the FIRST hyphen instead of the last");
        assert_eq!(id.version, "1.97.1");
    }

    #[test]
    fn store_key_and_coordinate_are_inverses() {
        for (name, version) in
            [("tillsynia", "20260706"), ("rust-dev-rhel8", "1.97.1"), ("a", "0")]
        {
            let id = id_of(name, version);
            let back = coordinate_from_key(&store_key(&id)).expect("round trip");
            assert_eq!(back, id, "store_key/coordinate_from_key are not inverses for {name}");
        }
    }

    #[test]
    fn a_key_that_is_not_a_bundle_yields_no_coordinate() {
        assert_eq!(coordinate_from_key("README.md"), None);
        assert_eq!(coordinate_from_key("noversion.znippy"), None);
        assert_eq!(coordinate_from_key("-1.0.znippy"), None, "empty name accepted");
        assert_eq!(coordinate_from_key("x-.znippy"), None, "empty version accepted");
    }

    #[test]
    fn the_repo_segment_is_never_part_of_a_coordinate() {
        assert_eq!(path_segments("/bundles/tillsynia/20260706"), vec!["tillsynia", "20260706"]);
        assert_eq!(path_segments("/bundles/"), Vec::<&str>::new());
    }
}