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