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/rollback.rs

//! Anti-rollback state, per `(supplier, product)`.
//!
//! Each accepted bundle leaves one marker file `<NNNN>.accepted` written with
//! `create_new(true)` — race-safe (two importers, one wins with `AlreadyExists`)
//! and crash-safe (a torn marker is simply re-created). The reduced in-memory view
//! ([`AcceptedState`]) is the highest accepted sequence plus, per sequence, the
//! `(bundle_id, core_digest)` that was accepted — enough to reject a downgrade, a
//! same-sequence-different-bytes replay, and a bundle-id reuse.

use std::collections::BTreeMap;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::core::BundleCore;
use crate::error::{BundleError, Result};

/// One accepted bundle's recorded identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcceptedEntry {
    pub sequence: u64,
    pub bundle_id: String,
    pub core_digest: String,
    pub accepted_at: DateTime<Utc>,
}

/// The reduced accepted state for one `(supplier, product)`.
#[derive(Debug, Clone, Default)]
pub struct AcceptedState {
    entries: BTreeMap<u64, AcceptedEntry>,
}

/// Whether a bundle may be admitted, and if so whether it is a no-op re-import.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Admissibility {
    /// A new, strictly-newer bundle — import proceeds and records state.
    New,
    /// The exact bundle already accepted at this sequence — import is a no-op.
    IdempotentReimport,
}

impl AcceptedState {
    pub fn empty() -> Self {
        Self::default()
    }

    pub fn from_entries(entries: Vec<AcceptedEntry>) -> Self {
        let mut map = BTreeMap::new();
        for e in entries {
            map.insert(e.sequence, e);
        }
        AcceptedState { entries: map }
    }

    pub fn highest_accepted_sequence(&self) -> Option<u64> {
        self.entries.keys().next_back().copied()
    }

    /// Decide whether `core` (with the given candidate digest) may be admitted.
    /// Rejects: a lower sequence; a reused sequence with different bytes; a reused
    /// bundle id with different bytes.
    pub fn check_admissible(
        &self,
        core: &BundleCore,
        candidate_digest: &str,
    ) -> Result<Admissibility> {
        // Bundle-id reuse with different content is always a forgery signal.
        for e in self.entries.values() {
            if e.bundle_id == core.bundle_id && e.core_digest != candidate_digest {
                return Err(BundleError::BundleIdReuse(core.bundle_id.clone()));
            }
        }
        if let Some(hi) = self.highest_accepted_sequence() {
            if core.sequence < hi {
                return Err(BundleError::SequenceRollback {
                    found: core.sequence,
                    accepted: hi,
                });
            }
        }
        if let Some(existing) = self.entries.get(&core.sequence) {
            if existing.core_digest == candidate_digest && existing.bundle_id == core.bundle_id {
                return Ok(Admissibility::IdempotentReimport);
            }
            return Err(BundleError::SequenceReplay(core.sequence));
        }
        Ok(Admissibility::New)
    }
}

/// **The anti-rollback floor, as an ANSWER rather than an empty value.**
///
/// Before 2026-09-20 an importer asked [`RollbackStore::load`] for an
/// [`AcceptedState`] and got `AcceptedState::empty()` back when the directory did
/// not exist. `check_admissible` then compared against a highest-accepted sequence
/// of `None`, so EVERY sequence read as `New` — including an old, vulnerable one.
/// A `wipe` followed by an import of an ancient paid module was admitted in
/// silence, and the module's own CMS chain does not stop it: a correctly signed
/// OLD module verifies forever, because the signature says nothing about age.
/// **The floor is the only downgrade stop there is.**
///
/// The hole cannot be fixed by MOVING the floor. Nothing on an appliance is out of
/// reach of whoever holds the disk, so a floor stored anywhere on it can be
/// erased. What can be fixed is the SILENCE: absence is now a value the importer
/// has to handle, and handling it means refusing unless the operator says
/// `--first-import` by name.
#[derive(Debug, Clone)]
pub enum Floor {
    /// A floor exists: at least one bundle has been accepted for this
    /// `(supplier, product)`, and this is what was accepted.
    InForce(AcceptedState),
    /// No floor. Either nothing was ever accepted here, or the state was erased.
    /// An import against this is REFUSED unless `first_import` is passed.
    Absent,
}

impl Floor {
    /// Is there a floor at all?
    pub fn is_in_force(&self) -> bool {
        matches!(self, Floor::InForce(_))
    }

    /// The accepted state, when there is one.
    pub fn accepted(&self) -> Option<&AcceptedState> {
        match self {
            Floor::InForce(s) => Some(s),
            Floor::Absent => None,
        }
    }

