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

nordisk/Skidbladnir

src/promote/envelope.rs

//! **The bundle envelope** — seal · unseal · verify · manifest, payload-agnostic.
//!
//! The ship packs whatever the application staged and never looks inside. What it
//! *does* own is the envelope around it: a content-addressed manifest with an
//! integrity root ([`crate::archive::BundleManifest`], already the airgap law
//! here), plus a promotion manifest that records **where the payload came from,
//! when, and — the part that matters — whether the capture was whole**.
//!
//! # Why the completeness verdict lives in the envelope
//!
//! A bundle outlives the terminal that produced it. If "the object store was
//! unreachable, so this is database-only" exists solely as a warning line that
//! scrolled past three days ago, then the bundle is indistinguishable from a good
//! one at the moment somebody restores it. So the verdict from
//! [`DumpReport::completeness`] is written into the sidecar manifest, `verify`
//! reports it, and [`unseal`] **refuses** an incomplete bundle unless the caller
//! passes [`LoadPolicy::accept_incomplete`] — saying out loud, at load time, what
//! was only implied at seal time.
//!
//! # The sidecar
//!
//! The promotion manifest is written **beside** the bundle as
//! `<bundle><MANIFEST_SUFFIX>`, not inside it. Writing it inside would change the
//! archive's content and therefore its own integrity root — a manifest cannot
//! describe a bundle it is part of. This mirrors [`crate::archive::manifest_path`].

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::archive::{Archive, BundleManifest};

use super::appdata::{Completeness, DumpReport, Watermark};

/// The suffix of the promotion manifest sidecar.
pub const MANIFEST_SUFFIX: &str = ".promote.json";

/// The manifest version, so a reader can refuse a shape it does not know rather
/// than best-effort parse it.
pub const MANIFEST_VERSION: &str = "skidbladnir.nordisk/promote-bundle.v1";

/// The promotion manifest — everything the ship knows about a sealed payload.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromotionManifest {
    /// Must equal [`MANIFEST_VERSION`].
    pub version: String,
    /// The slot the payload was captured from, by name.
    pub source_slot: String,
    /// RFC3339, supplied by the caller so the envelope stays pure.
    pub taken_at: String,
    /// [`AppData::describe`](super::AppData::describe) — what produced the payload.
    pub app: String,
    /// **The verdict.** Not a boolean, and not derivable from the archive: a
    /// bundle whose capture was partial is byte-perfect and still wrong.
    pub completeness: Completeness,
    /// Per-part detail behind the verdict.
    pub parts: Vec<super::appdata::CapturedPart>,
    /// The source's watermark at seal time. `None` = unknown.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub watermark: Option<Watermark>,
    /// The application's own payload knowledge. **Opaque** — carried and
    /// round-tripped, never read.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub app_meta: Option<serde_json::Value>,
    /// The archive's content-addressed manifest — per-entry digests and the
    /// integrity root the receiver checks.
    pub bundle: BundleManifest,
}

impl PromotionManifest {
    /// Is the archive's own integrity root self-consistent?
    pub fn verify_root(&self) -> bool {
        self.bundle.verify_root()
    }
}

/// What to seal, and how to label it.
#[derive(Debug, Clone)]
pub struct SealSpec<'a> {
    /// The staging tree the application filled.
    pub staging: &'a Path,
    /// Where the bundle goes. The sidecar lands next to it.
    pub out: &'a Path,
    /// The slot the payload came from, by name.
    pub source_slot: &'a str,
    /// The airgap generation label for [`crate::archive::BundleManifest::version`].
    pub version: &'a str,
    /// RFC3339 — [`now_rfc3339`](super::now_rfc3339) if you want the clock.
    pub taken_at: &'a str,
    /// [`AppData::describe`](super::AppData::describe).
    pub app: &'a str,
}

/// A sealed bundle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sealed {
    /// The archive.
    pub bundle: PathBuf,
    /// The sidecar manifest.
    pub manifest_path: PathBuf,
    pub manifest: PromotionManifest,
}

