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

nordisk/holger

plugin/host/src/floor.rs

//! **The anti-rollback floor — a module may never go BACK** (W14).
//!
//! `verify.rs` answers "is this the module we signed?". It cannot answer "is
//! this the module we signed LAST WEEK, before the fix?" — a correctly signed
//! old module verifies forever, because a signature says nothing about age. So
//! until this file existed, anyone who could write the plugin directory could
//! roll a module back to any version that had ever been signed, and every gate
//! on the way would say yes.
//!
//! # The shape, and why absence is a VALUE
//!
//! Per `(supplier, product)` — the module's own lane, the same key the
//! update-bundle floor uses, so a routine plugin update can never read as a
//! firmware rollback — this box remembers the highest version it ever
//! **activated**. A module offering a lower version is refused BY NAME
//! ([`ec::PLUGIN_BELOW_FLOOR`]): supplier, product, offered version and the
//! floor version are in the message, because "rollback refused" without the two
//! numbers is a bug report nobody can act on.
//!
//! The plan's ruling was to fix the SILENCE, not the location. Nothing on an
//! appliance is out of reach of whoever holds the disk, so the floor can always
//! be erased; what must not happen is that an erased floor and a never-set floor
//! and a corrupt floor all read as "fine". Hence [`Floor`] is an enum and not an
//! `Option`:
//!
//! * [`Floor::InForce`] — a floor exists; a lower version is refused.
//! * [`Floor::Absent`] — this box has never activated this product. The load is
//!   ALLOWED and the first activation SETS the floor, and it says so
//!   ([`Admission::FirstActivation`]) — never silently.
//! * A floor file that exists but cannot be read or parsed is a REFUSAL
//!   ([`ec::PLUGIN_FLOOR_UNREADABLE`]), never `Absent`: otherwise corrupting one
//!   file would be a quieter erase than deleting it.
//!
//! # Where it lives and how it is written
//!
//! `<floor dir>/<supplier>/<product>.floor` — the server anchors the dir at
//! `<data_root>/plugins/floor`, beside the meter plane's private store. Written
//! at ACTIVATION (the module loaded, verified, its grants checked and its name
//! accepted into a catalog), by staging + `rename`, never in place, so a crash
//! mid-write leaves the previous floor intact rather than a torn file that
//! would refuse every load until an operator found it.
//!
//! # Policy versus floor
//!
//! [`FloorPolicy`] is what the OPERATOR decides: keep a floor at this path, or
//! not (`NotKept`, the pre-existing behaviour, kept as the host's default because
//! this crate has no data root of its own). [`Floor`] is what the DISK answers.
//! The server always keeps one; a build that does not is not "floor = fine", it
//! is a build whose policy says so by name.

use std::path::{Path, PathBuf};

use holger_errcode as ec;
use holger_plugin_abi::ModuleIdentity;

/// The file name extension a floor record carries: `<product>.floor`.
pub const FLOOR_EXT: &str = "floor";

/// The first line of every floor file. A file that does not start with it is
/// not a floor file, whatever else it holds.
const MAGIC: &str = "holger-plugin-floor v1";

/// `MAJOR.MINOR.PATCH`, ordered numerically — `1.10.0` is above `1.9.0`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Version(pub u64, pub u64, pub u64);

impl Version {
    /// Parse `MAJOR.MINOR.PATCH`. Nothing looser: a version the floor cannot
    /// order is a version the floor cannot hold, and the refusal names it.
    pub fn parse(s: &str) -> Result<Self, String> {
        let s = s.trim();
        let mut parts = s.split('.');
        let mut next = |what: &str| -> Result<u64, String> {
            parts
                .next()
                .ok_or_else(|| format!("'{s}' has no {what} component"))?
                .parse::<u64>()
                .map_err(|e| format!("'{s}': {what} component is not a decimal number: {e}"))
        };
        let v = Version(next("major")?, next("minor")?, next("patch")?);
        if parts.next().is_some() {
            return Err(format!(
                "'{s}' has more than three components; only MAJOR.MINOR.PATCH is ordered"
            ));
        }
        Ok(v)
    }
}

impl std::fmt::Display for Version {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}.{}.{}", self.0, self.1, self.2)
    }
}

