update-bundle/src/api.rs
//! File-level convenience: load a `.holger.znippy`, verify it, and (for extract)
//! materialize its members into a fresh directory. `extract` always verifies first;
//! there is no production path that writes payloads without verification.
use std::fs;
use std::path::Path;
use crate::container::load_bundle_verified;
use crate::error::{BundleError, Result};
use crate::pathsafe::validate_member_path;
use crate::policy::TrustPolicy;
use crate::rollback::AcceptedState;
use crate::time::{HostRtc, TrustedTime};
use crate::verify::{verify_member_map_with_time, TargetSpec, VerifyReport};
use crate::MemberMap;
/// Load + verify a `.holger.znippy` bundle file, entirely offline.
///
/// Loads through [`load_bundle_verified`], so the release signature is checked
/// against `policy` from a few MiB of control members BEFORE any payload is
/// decompressed. A caller holding a policy never pays a decompression bomb's
/// price to find out the bundle was not signed.
pub fn verify_bundle_file(
path: &Path,
policy: &TrustPolicy,
target: &TargetSpec,
local_state: Option<&AcceptedState>,
) -> Result<VerifyReport> {
// W13: the published signature stays, and it delegates to the `_at` form with
// the host RTC NAMED. Before W13 this line reached for the wall clock and the verdict
// said nothing about where the instant came from; it now says `host-rtc
// (UNTRUSTED)` in its own checks list. What is accepted is unchanged.
verify_bundle_file_at(path, policy, target, local_state, &HostRtc)
}
/// [`verify_bundle_file`] against a named time source.
///
/// An airgapped appliance has no NTP and a battery for a clock. Handing this the
/// estate's clock — `holger-server`'s `EstateTime`, over edda `estate-clock`'s
/// signed answer to a nonce this box chose (the house's one signed-time format)
/// — is what makes an expiry refusal mean something. Handing it [`HostRtc`] is
/// what [`verify_bundle_file`] does, and the verdict says so.
///
/// # Errors
///
/// Every refusal [`verify_bundle_file`] makes, plus a refusal when the time
/// source cannot answer. A signed source whose signature did not verify refuses
/// here; it does not fall back to the RTC.
pub fn verify_bundle_file_at(
path: &Path,
policy: &TrustPolicy,
target: &TargetSpec,
local_state: Option<&AcceptedState>,
time: &dyn TrustedTime,
) -> Result<VerifyReport> {
let members = load_bundle_verified(path, policy)?;
verify_member_map_with_time(&members, policy, target, local_state, time)
}
/// Verify, then materialize the verified members into `dest`. `dest` must be
/// absent or an empty directory. Members are staged into a sibling temp directory
/// and the whole tree is atomically renamed into place — verify-before-extract,
/// with no partial tree ever visible under the final path.
pub fn extract_bundle_file(
path: &Path,
policy: &TrustPolicy,
target: &TargetSpec,
dest: &Path,
) -> Result<VerifyReport> {
extract_bundle_file_at(path, policy, target, dest, &HostRtc)
}
/// [`extract_bundle_file`] against a named time source. See
/// [`verify_bundle_file_at`].
///
/// # Errors
///
/// Every refusal [`extract_bundle_file`] makes, plus a refusal when the time
/// source cannot answer. Nothing is written when verification refuses.
pub fn extract_bundle_file_at(
path: &Path,
policy: &TrustPolicy,
target: &TargetSpec,
dest: &Path,
time: &dyn TrustedTime,
) -> Result<VerifyReport> {
let members = load_bundle_verified(path, policy)?;
let report = verify_member_map_with_time(&members, policy, target, None, time)?;
write_members_to_empty_dir(&members, dest)?;
Ok(report)
}
/// Write a verified member map into a fresh directory at `dest`, atomically.
pub fn write_members_to_empty_dir(members: &MemberMap, dest: &Path) -> Result<()> {
if dest.exists() {
let mut it = fs::read_dir(dest).map_err(|e| BundleError::io("read dest", e))?;
if it.next().is_some() {
return Err(BundleError::Io("destination directory is not empty".into()));
}
}
let parent = dest.parent().filter(|p| !p.as_os_str().is_empty());
let parent = parent.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent).map_err(|e| BundleError::io("mkdir dest parent", e))?;
let staging = tempfile::Builder::new()
.prefix(".holger-extract-")
.tempdir_in(parent)
.map_err(|e| BundleError::io("create staging dir", e))?;
for (rel, bytes) in members {
validate_member_path(rel)?;
let d = staging.path().join(rel);
if let Some(p) = d.parent() {
fs::create_dir_all(p).map_err(|e| BundleError::io("mkdir member parent", e))?;
}
fs::write(&d, bytes).map_err(|e| BundleError::io("write member", e))?;
}
// If dest is an existing empty dir, remove it so the rename can place the tree.
if dest.exists() {
fs::remove_dir(dest).map_err(|e| BundleError::io("remove empty dest", e))?;
}
let staging_path = staging.keep();
fs::rename(&staging_path, dest).map_err(|e| {
let _ = fs::remove_dir_all(&staging_path);
BundleError::io("rename extract into place", e)
})?;
Ok(())
}