impl Sealed {
    /// Was the capture whole?
    pub fn is_complete(&self) -> bool {
        self.manifest.completeness.is_complete()
    }
}

/// The sidecar path for a bundle.
pub fn manifest_path(bundle: &Path) -> PathBuf {
    let mut s = bundle.as_os_str().to_os_string();
    s.push(MANIFEST_SUFFIX);
    PathBuf::from(s)
}

/// **Seal** a staged payload.
///
/// The capture's verdict is recorded whatever it is — sealing an incomplete
/// capture is allowed, because a partial bundle is often the only thing anybody
/// has and destroying it helps nobody. What is *not* allowed is losing the fact,
/// which is why it is written into the manifest and re-checked at load.
pub fn seal(archive: &dyn Archive, spec: &SealSpec<'_>, report: &DumpReport) -> Result<Sealed> {
    let bundle_manifest = archive
        .pack(spec.staging, spec.version, spec.out)
        .with_context(|| format!("seal {} → {}", spec.staging.display(), spec.out.display()))?;

    let manifest = PromotionManifest {
        version: MANIFEST_VERSION.to_string(),
        source_slot: spec.source_slot.to_string(),
        taken_at: spec.taken_at.to_string(),
        app: spec.app.to_string(),
        completeness: report.completeness(),
        parts: report.parts.clone(),
        watermark: report.watermark.clone(),
        app_meta: report.app_meta.clone(),
        bundle: bundle_manifest,
    };

    let mp = manifest_path(spec.out);
    std::fs::write(&mp, serde_json::to_vec_pretty(&manifest)?)
        .with_context(|| format!("write promotion manifest {}", mp.display()))?;

    Ok(Sealed {
        bundle: spec.out.to_path_buf(),
        manifest_path: mp,
        manifest,
    })
}

/// Read the promotion manifest for a bundle.
pub fn load_manifest(bundle: &Path) -> Result<PromotionManifest> {
    let mp = manifest_path(bundle);
    let bytes =
        std::fs::read(&mp).with_context(|| format!("read promotion manifest {}", mp.display()))?;
    let m: PromotionManifest = serde_json::from_slice(&bytes)
        .with_context(|| format!("parse promotion manifest {}", mp.display()))?;
    if m.version != MANIFEST_VERSION {
        anyhow::bail!(
            "{}: manifest version '{}' is not supported by this build (expected '{MANIFEST_VERSION}').\n   \
             A bundle manifest is NOT parsed best-effort — a misread manifest is how an \
             incomplete capture gets treated as a whole one.",
            mp.display(),
            m.version
        );
    }
    Ok(m)
}

/// What `verify` found.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerifyReport {
    /// Does the manifest's integrity root recompute?
    pub root_ok: bool,
    /// Entries whose bytes do not match the manifest — or that the manifest never
    /// listed. Empty is the only good value.
    pub bad_entries: Vec<String>,
    /// The capture's verdict, carried forward from seal time.
    pub completeness: Completeness,
    /// Per-part detail.
    pub parts: Vec<super::appdata::CapturedPart>,
}

impl VerifyReport {
    /// Are the **bytes** intact? Says nothing about whether the capture was whole
    /// — that is [`VerifyReport::completeness`], and conflating the two is how a
    /// partial bundle passes a checksum and gets called good.
    pub fn intact(&self) -> bool {
        self.root_ok && self.bad_entries.is_empty()
    }

    /// Intact **and** whole.
    pub fn is_usable(&self) -> bool {
        self.intact() && self.completeness.is_complete()
    }

    /// Lines for an operator.
    pub fn lines(&self) -> Vec<String> {
        let mut v = vec![
            format!(
                "integrity root: {}",
                if self.root_ok { "ok" } else { "MISMATCH" }
            ),
            format!("capture: {}", self.completeness.as_str()),
        ];
        for b in &self.bad_entries {
            v.push(format!("corrupt or unlisted entry: {b}"));
        }
        v.extend(self.parts.iter().map(super::appdata::CapturedPart::line));
        v
    }
}

