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