116b1ea91a
Four defects from the follow-up audit. 1. Legacy migration swallowed every error. drop_legacy_managed_file was `let _ = file.remove(); Ok(())`, which tolerated not only the expected "that path is someone else's now" but also PermissionDenied, read errors and failed unlinks. If /etc/sysctl.d/99-awg2-harness.conf survived, it would keep winning over the new 90- file - it sorts later - while the reconcile reported success, so vm.swappiness would silently stay at the old value. The three cases are now distinguished: Absent is fine, Foreign warns and continues, Managed removes and any failure propagates. 2. The disable branches ran outside the lock. Both ensure_swap and ensure_sysctl_tuning checked `enabled` before acquiring their lock, so the cleanup path - which deletes managed files - was unsynchronised. A manual CLI invocation and the daemon's periodic reconcile could write and delete the same file concurrently. The lock is now taken first in both. 3. A failed teardown left foreign swaps half-disabled. disable_foreign_swaps aborted the loop on the first failure, so a host with two foreign swaps could end with the first one off and the second still up - a state nobody asked for and the error message did not describe. It now tracks what it disabled and re-enables it, in reverse order and with the original priority, before returning the error. The restore is best-effort and warns per entry rather than replacing the reported failure with a rollback failure. 4. Resizing dropped swap for the whole allocation. A live swapfile cannot be resized in place, and the previous code did swapoff, remove, fallocate/dd, mkswap, swapon. On a host with no other swap that is a swapless window spanning the slowest and most failure-prone part of the operation - on a machine that was given swap precisely because memory is tight, with no way back if any step failed. The replacement is now built and activated at a staging sibling while the old swap is still serving, and only then is the old file released. The remaining gap is swapoff/rename/swapon, which allocates nothing. A failure while staging removes the staging file and leaves the original swap untouched; a failure at the final rename says explicitly that the file is in place and can be activated with swapon. Related, same function: changing only swap.priority used to fall through to the full recreate path, destroying and rebuilding a correct multi-gigabyte file to change one integer. Size and priority are now separate decisions - a priority-only change is a reactivation. The reconciliation is now decided by an explicit match over (size correct, currently active) rather than a single `geometry_ok` flag, which is what hid the priority case. Three tests added around the new decision surface: the priority-only case is not a resize, the staging path is a sibling of the swapfile, and the managed swap is never part of the foreign teardown set. cargo test --workspace: 179 + 4 passed. clippy -D warnings clean.
247 lines
8.5 KiB
Rust
247 lines
8.5 KiB
Rust
//! 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<PathBuf>, marker: impl Into<String>) -> 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<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()
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// 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<Ownership> {
|
|
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<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 => {}
|
|
}
|
|
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<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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
}
|