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

nordisk/holger

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