/// What is on disk for one `(supplier, product)`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FloorRecord {
    pub supplier: String,
    pub product: String,
    /// The highest version ever activated here.
    pub version: Version,
    /// The file the version was activated from — a label for the operator, not
    /// a trust input.
    pub activated_from: String,
}

/// **The floor, as an answer rather than an empty value.**
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Floor {
    /// A version has been activated for this lane; nothing below it loads.
    InForce(FloorRecord),
    /// Nothing has ever been activated for this lane on this box. The next
    /// activation sets the floor, and says so.
    Absent,
}

impl Floor {
    pub fn is_in_force(&self) -> bool {
        matches!(self, Floor::InForce(_))
    }

    pub fn record(&self) -> Option<&FloorRecord> {
        match self {
            Floor::InForce(r) => Some(r),
            Floor::Absent => None,
        }
    }
}

/// Whether this host keeps a floor, and where.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum FloorPolicy {
    /// No floor is consulted or written — the pre-existing behaviour, and the
    /// host's default only because the host has no data root of its own. A
    /// server names a directory.
    #[default]
    NotKept,
    /// Consult and raise the floor under this directory.
    Kept { dir: PathBuf },
}

impl FloorPolicy {
    pub fn kept(dir: impl Into<PathBuf>) -> Self {
        FloorPolicy::Kept { dir: dir.into() }
    }

    pub fn is_kept(&self) -> bool {
        matches!(self, FloorPolicy::Kept { .. })
    }

    /// The store, when one is kept.
    pub fn store(&self) -> Option<FloorStore> {
        match self {
            FloorPolicy::Kept { dir } => Some(FloorStore::at(dir)),
            FloorPolicy::NotKept => None,
        }
    }
}

/// How a module stood against its lane's floor when it was admitted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Admission {
    /// This box had no floor for the lane; the module's activation SETS it.
    FirstActivation { identity: ModuleIdentity },
    /// A floor was in force and the module is at or above it. `raises` is true
    /// when the module is strictly above, i.e. activation moves the floor.
    AtOrAbove {
        identity: ModuleIdentity,
        floor: Version,
        raises: bool,
    },
    /// No floor is kept under this policy. Named, so a log line exists for the
    /// build whose failure mode would otherwise be silence.
    NotKept { module: String },
}

impl std::fmt::Display for Admission {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Admission::FirstActivation { identity } => write!(
                f,
                "{}/{} {}: no anti-rollback floor existed on this box for this product — \
                 first activation sets the floor at {}",
                identity.supplier, identity.product, identity.version, identity.version
            ),
            Admission::AtOrAbove { identity, floor, raises: true } => write!(
                f,
                "{}/{} {}: above the anti-rollback floor {floor} — activation raises the floor to {}",
                identity.supplier, identity.product, identity.version, identity.version
            ),
            Admission::AtOrAbove { identity, floor, raises: false } => write!(
                f,
                "{}/{} {}: at the anti-rollback floor {floor}",
                identity.supplier, identity.product, identity.version
            ),
            Admission::NotKept { module } => write!(
                f,
                "{module}: NO anti-rollback floor is kept under this policy — a signed older \
                 version of this module would load"
            ),
        }
    }
}

impl Admission {
    /// The identity this admission was decided for, when a floor is kept.
    pub fn identity(&self) -> Option<&ModuleIdentity> {
        match self {
            Admission::FirstActivation { identity } | Admission::AtOrAbove { identity, .. } => {
                Some(identity)
            }
            Admission::NotKept { .. } => None,
        }
    }
}

/// The on-disk floor store: `<dir>/<supplier>/<product>.floor`.
#[derive(Debug, Clone)]
pub struct FloorStore {
    dir: PathBuf,
}

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
}

impl FloorStore {
    pub fn at(dir: impl Into<PathBuf>) -> Self {
        Self { dir: dir.into() }
    }

    pub fn dir(&self) -> &Path {
        &self.dir
    }

    /// Where one lane's floor is written.
    pub fn path_for(&self, supplier: &str, product: &str) -> PathBuf {
        self.dir
            .join(sanitize_seg(supplier))
            .join(format!("{}.{FLOOR_EXT}", sanitize_seg(product)))
    }

