//! Marker-guarded ownership for the files this package writes under `/etc`. //! //! Both `sysctl_tuning` and `swap` drop files into `/etc/sysctl.d`, and they used to do it //! differently: one refused to touch a file without its marker, the other overwrote //! whatever was at its path and never cleaned up when disabled. The rules belong in one //! place, because the failure mode of getting them wrong is silently clobbering an //! administrator's configuration. //! //! Invariants: //! //! - a read that fails is never treated as an absent file; //! - a file whose first line does not carry our marker is never written or removed; //! - writes are atomic: temp sibling, `sync_all`, rename, directory flush. use std::{fs, io::ErrorKind, path::PathBuf}; use crate::atomic; /// Debian default for files under `/etc/sysctl.d` and `/etc/modules-load.d`. const MANAGED_MODE: u32 = 0o644; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Ownership { Absent, Managed, Foreign, } #[derive(Debug, Clone)] pub struct ManagedFile { path: PathBuf, marker: String, } impl ManagedFile { pub fn new(path: impl Into, marker: impl Into) -> Self { Self { path: path.into(), marker: marker.into(), } } pub fn path(&self) -> &std::path::Path { &self.path } /// `Ok(None)` only when the file is genuinely absent. Any other failure propagates. pub fn read(&self) -> anyhow::Result> { match fs::read_to_string(&self.path) { Ok(v) => Ok(Some(v)), Err(e) if e.kind() == ErrorKind::NotFound => Ok(None), Err(e) => Err(anyhow::anyhow!( "failed to read {}: {e}", self.path.display() )), } } /// Ownership is decided by an **exact** first line, not a substring. /// /// `contains` accepted a file whose first line merely embedded the marker, so /// `# NOT Managed by awg2-harness sysctl_tuning` counted as ours and became eligible /// for overwrite and removal. pub fn ownership(&self) -> anyhow::Result { let Some(content) = self.read()? else { return Ok(Ownership::Absent); }; let first = content.lines().next().unwrap_or_default(); if first.trim_end() == self.marker.trim_end() { Ok(Ownership::Managed) } else { Ok(Ownership::Foreign) } } /// Writes `content`, refusing if the path holds a file we do not own. /// /// Returns whether anything changed, so callers can stay quiet when reconciling an /// already-correct host. pub fn write(&self, content: &str) -> anyhow::Result { match self.ownership()? { Ownership::Foreign => { return Err(anyhow::anyhow!( "refusing to overwrite unmanaged file {}: it does not carry the marker {:?}", self.path.display(), self.marker )); } Ownership::Managed => { if self.read()?.as_deref() == Some(content) { return Ok(false); } } Ownership::Absent => {} } atomic::write_atomic_no_follow(&self.path, content, MANAGED_MODE)?; Ok(true) } /// Removes the file if we own it. Absent is success; foreign is an error. pub fn remove(&self) -> anyhow::Result { match self.ownership()? { Ownership::Absent => Ok(false), Ownership::Foreign => Err(anyhow::anyhow!( "refusing to remove unmanaged file {}: it does not carry the marker {:?}", self.path.display(), self.marker )), Ownership::Managed => { fs::remove_file(&self.path).map_err(|e| { anyhow::anyhow!("failed to remove {}: {e}", self.path.display()) })?; Ok(true) } } } } #[cfg(test)] mod tests { use super::*; use std::path::Path; const MARKER: &str = "# Managed by awg2-harness test"; fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("awg2_managed_{}_{}", name, std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("scratch dir"); dir } fn managed(dir: &Path) -> ManagedFile { ManagedFile::new(dir.join("conf"), MARKER) } #[test] fn absent_file_reads_as_none() { let dir = scratch("absent"); let f = managed(&dir); assert_eq!(f.read().expect("read"), None); assert_eq!(f.ownership().expect("ownership"), Ownership::Absent); } /// A path that exists but cannot be read as text must not look like an absent file. #[test] fn unreadable_file_is_an_error() { let dir = scratch("unreadable"); let f = ManagedFile::new(dir.clone(), MARKER); assert!(f.read().is_err()); } #[test] fn write_then_ownership_is_managed() { let dir = scratch("write"); let f = managed(&dir); assert!(f.write(&format!("{MARKER}\nkey = 1\n")).expect("write")); assert_eq!(f.ownership().expect("ownership"), Ownership::Managed); } #[test] fn rewriting_identical_content_reports_no_change() { let dir = scratch("idempotent"); let f = managed(&dir); let content = format!("{MARKER}\nkey = 1\n"); assert!(f.write(&content).expect("first")); assert!(!f.write(&content).expect("second")); } /// The guard that the swap module previously lacked entirely. #[test] fn foreign_file_is_never_overwritten() { let dir = scratch("foreign_write"); let f = managed(&dir); fs::write(dir.join("conf"), "# someone elses config\nkey = 9\n").expect("seed"); assert_eq!(f.ownership().expect("ownership"), Ownership::Foreign); let err = f .write(&format!("{MARKER}\nkey = 1\n")) .expect_err("refuse"); assert!(err.to_string().contains("refusing to overwrite unmanaged")); assert_eq!( fs::read_to_string(dir.join("conf")).expect("intact"), "# someone elses config\nkey = 9\n" ); } #[test] fn foreign_file_is_never_removed() { let dir = scratch("foreign_remove"); let f = managed(&dir); fs::write(dir.join("conf"), "# someone elses config\n").expect("seed"); let err = f.remove().expect_err("refuse"); assert!(err.to_string().contains("refusing to remove unmanaged")); assert!(dir.join("conf").exists()); } #[test] fn remove_is_idempotent_for_managed_and_absent() { let dir = scratch("remove"); let f = managed(&dir); f.write(&format!("{MARKER}\nkey = 1\n")).expect("write"); assert!(f.remove().expect("first remove")); assert!(!f.remove().expect("second remove")); } #[test] fn stale_temp_does_not_block_write() { let dir = scratch("stale_temp"); let f = managed(&dir); fs::write(dir.join("conf.awg2-harness.tmp"), "garbage").expect("stale"); assert!(f.write(&format!("{MARKER}\nkey = 1\n")).expect("write")); assert!(!dir.join("conf.awg2-harness.tmp").exists()); } /// `contains` used to accept any first line that embedded the marker, so a file /// explicitly declaring it is *not* managed became eligible for overwrite. #[test] fn marker_must_match_the_whole_first_line() { let dir = scratch("marker_exact"); let f = managed(&dir); for first_line in [ format!("# NOT {}", MARKER.trim_start_matches("# ")), format!("{MARKER} but actually hand written"), format!("prefix {MARKER}"), ] { fs::write(dir.join("conf"), format!("{first_line}\nkey = 1\n")).expect("seed"); assert_eq!( f.ownership().expect("ownership"), Ownership::Foreign, "must not claim ownership of first line {first_line:?}" ); } } /// Trailing whitespace on the marker line is not a reason to disown our own file. #[test] fn marker_comparison_ignores_trailing_whitespace() { let dir = scratch("marker_trailing"); let f = managed(&dir); fs::write(dir.join("conf"), format!("{MARKER} \nkey = 1\n")).expect("seed"); assert_eq!(f.ownership().expect("ownership"), Ownership::Managed); } }