use std::path::Path;
use serde::{Deserialize, Serialize};
pub const PAIR_PROOF_PATH: &str = "/var/lib/ados/wfb-pair-proof.json";
pub const PAIR_PROOF_SIDECAR_VERSION: u16 = 1;
pub const PAIR_PROOF_MODE: u32 = 0o644;
pub const PROOF_RESTAMP_INTERVAL_S: u64 = 3600;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProofStamp {
First,
Refreshed,
Unchanged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairProof {
#[serde(default)]
pub version: u16,
pub role: String,
pub key_fingerprint: String,
#[serde(default)]
pub proven_at: Option<u64>,
#[serde(default)]
pub rearm_episodes: u32,
#[serde(default)]
pub last_rearm_at: Option<u64>,
#[serde(default)]
pub force_rearm: bool,
}
impl PairProof {
pub fn fresh(role: &str, fingerprint: &str) -> Self {
Self {
version: PAIR_PROOF_SIDECAR_VERSION,
role: role.to_string(),
key_fingerprint: fingerprint.to_string(),
proven_at: None,
rearm_episodes: 0,
last_rearm_at: None,
force_rearm: false,
}
}
pub fn is_proven(&self) -> bool {
self.proven_at.is_some()
}
pub fn matches(&self, role: &str, fingerprint: &str) -> bool {
self.role == role && self.key_fingerprint == fingerprint
}
pub fn mark_proven(&mut self, now_unix: u64) -> ProofStamp {
match self.proven_at {
None => {
self.proven_at = Some(now_unix);
ProofStamp::First
}
Some(at) if now_unix.saturating_sub(at) >= PROOF_RESTAMP_INTERVAL_S => {
self.proven_at = Some(now_unix);
ProofStamp::Refreshed
}
Some(_) => ProofStamp::Unchanged,
}
}
pub fn record_rearm(&mut self, now_unix: u64) {
self.rearm_episodes = self.rearm_episodes.saturating_add(1);
self.last_rearm_at = Some(now_unix);
self.force_rearm = false;
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut body = serde_json::to_vec_pretty(self).unwrap_or_default();
body.push(b'\n');
body
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedProof {
pub proof: PairProof,
pub reset: bool,
}
pub fn read_pair_proof_from(path: &Path) -> Option<PairProof> {
let text = std::fs::read_to_string(path).ok()?;
let proof: PairProof = serde_json::from_str(&text).ok()?;
crate::sidecar::check_sidecar_version(
"wfb-pair-proof",
proof.version,
PAIR_PROOF_SIDECAR_VERSION,
);
Some(proof)
}
pub fn load_for(path: &Path, role: &str, fingerprint: &str) -> LoadedProof {
match read_pair_proof_from(path) {
Some(stored) if stored.matches(role, fingerprint) => LoadedProof {
proof: stored,
reset: false,
},
Some(_) => LoadedProof {
proof: PairProof::fresh(role, fingerprint),
reset: true,
},
None => LoadedProof {
proof: PairProof::fresh(role, fingerprint),
reset: false,
},
}
}
pub fn write_pair_proof_to(path: &Path, proof: &PairProof) -> std::io::Result<()> {
crate::sidecar::write_atomic(path, &proof.to_bytes(), PAIR_PROOF_MODE)
}
pub const AUTO_PAIR_RETRY_PATH: &str = "/run/ados/auto-pair-retry.request";
pub fn request_local_retry_at(path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, b"retry-local\n")
}
pub fn take_local_retry_request_at(path: &Path) -> bool {
std::fs::remove_file(path).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sidecar_version_matches_the_registry() {
assert_eq!(
PAIR_PROOF_SIDECAR_VERSION,
crate::contracts::sidecar_version("wfb-pair-proof").unwrap()
);
}
#[test]
fn the_record_lives_on_persistent_storage_not_tmpfs() {
assert!(
PAIR_PROOF_PATH.starts_with("/var/lib/"),
"the proof record must survive a reboot, got {PAIR_PROOF_PATH}"
);
}
#[test]
fn a_fresh_record_is_never_proven_with_a_zero_budget() {
let p = PairProof::fresh("drone", "aabbccdd11223344");
assert!(!p.is_proven());
assert_eq!(p.rearm_episodes, 0);
assert_eq!(p.last_rearm_at, None);
assert!(!p.force_rearm);
}
#[test]
fn first_proof_stamps_and_later_proofs_are_rate_limited() {
let mut p = PairProof::fresh("drone", "ff00");
assert_eq!(p.mark_proven(1_000), ProofStamp::First);
assert!(p.is_proven());
assert_eq!(p.mark_proven(1_001), ProofStamp::Unchanged);
assert_eq!(
p.mark_proven(1_000 + PROOF_RESTAMP_INTERVAL_S - 1),
ProofStamp::Unchanged
);
assert_eq!(
p.mark_proven(1_000 + PROOF_RESTAMP_INTERVAL_S),
ProofStamp::Refreshed
);
assert_eq!(p.proven_at, Some(1_000 + PROOF_RESTAMP_INTERVAL_S));
}
#[test]
fn recording_a_rearm_spends_budget_stamps_the_clock_and_clears_force() {
let mut p = PairProof::fresh("gs", "1234");
p.force_rearm = true;
p.record_rearm(500);
assert_eq!(p.rearm_episodes, 1);
assert_eq!(p.last_rearm_at, Some(500));
assert!(!p.force_rearm, "force is a one-shot");
p.record_rearm(900);
assert_eq!(p.rearm_episodes, 2);
assert_eq!(p.last_rearm_at, Some(900));
}
#[test]
fn a_record_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wfb-pair-proof.json");
let mut p = PairProof::fresh("drone", "abc123");
p.mark_proven(42);
p.record_rearm(7);
write_pair_proof_to(&path, &p).unwrap();
let stray = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().contains(".tmp"));
assert!(!stray);
assert_eq!(read_pair_proof_from(&path).unwrap(), p);
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, PAIR_PROOF_MODE);
}
#[test]
fn a_matching_record_loads_intact() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("proof.json");
let mut p = PairProof::fresh("drone", "same");
p.mark_proven(10);
p.record_rearm(11);
write_pair_proof_to(&path, &p).unwrap();
let loaded = load_for(&path, "drone", "same");
assert!(!loaded.reset);
assert!(loaded.proof.is_proven());
assert_eq!(loaded.proof.rearm_episodes, 1);
}
#[test]
fn a_new_fingerprint_resets_the_whole_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("proof.json");
let mut old = PairProof::fresh("drone", "old-key");
old.mark_proven(10);
old.record_rearm(11);
old.record_rearm(12);
write_pair_proof_to(&path, &old).unwrap();
let loaded = load_for(&path, "drone", "new-key");
assert!(loaded.reset, "a discarded record is worth announcing");
assert!(!loaded.proof.is_proven());
assert_eq!(loaded.proof.rearm_episodes, 0);
assert_eq!(loaded.proof.last_rearm_at, None);
assert_eq!(loaded.proof.key_fingerprint, "new-key");
}
#[test]
fn a_role_change_resets_the_record() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("proof.json");
let mut old = PairProof::fresh("drone", "k");
old.mark_proven(10);
write_pair_proof_to(&path, &old).unwrap();
let loaded = load_for(&path, "gs", "k");
assert!(loaded.reset);
assert!(!loaded.proof.is_proven());
}
#[test]
fn an_absent_or_unparseable_record_reads_as_never_proven_without_announcing() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.json");
let loaded = load_for(&missing, "drone", "k");
assert!(!loaded.proof.is_proven());
assert_eq!(loaded.proof.rearm_episodes, 0);
assert!(!loaded.reset, "nothing known was discarded");
let junk = dir.path().join("junk.json");
std::fs::write(&junk, b"{ not json at all").unwrap();
assert!(read_pair_proof_from(&junk).is_none());
let loaded = load_for(&junk, "drone", "k");
assert!(!loaded.proof.is_proven());
assert_eq!(loaded.proof.rearm_episodes, 0);
assert!(!loaded.reset);
write_pair_proof_to(&junk, &loaded.proof).unwrap();
assert!(read_pair_proof_from(&junk).is_some());
}
#[test]
fn a_local_retry_request_is_consumed_exactly_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("run").join("auto-pair-retry.request");
assert!(!take_local_retry_request_at(&path));
request_local_retry_at(&path).unwrap();
request_local_retry_at(&path).unwrap();
assert!(take_local_retry_request_at(&path));
assert!(!take_local_retry_request_at(&path));
}
}