    /// Read the floor for a lane. A missing file is [`Floor::Absent`]; a file
    /// that exists and cannot be read or parsed is an `Err` carrying
    /// [`ec::PLUGIN_FLOOR_UNREADABLE`] — fail closed.
    pub fn read(&self, supplier: &str, product: &str) -> Result<Floor, String> {
        let path = self.path_for(supplier, product);
        let text = match std::fs::read_to_string(&path) {
            Ok(t) => t,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Floor::Absent),
            Err(e) => {
                return Err(ec::PLUGIN_FLOOR_UNREADABLE.tag(format!(
                    "{}: the anti-rollback floor for {supplier}/{product} could not be read: {e}. \
                     An unreadable floor is a REFUSAL, not an absent one — refusing to load \
                     rather than treating it as a box that never activated this product.",
                    path.display()
                )))
            }
        };
        let rec = parse_record(&text).map_err(|why| {
            ec::PLUGIN_FLOOR_UNREADABLE.tag(format!(
                "{}: the anti-rollback floor for {supplier}/{product} is not a floor file this \
                 holger can read ({why}). A corrupt floor is a REFUSAL, not an absent one.",
                path.display()
            ))
        })?;
        if rec.supplier != supplier || rec.product != product {
            return Err(ec::PLUGIN_FLOOR_UNREADABLE.tag(format!(
                "{}: the floor file names {}/{} but sits on the lane for {supplier}/{product}",
                path.display(),
                rec.supplier,
                rec.product
            )));
        }
        Ok(Floor::InForce(rec))
    }

    /// Decide whether `identity` may be activated on this box.
    ///
    /// Every failure is a named `Err`; there is no "could not check, carrying
    /// on". `module` is what the refusal names alongside the lane.
    pub fn admit(
        &self,
        module: &Path,
        identity: Option<&ModuleIdentity>,
    ) -> Result<Admission, String> {
        let Some(identity) = identity else {
            return Err(ec::PLUGIN_IDENTITY_MISSING.tag(format!(
                "{}: the module's manifest declares no (supplier, product, version), and this box \
                 keeps an anti-rollback floor. A module the floor cannot place is refused, not \
                 loaded on a lane of its own — rebuild it with an identity in its manifest.",
                module.display()
            )));
        };
        let offered = Version::parse(&identity.version).map_err(|why| {
            ec::PLUGIN_IDENTITY_MISSING.tag(format!(
                "{}: {}/{} declares version {why} — the anti-rollback floor orders \
                 MAJOR.MINOR.PATCH only, so this module cannot be placed against it.",
                module.display(),
                identity.supplier,
                identity.product
            ))
        })?;
        match self.read(&identity.supplier, &identity.product)? {
            Floor::Absent => Ok(Admission::FirstActivation {
                identity: identity.clone(),
            }),
            Floor::InForce(rec) if offered < rec.version => {
                Err(ec::PLUGIN_BELOW_FLOOR.tag(format!(
                "{}: {}/{} offers version {offered}, BELOW the anti-rollback floor {} activated \
                 on this box (from {}). A correctly signed older module is still a rollback; \
                 it is refused by name. Floor file: {}",
                module.display(),
                identity.supplier,
                identity.product,
                rec.version,
                rec.activated_from,
                self.path_for(&identity.supplier, &identity.product).display()
            )))
            }
            Floor::InForce(rec) => Ok(Admission::AtOrAbove {
                identity: identity.clone(),
                floor: rec.version,
                raises: offered > rec.version,
            }),
        }
    }

    /// Record `identity` as ACTIVATED: raise the lane's floor to its version
    /// when that is higher than what is on disk. Staging + rename, never in
    /// place. A no-op when the floor is already at or above.
    pub fn activate(&self, module: &Path, identity: &ModuleIdentity) -> Result<Floor, String> {
        let offered = Version::parse(&identity.version).map_err(|why| {
            ec::PLUGIN_IDENTITY_MISSING.tag(format!("{}: {why}", module.display()))
        })?;
        if let Floor::InForce(rec) = self.read(&identity.supplier, &identity.product)? {
            if rec.version >= offered {
                return Ok(Floor::InForce(rec));
            }
        }
        let rec = FloorRecord {
            supplier: identity.supplier.clone(),
            product: identity.product.clone(),
            version: offered,
            activated_from: module
                .file_name()
                .map(|s| s.to_string_lossy().to_string())
                .unwrap_or_else(|| module.display().to_string()),
        };
        self.write(&rec)?;
        Ok(Floor::InForce(rec))
    }

    fn write(&self, rec: &FloorRecord) -> Result<(), String> {
        let path = self.path_for(&rec.supplier, &rec.product);
        let parent = path.parent().expect("a floor path has a parent");
        let io = |what: &str, e: std::io::Error| {
            ec::PLUGIN_FLOOR_UNREADABLE.tag(format!(
                "{}: could not {what} the anti-rollback floor for {}/{}: {e}",
                path.display(),
                rec.supplier,
                rec.product
            ))
        };
        std::fs::create_dir_all(parent).map_err(|e| io("create the directory for", e))?;
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        let staging = parent.join(format!(
            ".{}.floor.tmp-{}-{nanos}",
            sanitize_seg(&rec.product),
            std::process::id()
        ));
        {
            use std::io::Write;
            let mut f = std::fs::File::create(&staging).map_err(|e| io("stage", e))?;
            f.write_all(format_record(rec).as_bytes())
                .map_err(|e| io("write", e))?;
            f.sync_all().map_err(|e| io("sync", e))?;
        }
        if let Err(e) = std::fs::rename(&staging, &path) {
            let _ = std::fs::remove_file(&staging);
            return Err(io("rename into place", e));
        }
        // The rename is durable only once the directory entry is.
        if let Ok(d) = std::fs::File::open(parent) {
            let _ = d.sync_all();
        }
        Ok(())
    }
}

