znippy-plugin-skidbladnir 0.1.0

Skidbladnir airgap-bundle metadata plugin for znippy (native builtin — no WASM)
//! 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.

use std::collections::HashMap;

use znippy_common::arrow::datatypes::{DataType, Field};
use znippy_common::plugin::{
    ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta,
};

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.
pub struct NativeSkidbladnirPlugin;

impl NativeSkidbladnirPlugin {
    pub fn new() -> Self {
        NativeSkidbladnirPlugin
    }
}

impl Default for NativeSkidbladnirPlugin {
    fn default() -> Self {
        Self::new()
    }
}

/// Best-effort pull of a top-level string field from a JSON member. Never
/// panics — bad/garbage JSON just yields `None` (P-4).
fn json_str_field(data: &[u8], key: &str) -> Option<String> {
    let v: serde_json::Value = serde_json::from_slice(data).ok()?;
    v.get(key)?.as_str().map(str::to_string)
}

impl ArchiveTypePlugin for NativeSkidbladnirPlugin {
    fn name(&self) -> &str {
        "skidbladnir"
    }

    fn type_id(&self) -> i8 {
        SKIDBLADNIR_TYPE_ID
    }

    fn meta(&self) -> HandlerMeta {
        HandlerMeta {
            name: "skidbladnir".into(),
            aliases: vec!["skid".into(), "airgap".into(), "tillsynia".into()],
            type_id: SKIDBLADNIR_TYPE_ID,
            ecosystem: "Skidbladnir airgap bundle (Tillsynia/Njord deploy payload)".into(),
            extensions: vec![
                ".lakespec".into(),
                ".dump".into(),
                ".oci.tar".into(),
                ".imageref".into(),
                ".tgz".into(),
                ".iso".into(),
                ".age".into(),
            ],
            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"
                .into(),
            commands: vec![
                HandlerCommand::new(
                    "classify",
                    "Print the member_kind / component / encrypted for a bundle path",
                ),
                HandlerCommand::new("layout", "Print the canonical Skidbladnir bundle layout"),
            ],
        }
    }

    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 => anyhow::bail!("skidbladnir: unknown subcommand '{other}'"),
        }
    }

    fn matches_path(&self, path: &str) -> bool {
        classify(path).is_some()
    }

    fn schema_fields(&self) -> Vec<Field> {
        vec![
            Field::new("member_kind", DataType::Utf8, true),
            Field::new("component", DataType::Utf8, true),
            Field::new("logical_path", DataType::Utf8, true),
            Field::new("version", DataType::Utf8, true),
            Field::new("watermark", DataType::Utf8, true),
            Field::new("encrypted", DataType::Utf8, true),
        ]
    }

    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
        let kind = classify(path)?;
        let mut f: HashMap<String, ExtensionValue> = HashMap::new();

        f.insert("member_kind".into(), ExtensionValue::Str(kind.as_str().into()));
        f.insert("logical_path".into(), ExtensionValue::Str(path.into()));
        f.insert(
            "encrypted".into(),
            ExtensionValue::Str(is_encrypted(kind, path).to_string()),
        );
        if let Some(c) = component_of(kind, path) {
            f.insert("component".into(), ExtensionValue::Str(c.into()));
        }

        // 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(test)]
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);
    }
}