plugin/handler-bundle-core/src/lib.rs
//! **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, ModuleIdentity, 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;
/// The anti-rollback floor's lane is `(SUPPLIER, HANDLER)`.
const SUPPLIER: &'static str;
/// The version the floor compares: the handler crate's own
/// (`env!("CARGO_PKG_VERSION")`), so a release IS a floor step.
const VERSION: &'static str;
/// 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,
identity: Some(ModuleIdentity::new(K::SUPPLIER, K::HANDLER, K::VERSION)),
}
}
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());
}
}