fn format_record(rec: &FloorRecord) -> String {
    format!(
        "{MAGIC}\nsupplier={}\nproduct={}\nversion={}\nactivated_from={}\n",
        rec.supplier, rec.product, rec.version, rec.activated_from
    )
}

fn parse_record(text: &str) -> Result<FloorRecord, String> {
    let mut lines = text.lines();
    match lines.next() {
        Some(l) if l.trim() == MAGIC => {}
        Some(l) => return Err(format!("first line is '{l}', expected '{MAGIC}'")),
        None => return Err("the file is empty".to_string()),
    }
    let (mut supplier, mut product, mut version, mut from) = (None, None, None, None);
    for l in lines {
        let l = l.trim();
        if l.is_empty() {
            continue;
        }
        let Some((k, v)) = l.split_once('=') else {
            return Err(format!("line '{l}' is not key=value"));
        };
        match k {
            "supplier" => supplier = Some(v.to_string()),
            "product" => product = Some(v.to_string()),
            "version" => version = Some(Version::parse(v)?),
            "activated_from" => from = Some(v.to_string()),
            other => return Err(format!("unknown key '{other}'")),
        }
    }
    Ok(FloorRecord {
        supplier: supplier.ok_or("no supplier= line")?,
        product: product.ok_or("no product= line")?,
        version: version.ok_or("no version= line")?,
        activated_from: from.unwrap_or_default(),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn scratch(name: &str) -> PathBuf {
        let p = std::env::temp_dir().join(format!(
            "holger-plugin-floor-{name}-{}-{:?}",
            std::process::id(),
            std::thread::current().id()
        ));
        let _ = std::fs::remove_dir_all(&p);
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    fn id(v: &str) -> ModuleIdentity {
        ModuleIdentity::new("acme", "widget", v)
    }

    #[test]
    fn versions_order_numerically_not_lexically() {
        assert!(Version::parse("1.10.0").unwrap() > Version::parse("1.9.0").unwrap());
        assert!(Version::parse("2.0.0").unwrap() > Version::parse("1.99.99").unwrap());
        assert!(Version::parse("1.2").is_err());
        assert!(Version::parse("1.2.3.4").is_err());
        assert!(Version::parse("1.2.x").is_err());
        assert_eq!(Version::parse(" 3.4.5 ").unwrap().to_string(), "3.4.5");
    }

    #[test]
    fn absent_then_first_activation_sets_the_floor_and_says_so() {
        let store = FloorStore::at(scratch("first"));
        assert_eq!(store.read("acme", "widget").unwrap(), Floor::Absent);
        let m = Path::new("widget.wasm");
        let adm = store.admit(m, Some(&id("1.2.0"))).unwrap();
        assert!(matches!(adm, Admission::FirstActivation { .. }), "{adm:?}");
        assert!(
            adm.to_string().contains("first activation sets the floor"),
            "{adm}"
        );
        let floor = store.activate(m, &id("1.2.0")).unwrap();
        assert_eq!(floor.record().unwrap().version, Version(1, 2, 0));
        assert!(store.path_for("acme", "widget").is_file());
    }

    #[test]
    fn a_lower_version_is_refused_by_name_and_a_higher_one_raises() {
        let store = FloorStore::at(scratch("lower"));
        let m = Path::new("widget.wasm");
        store.activate(m, &id("1.2.0")).unwrap();
        let err = store
            .admit(m, Some(&id("1.1.9")))
            .expect_err("a rollback was admitted");
        assert!(err.contains(ec::PLUGIN_BELOW_FLOOR.code), "{err}");
        for word in ["acme", "widget", "1.1.9", "1.2.0"] {
            assert!(
                err.contains(word),
                "the refusal does not name {word}: {err}"
            );
        }
        // Same version: at the floor, no raise. Higher: raises.
        assert!(matches!(
            store.admit(m, Some(&id("1.2.0"))).unwrap(),
            Admission::AtOrAbove { raises: false, .. }
        ));
        assert!(matches!(
            store.admit(m, Some(&id("1.3.0"))).unwrap(),
            Admission::AtOrAbove { raises: true, .. }
        ));
        store.activate(m, &id("1.3.0")).unwrap();
        assert_eq!(
            store
                .read("acme", "widget")
                .unwrap()
                .record()
                .unwrap()
                .version,
            Version(1, 3, 0)
        );
        // Activating an older one afterwards never LOWERS the floor.
        store.activate(m, &id("1.0.0")).unwrap();
        assert_eq!(
            store
                .read("acme", "widget")
                .unwrap()
                .record()
                .unwrap()
                .version,
            Version(1, 3, 0)
        );
    }

    #[test]
    fn a_corrupt_floor_is_a_refusal_not_absent() {
        let store = FloorStore::at(scratch("corrupt"));
        let path = store.path_for("acme", "widget");
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(&path, "not a floor file at all").unwrap();
        let err = store
            .read("acme", "widget")
            .expect_err("garbage read as a floor");
        assert!(err.contains(ec::PLUGIN_FLOOR_UNREADABLE.code), "{err}");
        let err = store
            .admit(Path::new("w.wasm"), Some(&id("9.9.9")))
            .expect_err("loaded over a corrupt floor");
        assert!(err.contains(ec::PLUGIN_FLOOR_UNREADABLE.code), "{err}");
        // An empty file is corrupt too — not an erased floor.
        std::fs::write(&path, "").unwrap();
        assert!(store.read("acme", "widget").is_err());
    }

    #[test]
    fn no_identity_is_refused_when_a_floor_is_kept() {
        let store = FloorStore::at(scratch("noid"));
        let err = store
            .admit(Path::new("anon.wasm"), None)
            .expect_err("an anonymous module was placed");
        assert!(err.contains(ec::PLUGIN_IDENTITY_MISSING.code), "{err}");
        let err = store
            .admit(Path::new("anon.wasm"), Some(&id("latest")))
            .expect_err("an unorderable version was placed");
        assert!(err.contains(ec::PLUGIN_IDENTITY_MISSING.code), "{err}");
    }

    #[test]
    fn lanes_are_per_supplier_and_product() {
        let store = FloorStore::at(scratch("lanes"));
        let m = Path::new("x.wasm");
        store
            .activate(m, &ModuleIdentity::new("acme", "alpha", "2.0.0"))
            .unwrap();
        store
            .activate(m, &ModuleIdentity::new("acme", "beta", "1.0.0"))
            .unwrap();
        store
            .activate(m, &ModuleIdentity::new("other", "alpha", "0.1.0"))
            .unwrap();
        assert!(store
            .admit(m, Some(&ModuleIdentity::new("acme", "beta", "1.5.0")))
            .is_ok());
        assert!(store
            .admit(m, Some(&ModuleIdentity::new("acme", "alpha", "1.9.0")))
            .is_err());
        assert!(store
            .admit(m, Some(&ModuleIdentity::new("other", "alpha", "0.2.0")))
            .is_ok());
    }

    #[test]
    fn the_policy_default_is_named_not_kept() {
        assert_eq!(FloorPolicy::default(), FloorPolicy::NotKept);
        assert!(FloorPolicy::default().store().is_none());
        assert!(FloorPolicy::kept("/x").store().is_some());
    }
}