Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy

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);
    }
}