/// **Verify** a bundle: recompute the integrity root, check every entry's bytes,
/// and report the capture verdict recorded at seal time.
pub fn verify(archive: &dyn Archive, bundle: &Path) -> Result<VerifyReport> {
    let m = load_manifest(bundle)?;
    let bad = archive
        .verify_entries(bundle, &m.bundle)
        .with_context(|| format!("verify entries of {}", bundle.display()))?;
    Ok(VerifyReport {
        root_ok: m.verify_root(),
        bad_entries: bad,
        completeness: m.completeness,
        parts: m.parts,
    })
}

/// How much a caller is willing to accept when unsealing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LoadPolicy {
    /// Load a bundle whose capture was incomplete. Off by default; when on, the
    /// verdict still comes back in [`Unsealed::completeness`] so the caller
    /// reports it.
    pub accept_incomplete: bool,
    /// Load a bundle whose bytes do not verify. Off by default, and there is
    /// almost never a good reason — it exists so that a forensic tool does not
    /// have to reimplement unsealing.
    pub accept_corrupt: bool,
}

impl LoadPolicy {
    /// The strict default: intact and whole, or nothing.
    pub fn strict() -> Self {
        Self::default()
    }
    /// Accept an incomplete capture — deliberately, and with the verdict reported.
    pub fn accept_incomplete(mut self) -> Self {
        self.accept_incomplete = true;
        self
    }
    /// Accept bytes that do not verify. Forensics only.
    pub fn accept_corrupt(mut self) -> Self {
        self.accept_corrupt = true;
        self
    }
}

/// An unsealed bundle, ready for [`AppData::load`](super::AppData::load).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unsealed {
    /// Where the payload was extracted.
    pub staging: PathBuf,
    pub manifest: PromotionManifest,
    pub verified: VerifyReport,
}

impl Unsealed {
    pub fn completeness(&self) -> &Completeness {
        &self.verified.completeness
    }
    /// The source watermark, for [`Watermark::would_discard`].
    pub fn watermark(&self) -> Option<&Watermark> {
        self.manifest.watermark.as_ref()
    }
}