    /// Decide whether `core` may be admitted.
    ///
    /// `InForce` delegates to [`AcceptedState::check_admissible`], which is
    /// unchanged. `Absent` REFUSES with [`BundleError::RollbackFloorAbsent`]
    /// unless `first_import` is true — the explicit, named opt-in that establishes
    /// the floor with this bundle.
    pub fn check_admissible(
        &self,
        core: &BundleCore,
        candidate_digest: &str,
        first_import: bool,
    ) -> Result<Admissibility> {
        match self {
            Floor::InForce(state) => state.check_admissible(core, candidate_digest),
            Floor::Absent if first_import => Ok(Admissibility::New),
            Floor::Absent => Err(BundleError::RollbackFloorAbsent {
                supplier: core.supplier.clone(),
                product: core.product.clone(),
            }),
        }
    }
}

fn sanitize_seg(s: &str) -> String {
    let mut out: String = s
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
                c
            } else {
                '_'
            }
        })
        .collect();
    if out.is_empty() || out == "." || out == ".." {
        out = "_".to_string();
    }
    out
}

/// On-disk store of accepted-sequence markers.
pub struct RollbackStore {
    root: PathBuf,
}

impl RollbackStore {
    /// Open (do not create) a store rooted at `root`.
    pub fn open(root: impl AsRef<Path>) -> Self {
        RollbackStore {
            root: root.as_ref().to_path_buf(),
        }
    }

    fn dir_for(&self, supplier: &str, product: &str) -> PathBuf {
        self.root
            .join(sanitize_seg(supplier))
            .join(sanitize_seg(product))
    }

    /// Load the accepted state for a `(supplier, product)`.
    pub fn load(&self, supplier: &str, product: &str) -> Result<AcceptedState> {
        let dir = self.dir_for(supplier, product);
        if !dir.exists() {
            return Ok(AcceptedState::empty());
        }
        let mut entries = Vec::new();
        for de in fs::read_dir(&dir).map_err(|e| BundleError::io("read state dir", e))? {
            let de = de.map_err(|e| BundleError::io("read state entry", e))?;
            let path = de.path();
            if path.extension().and_then(|s| s.to_str()) != Some("accepted") {
                continue;
            }
            let bytes = fs::read(&path).map_err(|e| BundleError::io("read state marker", e))?;
            let entry: AcceptedEntry = serde_json::from_slice(&bytes)?;
            entries.push(entry);
        }
        Ok(AcceptedState::from_entries(entries))
    }

    /// Load the [`Floor`] for a `(supplier, product)` — the same walk as
    /// [`Self::load`], except that "nothing here" comes back as [`Floor::Absent`]
    /// instead of an empty state that every sequence clears.
    ///
    /// A directory that exists but holds no `.accepted` marker is ALSO `Absent`:
    /// an emptied directory and a missing one admit exactly the same bundles, so
    /// they must answer the same way. Anything else would make `rm *.accepted` a
    /// quieter erase than `rm -r`.
    pub fn floor(&self, supplier: &str, product: &str) -> Result<Floor> {
        let state = self.load(supplier, product)?;
        if state.highest_accepted_sequence().is_none() {
            return Ok(Floor::Absent);
        }
        Ok(Floor::InForce(state))
    }

    /// Record an accepted bundle. Race-safe via `create_new`; a second record of an
    /// identical `(sequence, bundle_id, core_digest)` is an idempotent no-op, but a
    /// conflicting record at the same sequence is refused.
    pub fn record(&self, supplier: &str, product: &str, entry: &AcceptedEntry) -> Result<()> {
        let dir = self.dir_for(supplier, product);
        fs::create_dir_all(&dir).map_err(|e| BundleError::io("mkdir state dir", e))?;
        let marker = dir.join(format!("{:020}.accepted", entry.sequence));
        let bytes = serde_json::to_vec_pretty(entry)?;
        match fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&marker)
        {
            Ok(mut f) => {
                use std::io::Write;
                f.write_all(&bytes)
                    .map_err(|e| BundleError::io("write marker", e))?;
                f.flush().map_err(|e| BundleError::io("flush marker", e))?;
                Ok(())
            }
            Err(e) if e.kind() == ErrorKind::AlreadyExists => {
                // Idempotent only if the existing marker matches exactly.
                let existing = fs::read(&marker).map_err(|e| BundleError::io("read marker", e))?;
                let existing: AcceptedEntry = serde_json::from_slice(&existing)?;
                if existing.bundle_id == entry.bundle_id
                    && existing.core_digest == entry.core_digest
                {
                    Ok(())
                } else {
                    Err(BundleError::SequenceReplay(entry.sequence))
                }
            }
            Err(e) => Err(BundleError::io("create marker", e)),
        }
    }
}