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};
pub const SKIDBLADNIR_TYPE_ID: i8 = 40;
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";
pub struct NativeSkidbladnirPlugin;
impl NativeSkidbladnirPlugin {
pub fn new() -> Self {
NativeSkidbladnirPlugin
}
}
impl Default for NativeSkidbladnirPlugin {
fn default() -> Self {
Self::new()
}
}
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()));
}
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())));
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();
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);
}
}