/// **Unseal** a bundle into `dest`, refusing anything the policy does not permit.
pub fn unseal(
    archive: &dyn Archive,
    bundle: &Path,
    dest: &Path,
    policy: LoadPolicy,
) -> Result<Unsealed> {
    let report = verify(archive, bundle)?;

    if !report.intact() && !policy.accept_corrupt {
        anyhow::bail!(
            "refusing to unseal {}: its bytes do not verify.\n   \
             integrity root: {}\n   \
             bad or unlisted entries: {}\n   \
             Re-fetch the bundle. If this is a forensic read, say so explicitly \
             (LoadPolicy::accept_corrupt).",
            bundle.display(),
            if report.root_ok { "ok" } else { "MISMATCH" },
            if report.bad_entries.is_empty() {
                "none".to_string()
            } else {
                report.bad_entries.join(", ")
            }
        );
    }

    if !report.completeness.is_complete() && !policy.accept_incomplete {
        anyhow::bail!(
            "refusing to unseal {}: the capture that produced it was NOT whole.\n   \
             verdict: {}\n   {}\n   \
             The bytes are fine; the payload is not all there. Loading it will make the \
             target look restored while parts of it are missing. Capture again, or say \
             explicitly that you accept an incomplete bundle (LoadPolicy::accept_incomplete) \
             — the verdict is reported either way.",
            bundle.display(),
            report.completeness.as_str(),
            report
                .parts
                .iter()
                .filter(|p| p.required && !p.state.is_complete())
                .map(super::appdata::CapturedPart::line)
                .collect::<Vec<_>>()
                .join("\n   ")
        );
    }

    let manifest = load_manifest(bundle)?;
    std::fs::create_dir_all(dest)
        .with_context(|| format!("create staging dir {}", dest.display()))?;
    archive
        .extract_all(bundle, dest)
        .with_context(|| format!("unseal {} → {}", bundle.display(), dest.display()))?;

    Ok(Unsealed {
        staging: dest.to_path_buf(),
        manifest,
        verified: report,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::archive::TarZstd;
    use crate::promote::appdata::CapturedPart;

    fn staging(dir: &Path, files: &[(&str, &str)]) {
        for (name, body) in files {
            let p = dir.join(name);
            if let Some(parent) = p.parent() {
                std::fs::create_dir_all(parent).unwrap();
            }
            std::fs::write(p, body).unwrap();
        }
    }

    fn whole() -> DumpReport {
        DumpReport::new()
            .part(CapturedPart::complete("database", Some(2)))
            .part(CapturedPart::complete("objects", Some(1)))
            .watermark(Some(Watermark::at("2026-07-20T10:00:00Z")))
            .app_meta(serde_json::json!({"schema_version": 51}))
    }

    fn partial() -> DumpReport {
        DumpReport::new()
            .part(CapturedPart::complete("database", Some(2)))
            .part(CapturedPart::skipped(
                "objects",
                "S3 credentials were not configured",
            ))
    }

    fn sealed(tmp: &Path, report: &DumpReport) -> Sealed {
        let stage = tmp.join("stage");
        std::fs::create_dir_all(&stage).unwrap();
        staging(
            &stage,
            &[("dbdump/a.copy", "one\n"), ("objects/x.pdf", "bytes")],
        );
        let out = tmp.join("bundle.tar.zst");
        seal(
            &TarZstd::default(),
            &SealSpec {
                staging: &stage,
                out: &out,
                source_slot: "prod",
                version: "20260727",
                taken_at: "2026-07-27T12:00:00Z",
                app: "example-app",
            },
            report,
        )
        .unwrap()
    }

    #[test]
    fn a_whole_capture_seals_verifies_and_unseals() {
        let tmp = tempfile::tempdir().unwrap();
        let s = sealed(tmp.path(), &whole());
        assert!(s.is_complete());
        assert!(
            s.manifest_path.exists(),
            "the sidecar must be written next to the bundle"
        );

        let v = verify(&TarZstd::default(), &s.bundle).unwrap();
        assert!(v.intact() && v.is_usable(), "{v:?}");

        let dest = tmp.path().join("unsealed");
        let u = unseal(&TarZstd::default(), &s.bundle, &dest, LoadPolicy::strict()).unwrap();
        assert_eq!(
            std::fs::read_to_string(u.staging.join("dbdump/a.copy")).unwrap(),
            "one\n"
        );
        assert_eq!(u.manifest.source_slot, "prod");
        assert_eq!(u.manifest.app, "example-app");
        assert_eq!(
            u.watermark().unwrap().mark.as_deref(),
            Some("2026-07-20T10:00:00Z")
        );
        // The application's own knowledge survives untouched.
        assert_eq!(u.manifest.app_meta.unwrap()["schema_version"], 51);
    }

    /// **The bug this envelope exists to prevent**: a partial capture produces a
    /// bundle whose bytes are perfect. A checksum cannot tell the difference; the
    /// manifest can, and must.
    #[test]
    fn an_incomplete_capture_is_stamped_incomplete_and_refuses_to_unseal() {
        let tmp = tempfile::tempdir().unwrap();
        let s = sealed(tmp.path(), &partial());
        assert!(
            !s.is_complete(),
            "the seal must record the verdict, not the intent"
        );

        let v = verify(&TarZstd::default(), &s.bundle).unwrap();
        assert!(v.intact(), "the BYTES are fine — that is the whole trap");
        assert!(!v.is_usable(), "…and the bundle is still not usable");

        let dest = tmp.path().join("unsealed");
        let err = unseal(&TarZstd::default(), &s.bundle, &dest, LoadPolicy::strict())
            .expect_err("an incomplete bundle must not unseal silently");
        let m = format!("{err:#}");
        assert!(m.contains("NOT whole"), "{m}");
        assert!(
            m.contains("objects: skipped"),
            "the offender must be named: {m}"
        );
        assert!(
            m.contains("S3 credentials"),
            "…with the reason from the capture: {m}"
        );
    }

    #[test]
    fn an_incomplete_bundle_may_be_unsealed_deliberately_and_still_reports_the_verdict() {
        let tmp = tempfile::tempdir().unwrap();
        let s = sealed(tmp.path(), &partial());
        let dest = tmp.path().join("unsealed");
        let u = unseal(
            &TarZstd::default(),
            &s.bundle,
            &dest,
            LoadPolicy::strict().accept_incomplete(),
        )
        .unwrap();
        assert!(
            !u.completeness().is_complete(),
            "the verdict must survive the override"
        );
        assert!(
            u.verified
                .lines()
                .iter()
                .any(|l| l.contains("objects: skipped"))
        );
    }

    /// A dump that declared nothing is not a successful dump.
    #[test]
    fn sealing_an_empty_report_records_unknown_not_complete() {
        let tmp = tempfile::tempdir().unwrap();
        let s = sealed(tmp.path(), &DumpReport::new());
        assert_eq!(s.manifest.completeness, Completeness::Unknown);
        assert!(!s.is_complete());
        let dest = tmp.path().join("u");
        assert!(unseal(&TarZstd::default(), &s.bundle, &dest, LoadPolicy::strict()).is_err());
    }

    #[test]
    fn tampered_bytes_are_caught_and_refused() {
        let tmp = tempfile::tempdir().unwrap();
        let s = sealed(tmp.path(), &whole());
        // Corrupt one entry's recorded digest so verify has to disagree with the
        // archive. (Rewriting the archive itself is the same test with more steps.)
        let mut m = load_manifest(&s.bundle).unwrap();
        m.bundle.entries[0].sha256 = "0".repeat(64);
        std::fs::write(&s.manifest_path, serde_json::to_vec_pretty(&m).unwrap()).unwrap();

        let v = verify(&TarZstd::default(), &s.bundle).unwrap();
        assert!(!v.intact(), "a digest that does not match must not verify");
        let dest = tmp.path().join("u");
        let err = unseal(&TarZstd::default(), &s.bundle, &dest, LoadPolicy::strict()).unwrap_err();
        assert!(format!("{err:#}").contains("do not verify"), "{err:#}");
        // …and forensics can still get at it, explicitly.
        assert!(
            unseal(
                &TarZstd::default(),
                &s.bundle,
                &dest,
                LoadPolicy::strict().accept_corrupt().accept_incomplete()
            )
            .is_ok()
        );
    }

    #[test]
    fn a_manifest_from_an_unknown_version_is_refused_not_guessed_at() {
        let tmp = tempfile::tempdir().unwrap();
        let s = sealed(tmp.path(), &whole());
        let mut m = load_manifest(&s.bundle).unwrap();
        m.version = "skidbladnir.nordisk/promote-bundle.v9".into();
        std::fs::write(&s.manifest_path, serde_json::to_vec_pretty(&m).unwrap()).unwrap();
        let err = load_manifest(&s.bundle).unwrap_err();
        assert!(format!("{err:#}").contains("not supported"), "{err:#}");
    }

    #[test]
    fn the_manifest_round_trips_through_serde() {
        let tmp = tempfile::tempdir().unwrap();
        let s = sealed(tmp.path(), &whole());
        let back: PromotionManifest =
            serde_json::from_str(&serde_json::to_string(&s.manifest).unwrap()).unwrap();
        assert_eq!(back, s.manifest);
        assert!(back.verify_root());
    }
}