plugins/native/znippy-plugin-skidbladnir/src/lib.rs
//! znippy handler for **Skidbladnir airgap bundles** — the Tillsynia/Njord
//! deploy payload (db dump · S3 blobs · container images · infra operators +
//! Talos ISO · declarative config · encrypted secrets).
//!
//! It teaches znippy's Arrow-IPC index what each entry of a sealed bundle *is*,
//! so the archive is self-describing and DuckDB/Polars-queryable
//! (`SELECT logical_path FROM bundle WHERE member_kind = 's3-blob'`). It adds
//! **only metadata columns** — the format, the pack/move/unfold mechanics and
//! the sealing stay owned by znippy + Skidbladnir.
//!
//! Native builtin: registered in `znippy-cli/src/handlers.rs::builtin_handlers`,
//! discovered via [`ArchiveTypePlugin::meta`]. NOT a WASM plugin.
//!
//! Laws it honours: **P-1** one archive = one ecosystem (`--format skidbladnir`);
//! **P-2** writes only the columns it declared; **P-4** never panics — a member
//! it cannot parse still writes with its path-derived `member_kind`, only the
//! optional `version`/`watermark` degrade to null.
#[cfg(feature = "znippy-handler")]
use znippy_common::plugin::{
default_command, ArchiveTypePlugin, Col, ExtensionRow, ExtensionValue, Claim, HandlerSpec,
};
#[cfg(feature = "znippy-handler")]
use znippy_common::plugins::bundle::{json_str_field, member_row};
/// The bundle-member classifier. **Unconditional**, and deliberately free of
/// every dependency this crate has: it is pure `&str` → enum, so it compiles for
/// `wasm32-unknown-unknown` as-is.
///
/// That is what `--no-default-features` buys a consumer. holger's package-handler
/// plugins (`holger-handler-skidbladnir`, and its wasm shim) need this ownership
/// rule and nothing else; taking it with the default features would drag
/// `znippy-common` — arrow, io_uring and the gatling engine — into a `wasm32`
/// build that cannot link any of them. Same shape as `znippy-plugin-maven`'s
/// `host-decompressors`: the target-independent core stays, the host-only
/// machinery is compiled out.
pub mod member;
pub use member::{MemberKind, classify, component_of, is_encrypted};
/// DenseUnion / pkg_type discriminant. Clear of the built-ins (1–18) and media (25).
pub const SKIDBLADNIR_TYPE_ID: i8 = 40;
/// The canonical bundle layout, printed by the `layout` subcommand and mirrored
/// by the classifier in [`member`].
pub const BUNDLE_LAYOUT: &str = "\
tillsynia-<version>.znippy
├── lakespec.json # products + depends_on order + pki source
├── manifest/ # bundle.json (version·scope·watermark) + consistency.json
├── dbdump/ # atomic pg_dump stream (postgres)
├── s3/ # document blobs (minio; dokument.fil_nyckel)
├── app/ # container images: njord-web, njord-mail-server (oci tar)
├── infra/ # unfold operators + boot media
│ ├── terraform/ redfish/ kvm/
│ └── talos/ # talos-<ver>.iso + machineconfig.yaml
├── njordconf/ # declarative config (serverspec/services; helm optional)
└── secrets/ # *.age — encrypted; key supplied at unfold, never in bundle";
/// Native Skidbladnir-bundle handler.
#[cfg(feature = "znippy-handler")]
pub struct NativeSkidbladnirPlugin;
#[cfg(feature = "znippy-handler")]
impl NativeSkidbladnirPlugin {
pub fn new() -> Self {
NativeSkidbladnirPlugin
}
}
#[cfg(feature = "znippy-handler")]
impl Default for NativeSkidbladnirPlugin {
fn default() -> Self {
Self::new()
}
}
/// The handler's claim: a path is its own when it sits in the bundle layout
/// (`member::classify`), whatever its extension.
#[cfg(feature = "znippy-handler")]
fn is_bundle_member(path: &str) -> bool {
classify(path).is_some()
}
/// The Skidbladnir handler, stated once.
#[cfg(feature = "znippy-handler")]
pub static SKIDBLADNIR_SPEC: HandlerSpec = HandlerSpec {
name: "skidbladnir",
aliases: &["skid", "airgap", "tillsynia"],
type_id: SKIDBLADNIR_TYPE_ID,
ecosystem: "Skidbladnir airgap bundle (Tillsynia/Njord deploy payload)",
extensions: &[".lakespec", ".dump", ".oci.tar", ".imageref", ".tgz", ".iso", ".age"],
claim: Claim::Layout(is_bundle_member),
description: "Tags each bundle entry (db dump / S3 blob / image / infra / \
talos-iso / config / secret) into the index so a sealed \
deploy bundle is self-describing and queryable",
fields: &[
("member_kind", Col::Utf8),
("component", Col::Utf8),
("logical_path", Col::Utf8),
("version", Col::Utf8),
("watermark", Col::Utf8),
("encrypted", Col::Utf8),
],
commands: &[
("classify", "Print the member_kind / component / encrypted for a bundle path"),
("layout", "Print the canonical Skidbladnir bundle layout"),
],
};
#[cfg(feature = "znippy-handler")]
impl ArchiveTypePlugin for NativeSkidbladnirPlugin {
fn spec(&self) -> &HandlerSpec {
&SKIDBLADNIR_SPEC
}
fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
match cmd {
"layout" => {
println!("{BUNDLE_LAYOUT}");
Ok(())
}
"classify" => {
let path = args
.first()
.ok_or_else(|| anyhow::anyhow!("usage: skidbladnir classify <bundle-path>"))?;
match classify(path) {
Some(kind) => {
println!("path: {path}");
println!("member_kind: {}", kind.as_str());
println!("component: {}", component_of(kind, path).unwrap_or("-"));
println!("encrypted: {}", is_encrypted(kind, path));
Ok(())
}
None => anyhow::bail!("'{path}' is not a recognised Skidbladnir bundle member"),
}
}
other => default_command(self, other, args),
}
}
fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
let kind = classify(path)?;
let mut f = member_row(kind.as_str(), path, component_of(kind, path).map(str::to_string));
f.insert(
"encrypted".into(),
ExtensionValue::Str(is_encrypted(kind, path).to_string()),
);
// version / watermark only live in the declarative JSON members; parse
// best-effort. A garbage or oversized file just leaves them null (P-4).
if matches!(kind, MemberKind::Lakespec | MemberKind::Manifest) {
if let Some(v) = json_str_field(data, "version") {
f.insert("version".into(), ExtensionValue::Str(v));
}
if let Some(w) = json_str_field(data, "watermark") {
f.insert("watermark".into(), ExtensionValue::Str(w));
}
}
Some(ExtensionRow { fields: f })
}
}
#[cfg(all(test, feature = "znippy-handler"))]
mod tests {
use super::*;
fn get<'a>(row: &'a ExtensionRow, k: &str) -> Option<&'a ExtensionValue> {
row.fields.get(k)
}
#[test]
fn tags_a_db_dump() {
let p = NativeSkidbladnirPlugin::new();
let row = p.extract_metadata("dbdump/njord.dump", b"\x00binary-dump").unwrap();
assert_eq!(get(&row, "member_kind"), Some(&ExtensionValue::Str("dbdump".into())));
assert_eq!(get(&row, "component"), Some(&ExtensionValue::Str("postgres".into())));
assert_eq!(get(&row, "encrypted"), Some(&ExtensionValue::Str("false".into())));
// no version/watermark for a raw dump
assert!(get(&row, "version").is_none());
}
#[test]
fn parses_manifest_version_and_watermark() {
let p = NativeSkidbladnirPlugin::new();
let json = br#"{"version":"20260706","watermark":"2026-07-06T10:00:00Z","scope":"full"}"#;
let row = p.extract_metadata("manifest/bundle.json", json).unwrap();
assert_eq!(get(&row, "version"), Some(&ExtensionValue::Str("20260706".into())));
assert_eq!(
get(&row, "watermark"),
Some(&ExtensionValue::Str("2026-07-06T10:00:00Z".into()))
);
}
#[test]
fn manifest_with_garbage_json_falls_back_never_panics() {
let p = NativeSkidbladnirPlugin::new();
let row = p.extract_metadata("manifest/bundle.json", b"not json at all }{").unwrap();
// still tagged, just no version/watermark
assert_eq!(get(&row, "member_kind"), Some(&ExtensionValue::Str("manifest".into())));
assert!(get(&row, "version").is_none());
assert!(get(&row, "watermark").is_none());
}
#[test]
fn secret_is_flagged_encrypted() {
let p = NativeSkidbladnirPlugin::new();
let row = p.extract_metadata("secrets/secrets.age", b"age-encrypted").unwrap();
assert_eq!(get(&row, "member_kind"), Some(&ExtensionValue::Str("secret".into())));
assert_eq!(get(&row, "encrypted"), Some(&ExtensionValue::Str("true".into())));
}
#[test]
fn does_not_claim_foreign_paths() {
let p = NativeSkidbladnirPlugin::new();
assert!(!p.matches_path("README.md"));
assert!(p.matches_path("s3/doc.pdf"));
assert!(p.extract_metadata("README.md", b"x").is_none());
}
#[test]
fn schema_and_meta_are_consistent() {
let p = NativeSkidbladnirPlugin::new();
assert_eq!(p.type_id(), SKIDBLADNIR_TYPE_ID);
assert_eq!(p.meta().type_id, SKIDBLADNIR_TYPE_ID);
assert_eq!(p.schema_fields().len(), 6);
}
}