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