refactor: one ownership model for every file written under /etc [P1]
Two modules wrote into /etc/sysctl.d and did it differently. sysctl_tuning refused to touch a file whose first line lacked its marker and cleaned up on disable. swap wrote /etc/sysctl.d/99-awg2-harness.conf with a different marker, no ownership check at all - an administrator's file at that path was silently clobbered - and never removed it when the module was turned off, so vm.swappiness stayed pinned forever. New module managed_file.rs holds the rules once: - a read that fails is never an absent file; - ownership is Absent / Managed / Foreign, decided by the marker on the first line; - write refuses Foreign, skips when the content already matches, and is otherwise atomic: temp sibling created with create_new, sync_all, mode 0644, rename, directory flush; - remove refuses Foreign and treats Absent as success. Both modules now go through it. sysctl_tuning loses its private write_atomic, ensure_managed_or_absent and remove_managed_sysctl_file_at. The swappiness file moves to /etc/sysctl.d/90-awg2-harness-swap.conf with the marker "# Managed by awg2-harness swap", matching the 90- prefix every other managed file uses. The old path is removed on first run of the new version, but only when it still carries the previous "# Managed by awg2-harness" marker; a foreign file there is left alone and does not fail the migration, since the current file lives elsewhere. Disable semantics are now explicit: swap.enabled = false removes the managed file and does nothing else. Runtime vm.swappiness is deliberately not reset - picking a value would be inventing a policy - and active swap is not deactivated, because merely turning a feature off should not trigger a destructive action. Previously ensure_swap returned early and left the persisted file behind. Also replaced a vacuous test: swappiness_conf_render_is_stable asserted that a string literal equals a format! of the same literal, touching no production code at all. It now exercises render_swappiness_conf, and a companion test proves the rendered file is recognised as managed by the same marker check that guards overwriting it - otherwise every reconcile would refuse to touch its own output. Twelve tests: the three ownership states, refusal to overwrite and refusal to remove a foreign file with content asserted intact, idempotent write, stale temp handling, an unreadable path reported as an error, plus legacy migration in all three cases - ours, foreign, absent. cargo test --workspace: 148 + 4 passed. clippy -D warnings clean.
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
//! 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, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[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<PathBuf>, marker: impl Into<String>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
marker: marker.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ok(None)` only when the file is genuinely absent. Any other failure propagates.
|
||||
pub fn read(&self) -> anyhow::Result<Option<String>> {
|
||||
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()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ownership(&self) -> anyhow::Result<Ownership> {
|
||||
let Some(content) = self.read()? else {
|
||||
return Ok(Ownership::Absent);
|
||||
};
|
||||
let first = content.lines().next().unwrap_or_default();
|
||||
if first.contains(&self.marker) {
|
||||
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<bool> {
|
||||
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 => {}
|
||||
}
|
||||
write_atomic(&self.path, content)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Removes the file if we own it. Absent is success; foreign is an error.
|
||||
pub fn remove(&self) -> anyhow::Result<bool> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, content: &str) -> anyhow::Result<()> {
|
||||
let mut temp_name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid managed file path: {}", path.display()))?
|
||||
.to_os_string();
|
||||
temp_name.push(".awg2-harness.tmp");
|
||||
let temp = path.with_file_name(temp_name);
|
||||
|
||||
match fs::remove_file(&temp) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to clear stale {}: {e}",
|
||||
temp.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&temp)
|
||||
.map_err(|e| anyhow::anyhow!("failed to create {}: {e}", temp.display()))?;
|
||||
let write_result = f
|
||||
.write_all(content.as_bytes())
|
||||
.and_then(|()| f.sync_all())
|
||||
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", temp.display()));
|
||||
drop(f);
|
||||
if let Err(e) = write_result {
|
||||
let _ = fs::remove_file(&temp);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = fs::set_permissions(&temp, fs::Permissions::from_mode(0o644)) {
|
||||
let _ = fs::remove_file(&temp);
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to set permissions on {}: {e}",
|
||||
temp.display()
|
||||
));
|
||||
}
|
||||
|
||||
fs::rename(&temp, path).map_err(|e| {
|
||||
let _ = fs::remove_file(&temp);
|
||||
anyhow::anyhow!("failed to install {}: {e}", path.display())
|
||||
})?;
|
||||
|
||||
sync_parent_dir(path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_parent_dir(target: &Path) {
|
||||
if let Some(dir) = target.parent()
|
||||
&& let Ok(f) = fs::File::open(dir)
|
||||
{
|
||||
let _ = f.sync_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_parent_dir(_target: &Path) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user