use std::{ fs, path::{Path, PathBuf}, process::Command, time::Duration, }; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use anyhow::Context; use crate::{ config::Config, fstab::{self, FSTAB_PATH, FstabTransaction, escape_field}, lock::FileLock, managed_file::{ManagedFile, Ownership}, }; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ActiveSwap { pub name: PathBuf, pub kind: String, pub size_bytes: u64, pub priority: i32, } #[derive(Debug, Clone)] struct DesiredSwap { path: PathBuf, size_bytes: u64, priority: i32, swappiness: u8, } #[derive(Debug, Clone)] pub struct SwapStatus { pub enabled: bool, pub desired_path: PathBuf, pub active_entries: Vec, pub desired_active: bool, pub exclusive: bool, pub fstab_swap_entries: Vec, pub fstab_exclusive: bool, pub current_swappiness: Option, pub persisted_swappiness: Option, pub drift_reason: Option, } /// Root-only directory holding this package's persistent state. pub const STATE_DIR: &str = "/var/lib/awg2-harness"; const STATE_DIR_PREFIX: &str = "/var/lib/awg2-harness/"; /// Historical location, kept so existing deployments do not need a config change. const DEFAULT_SWAP_PATH: &str = "/swapfile"; /// Records which swapfile we created, by identity rather than by location. const SWAP_OWNERSHIP_PATH: &str = "/var/lib/awg2-harness/swap-ownership.conf"; const SWAP_OWNERSHIP_MARKER: &str = "# Managed by awg2-harness swap-ownership"; const SWAP_OWNERSHIP_VERSION: u32 = 1; /// Sidecar written by earlier versions next to the swapfile. Only ever removed, and only /// when it carries the marker that version wrote. const LEGACY_OWNERSHIP_SUFFIX: &str = ".awg2-harness-owned"; const LEGACY_OWNERSHIP_MARKER: &str = "# Managed by awg2-harness swap"; /// Replacement being built while the current swapfile is still active. const STAGING_SUFFIX: &str = ".awg2-harness.new"; /// The outgoing swapfile, kept until the replacement is confirmed active. const PREVIOUS_SUFFIX: &str = ".awg2-harness.previous"; /// Identity of a file on disk, independent of its name. /// /// Path alone is not identity: a file can be deleted and a different one created at the /// same path. `(device, inode)` is what actually distinguishes the swapfile we made from /// whatever now occupies its path. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct FileIdentity { device: u64, inode: u64, } /// What we recorded when we created the managed swapfile. #[derive(Debug, Clone, PartialEq, Eq)] struct SwapOwnershipRecord { version: u32, path: PathBuf, identity: FileIdentity, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SwapfileOwnership { /// Nothing at the path: safe to create. Missing, /// Ours, confirmed by identity, and already the requested size. ManagedValid, /// Ours, confirmed by identity, but the size differs: safe to recreate. ManagedWrongSize, /// A file exists and we have no record of creating it. Never touched. Foreign, /// We have a record, but the file at the path is not the one we created - it was /// replaced behind our back. Refused exactly like `Foreign`; kept separate so the /// error can say which situation the operator is in. StaleOwnership, } impl SwapfileOwnership { fn is_managed(self) -> bool { matches!(self, Self::ManagedValid | Self::ManagedWrongSize) } } /// Persisted `vm.swappiness`, owned by the swap module. /// /// Lives at `90-` like every other file this package manages. It used to be written to /// `/etc/sysctl.d/99-awg2-harness.conf` with a different marker, no ownership check before /// overwriting, and no cleanup on disable. const SWAPPINESS_CONF_PATH: &str = "/etc/sysctl.d/90-awg2-harness-swap.conf"; const SWAPPINESS_MARKER: &str = "# Managed by awg2-harness swap"; /// Written by versions before the unified managed-file handling. const LEGACY_SWAPPINESS_CONF_PATH: &str = "/etc/sysctl.d/99-awg2-harness.conf"; const LEGACY_SWAPPINESS_MARKER: &str = "# Managed by awg2-harness"; fn swappiness_file() -> ManagedFile { ManagedFile::new(SWAPPINESS_CONF_PATH, SWAPPINESS_MARKER) } fn legacy_swappiness_file() -> ManagedFile { ManagedFile::new(LEGACY_SWAPPINESS_CONF_PATH, LEGACY_SWAPPINESS_MARKER) } fn render_swappiness_conf(v: u8) -> String { format!("{SWAPPINESS_MARKER}\n# Source: /etc/awg2-harness/config.toml\nvm.swappiness = {v}\n") } /// Drops the pre-rename file, but only when it still carries the old marker. /// /// A file an administrator put at that path is left alone, which is why the removal goes /// through the same ownership check as everything else. fn migrate_legacy_swappiness_file() -> anyhow::Result<()> { drop_legacy_managed_file(&legacy_swappiness_file()) } /// Removes a file left by an older version. /// /// Only a foreign file is tolerated - that path belongs to someone else now and the /// current file lives elsewhere, so there is nothing to migrate. Everything else is /// reported: swallowing `PermissionDenied` or a failed unlink would leave /// `/etc/sysctl.d/99-awg2-harness.conf` in place, and it sorts *after* the new `90-` file, /// so the stale value would keep winning while the reconcile reported success. fn drop_legacy_managed_file(file: &ManagedFile) -> anyhow::Result<()> { match file.ownership()? { Ownership::Absent => Ok(()), Ownership::Foreign => { tracing::warn!( target: "swap", "legacy path {} holds a file we do not own; leaving it alone", file.path().display() ); Ok(()) } Ownership::Managed => file.remove().map(|_| ()), } } /// Brings host swap to the desired state as a transaction. /// /// Ordering is the point: the managed swap is created, activated and verified *before* /// any foreign swap is disabled. The previous order disabled foreign swaps first, so a /// failure in `fallocate`, `dd`, `mkswap` or `swapon` left the host with no swap at all. pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> { // Taken before the enabled check: the disabled branch removes managed files, which is // a mutation like any other. Leaving it outside the lock let a manual CLI run and the // daemon's reconcile write and delete the same file at the same time. let _lock = FileLock::acquire_timeout("/run/awg2-harness/swap.lock", Duration::from_secs(30))?; if !cfg.swap.enabled { return remove_managed_swap_state(); } // 1. Preflight. The state directory is created and verified first: on a clean host // /var/lib/awg2-harness does not exist yet, and the path check below requires every // parent to be present. ensure_state_dir()?; let desired = DesiredSwap::from_config(cfg)?; assert_swap_path_is_safe(&desired.path)?; let active_before = read_active_swaps()?; // 2-3. Prepare and activate the managed swap. Activation belongs to this step so that // every transition can roll back while it still owns both the old and the new file. ensure_desired_swapfile(&desired, &active_before)?; // 4. Confirm it is really live before touching anything else. let active_after = read_active_swaps()?; if !active_after.iter().any(|s| s.name == desired.path) { anyhow::bail!( "managed swap {} did not become active; foreign swap left untouched", desired.path.display() ); } // 5. Only now is it safe to take the others down. disable_foreign_swaps(&desired, &active_after)?; // 6. Persist and verify. rewrite_fstab_exclusive(&desired)?; apply_swappiness(desired.swappiness)?; verify_exclusive_swap_state(&desired)?; Ok(()) } /// Undoes the persistent part of the module when `swap.enabled = false`. /// /// Only the managed file is removed. The runtime `vm.swappiness` is deliberately left as /// it is: forcing an arbitrary value would be inventing a policy the operator never asked /// for. The kernel default applies again after reboot, and `status` reports that the value /// is no longer managed. Active swap is not disabled either - turning it off would be a /// destructive action triggered by merely disabling a feature. fn remove_managed_swap_state() -> anyhow::Result<()> { migrate_legacy_swappiness_file()?; swappiness_file().remove()?; Ok(()) } /// Lexical validation of `swap.path`, with no filesystem access. /// /// Kept side-effect free so `validate-config` can run it on a host where the file does not /// exist yet. Operates on the string rather than [`Path`] because the target is always a /// Linux path, regardless of where validation runs. pub fn validate_swap_path_syntax(raw: &str) -> anyhow::Result<()> { if !raw.starts_with('/') { anyhow::bail!("swap.path must be absolute: {raw}"); } if raw.ends_with('/') { anyhow::bail!("swap.path must not be a directory path: {raw}"); } if let Some(bad) = raw .chars() .find(|c| c.is_whitespace() || c.is_control() || *c == '\\') { anyhow::bail!( "swap.path must not contain whitespace or control characters: {raw:?} contains {bad:?}" ); } for component in raw.split('/') { if component == "." || component == ".." { anyhow::bail!("swap.path must not contain '.' or '..' components: {raw}"); } } // Allow-list, not deny-list. A deny-list only rules out the paths someone thought of; // what actually matters is whether the parent directory can be written by anyone but // root. Between the preflight check and `fallocate`/`dd`/`mkswap` running, a // world-writable parent lets an unprivileged user swap the final component for a // symlink and redirect a root-owned operation - a TOCTOU that no amount of stat-ing // before use can close. Restricting the location to directories only root can write // removes the window instead of trying to detect it. if raw == DEFAULT_SWAP_PATH { return Ok(()); } if let Some(rest) = raw.strip_prefix(STATE_DIR_PREFIX) && !rest.is_empty() && !rest.contains('/') { return Ok(()); } anyhow::bail!( "swap.path must be {DEFAULT_SWAP_PATH} or a file directly under {STATE_DIR}; got {raw}. \ Both are root-owned locations; an arbitrary path can have a parent directory that \ another user may write, which would let them redirect the swapfile operations" ); } /// Filesystem-level checks performed just before the path is used. /// /// A symlink anywhere in the chain, not only at the final component, can redirect the /// whole operation at a file we never intended to touch. fn assert_swap_path_is_safe(path: &Path) -> anyhow::Result<()> { validate_swap_path_syntax(&path.to_string_lossy())?; assert_path_chain_is_safe(path) } /// Walks the parent chain and refuses any symlink or missing directory in it. /// /// Split from the syntax check so it can be exercised independently of the Linux path /// grammar. fn assert_path_chain_is_safe(path: &Path) -> anyhow::Result<()> { let mut walked = PathBuf::new(); let components: Vec<_> = path.components().collect(); for component in components.iter().take(components.len().saturating_sub(1)) { walked.push(component); match fs::symlink_metadata(&walked) { Ok(md) if md.file_type().is_symlink() => { anyhow::bail!( "swap.path traverses a symlink at {}; refusing to continue", walked.display() ); } Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => { anyhow::bail!( "swap.path parent directory does not exist: {}", walked.display() ); } Err(e) => { anyhow::bail!("failed to stat {}: {e}", walked.display()); } } } Ok(()) } fn ownership_file() -> ManagedFile { ManagedFile::new(SWAP_OWNERSHIP_PATH, SWAP_OWNERSHIP_MARKER) } fn render_ownership_record(rec: &SwapOwnershipRecord) -> String { format!( "{SWAP_OWNERSHIP_MARKER}\nversion = {}\npath = {}\ndevice = {}\ninode = {}\n", rec.version, rec.path.display(), rec.identity.device, rec.identity.inode ) } fn parse_ownership_record(raw: &str) -> anyhow::Result { let mut version = None; let mut path = None; let mut device = None; let mut inode = None; for line in raw.lines() { let t = line.trim(); if t.is_empty() || t.starts_with('#') { continue; } let Some((k, v)) = t.split_once('=') else { continue; }; let v = v.trim(); match k.trim() { "version" => version = v.parse::().ok(), "path" => path = Some(PathBuf::from(v)), "device" => device = v.parse::().ok(), "inode" => inode = v.parse::().ok(), _ => {} } } let (Some(version), Some(path), Some(device), Some(inode)) = (version, path, device, inode) else { anyhow::bail!("swap ownership record is incomplete or malformed"); }; if version != SWAP_OWNERSHIP_VERSION { anyhow::bail!("unsupported swap ownership record version: {version}"); } Ok(SwapOwnershipRecord { version, path, identity: FileIdentity { device, inode }, }) } /// Decides ownership from a recorded state and the file actually on disk. /// /// Pure on purpose: the interesting cases - a stale record pointing at a file someone /// else created, a record for a different path - are the ones that must never authorise a /// delete, and keeping the decision free of filesystem access makes them directly /// testable. fn classify_ownership( recorded: Option<&SwapOwnershipRecord>, actual_path: &Path, actual: Option<(FileIdentity, u64)>, desired_size: u64, ) -> SwapfileOwnership { let Some((identity, size)) = actual else { return SwapfileOwnership::Missing; }; let Some(rec) = recorded else { return SwapfileOwnership::Foreign; }; if rec.path != actual_path { // The record describes a different managed path; this file is not ours. return SwapfileOwnership::Foreign; } if rec.identity != identity { // Right path, wrong file: ours was replaced. Never a licence to delete. return SwapfileOwnership::StaleOwnership; } if size == desired_size { SwapfileOwnership::ManagedValid } else { SwapfileOwnership::ManagedWrongSize } } #[cfg(unix)] fn identity_of(md: &fs::Metadata) -> FileIdentity { use std::os::unix::fs::MetadataExt; FileIdentity { device: md.dev(), inode: md.ino(), } } /// Windows has no stable dev/inode pair through `Metadata`; this target exists only so the /// pure logic above stays testable off Linux. #[cfg(not(unix))] fn identity_of(_md: &fs::Metadata) -> FileIdentity { FileIdentity { device: 0, inode: 0, } } fn read_ownership_record() -> anyhow::Result> { let Some(raw) = ownership_file().read()? else { return Ok(None); }; parse_ownership_record(&raw).map(Some) } fn inspect_swapfile_ownership(path: &Path, desired_size: u64) -> anyhow::Result { let actual = match fs::symlink_metadata(path) { Ok(md) => { if md.file_type().is_symlink() { anyhow::bail!("swap.path is a symlink: {}", path.display()); } if !md.file_type().is_file() { anyhow::bail!("swap.path is not a regular file: {}", path.display()); } Some((identity_of(&md), md.len())) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => anyhow::bail!("failed to stat {}: {e}", path.display()), }; let recorded = read_ownership_record()?; Ok(classify_ownership( recorded.as_ref(), path, actual, desired_size, )) } /// Records the identity of the swapfile we just created. /// /// Written after the file exists, so the recorded `(device, inode)` describes the real /// file. Until this succeeds the swapfile is not considered managed. fn record_ownership(path: &Path) -> anyhow::Result<()> { ensure_state_dir()?; let md = fs::metadata(path) .map_err(|e| anyhow::anyhow!("failed to stat {} after create: {e}", path.display()))?; let rec = SwapOwnershipRecord { version: SWAP_OWNERSHIP_VERSION, path: path.to_path_buf(), identity: identity_of(&md), }; ownership_file().write(&render_ownership_record(&rec))?; Ok(()) } /// Creates the state directory if needed and asserts it is safe to keep state in. /// /// Run before `swap.path` is validated, for two reasons. On a clean host /// `/var/lib/awg2-harness` does not exist yet, so a perfectly legal /// `/var/lib/awg2-harness/swapfile` would be rejected by the parent-chain check for a /// directory this package is supposed to create. And an existing directory has to be /// verified, not just assumed: if it is group- or world-writable, the whole point of /// restricting `swap.path` to root-owned roots is gone, because another user could swap /// the swapfile out from under a root-owned operation. pub fn ensure_state_dir() -> anyhow::Result<()> { let dir = Path::new(STATE_DIR); // The parent must exist and must not be a symlink, or the directory we create could // land somewhere else entirely. if let Some(parent) = dir.parent() { match fs::symlink_metadata(parent) { Ok(md) if md.file_type().is_symlink() => { anyhow::bail!("{} is a symlink; refusing to use it", parent.display()) } Ok(md) if !md.file_type().is_dir() => { anyhow::bail!("{} is not a directory", parent.display()) } Ok(_) => {} Err(e) => anyhow::bail!("failed to stat {}: {e}", parent.display()), } } match fs::symlink_metadata(dir) { Ok(md) if md.file_type().is_symlink() => { anyhow::bail!("{STATE_DIR} is a symlink; refusing to use it") } Ok(md) if !md.file_type().is_dir() => { anyhow::bail!("{STATE_DIR} exists and is not a directory") } Ok(md) => return assert_state_dir_is_private(dir, &md), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => anyhow::bail!("failed to stat {STATE_DIR}: {e}"), } fs::create_dir(dir).map_err(|e| anyhow::anyhow!("failed to create {STATE_DIR}: {e}"))?; set_dir_mode(dir)?; let md = fs::symlink_metadata(dir) .map_err(|e| anyhow::anyhow!("failed to stat {STATE_DIR} after create: {e}"))?; assert_state_dir_is_private(dir, &md) } /// Refuses a state directory anyone but root can write into. #[cfg(unix)] fn assert_state_dir_is_private(dir: &Path, md: &fs::Metadata) -> anyhow::Result<()> { use std::os::unix::fs::MetadataExt; if md.uid() != 0 { anyhow::bail!( "{} must be owned by root, got uid {}", dir.display(), md.uid() ); } let mode = md.permissions().mode() & 0o777; if mode & 0o022 != 0 { anyhow::bail!( "{} is writable by group or others (mode {mode:04o}); refusing to keep state there", dir.display() ); } if mode != 0o700 { set_dir_mode(dir)?; } Ok(()) } #[cfg(not(unix))] fn assert_state_dir_is_private(_dir: &Path, _md: &fs::Metadata) -> anyhow::Result<()> { Ok(()) } #[cfg(unix)] fn set_dir_mode(dir: &Path) -> anyhow::Result<()> { fs::set_permissions(dir, fs::Permissions::from_mode(0o700)) .map_err(|e| anyhow::anyhow!("failed to chmod 0700 {}: {e}", dir.display())) } #[cfg(not(unix))] fn set_dir_mode(_dir: &Path) -> anyhow::Result<()> { Ok(()) } /// Removes the sidecar written next to the swapfile by earlier versions. /// /// Only removed, never trusted: ownership now lives in the state directory. A sidecar at /// a path we do not own is left alone. fn drop_legacy_ownership_sidecar(swap_path: &Path) { let Ok(sidecar) = suffixed_path(swap_path, LEGACY_OWNERSHIP_SUFFIX) else { return; }; // Goes through the same marker check as every other managed file. Removing any regular // file that happened to match the name would delete an operator's file purely because // of what it was called. let file = ManagedFile::new(sidecar.clone(), LEGACY_OWNERSHIP_MARKER); match file.ownership() { Ok(Ownership::Absent) => {} Ok(Ownership::Managed) => { if let Err(e) = file.remove() { tracing::warn!( target: "swap", "could not remove legacy ownership sidecar {}: {e}", sidecar.display() ); } } Ok(Ownership::Foreign) => tracing::warn!( target: "swap", "{} exists but is not the legacy ownership sidecar; leaving it alone", sidecar.display() ), Err(e) => tracing::warn!( target: "swap", "could not inspect {}: {e}", sidecar.display() ), } } pub fn inspect_swap(cfg: &Config) -> anyhow::Result { let desired_path = PathBuf::from(cfg.swap.path.clone()); let active_entries = read_active_swaps()?; let desired_active = active_entries.iter().any(|s| s.name == desired_path); let exclusive = active_entries.len() == 1 && desired_active; let fstab_swap_entries = read_fstab_swap_entries("/etc/fstab")?; let expected_line = render_fstab_line(&desired_path, cfg.swap.priority); let fstab_exclusive = fstab_swap_entries.len() == 1 && normalize_ws(&fstab_swap_entries[0]) == normalize_ws(expected_line.trim_end()); let current_swappiness = read_current_swappiness().ok(); let persisted_swappiness = read_persisted_swappiness()?; let drift_reason = if !cfg.swap.enabled { None } else if !exclusive { if let Some(foreign) = active_entries.iter().find(|s| s.name != desired_path) { Some(format!("foreign active swap: {}", foreign.name.display())) } else { Some("desired swap is not exclusively active".to_string()) } } else if !fstab_exclusive { Some("/etc/fstab swap entries are not exclusive".to_string()) } else if current_swappiness != Some(cfg.swap.swappiness) { Some(format!( "runtime swappiness mismatch: current={:?} desired={}", current_swappiness, cfg.swap.swappiness )) } else if persisted_swappiness != Some(cfg.swap.swappiness) { Some(format!( "persisted swappiness mismatch: persisted={:?} desired={}", persisted_swappiness, cfg.swap.swappiness )) } else { None }; Ok(SwapStatus { enabled: cfg.swap.enabled, desired_path, active_entries, desired_active, exclusive, fstab_swap_entries, fstab_exclusive, current_swappiness, persisted_swappiness, drift_reason, }) } impl DesiredSwap { fn from_config(cfg: &Config) -> anyhow::Result { let path = PathBuf::from(cfg.swap.path.clone()); if !path.is_absolute() { anyhow::bail!("swap.path must be absolute: {}", path.display()); } let size_bytes = cfg .swap .size_mb .checked_mul(1024 * 1024) .ok_or_else(|| anyhow::anyhow!("swap.size_mb overflow"))?; Ok(Self { path, size_bytes, priority: cfg.swap.priority, swappiness: cfg.swap.swappiness, }) } } fn read_active_swaps() -> anyhow::Result> { let out = Command::new("swapon") .args([ "--show", "--bytes", "--raw", "--noheadings", "--output", "NAME,TYPE,SIZE,PRIO", ]) .output()?; if !out.status.success() { anyhow::bail!( "swapon --show failed: {}", String::from_utf8_lossy(&out.stderr) ); } parse_swapon_output(&String::from_utf8_lossy(&out.stdout)) } fn parse_swapon_output(raw: &str) -> anyhow::Result> { let mut out = Vec::new(); for line in raw.lines() { let t = line.trim(); if t.is_empty() { continue; } let parts: Vec<&str> = t.split_whitespace().collect(); if parts.len() < 4 { anyhow::bail!("malformed swapon row: {t}"); } let size_bytes = parts[2] .parse::() .map_err(|_| anyhow::anyhow!("malformed swapon size: {}", parts[2]))?; // Debian 13/util-linux may include USED and UUID despite the requested // output set. Accept both the old 4-column form: // NAME TYPE SIZE PRIO // and the observed 5/6-column form: // NAME TYPE SIZE USED PRIO [UUID] let priority_index = if parts.len() >= 5 && parts[4].parse::().is_ok() { 4 } else { 3 }; let priority = parts[priority_index] .parse::() .map_err(|_| anyhow::anyhow!("malformed swapon priority: {}", parts[priority_index]))?; out.push(ActiveSwap { name: PathBuf::from(parts[0]), kind: parts[1].to_string(), size_bytes, priority, }); } Ok(out) } /// Takes foreign swap offline. The file itself is never deleted. /// /// Destroying a swapfile an administrator created is not this package's call: `swapoff` /// plus removal from `/etc/fstab` already achieves the exclusive runtime state, and the /// file can be removed by hand once the operator agrees. /// Takes foreign swap offline, restoring what it already disabled if a later one fails. /// /// Without the rollback a host with two foreign swaps could end up with the first one /// disabled and the second still up, because the error aborted the loop and nothing put /// the first back. That is a state neither the operator nor the next reconcile asked for. fn disable_foreign_swaps(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> { let mut disabled: Vec<&ActiveSwap> = Vec::new(); for entry in active { if entry.name == desired.path { continue; } if let Err(e) = run("swapoff", &[entry.name.to_string_lossy().as_ref()]) { reenable_swaps(&disabled); return Err(anyhow::anyhow!( "failed to disable foreign swap {}: {e}", entry.name.display() )); } disabled.push(entry); } Ok(()) } /// Best-effort restore after a failed teardown: report what could not be brought back /// rather than masking it, but never turn the rollback itself into the reported error. fn reenable_swaps(entries: &[&ActiveSwap]) { for entry in entries.iter().rev() { if let Err(e) = run( "swapon", &[ "-p", &entry.priority.to_string(), entry.name.to_string_lossy().as_ref(), ], ) { tracing::warn!( target: "swap", "could not re-enable {} while rolling back: {e}", entry.name.display() ); } } } /// Creates or validates the managed swapfile without ever touching an unowned one. /// /// `active` is needed because a live swapfile cannot be resized in place: if the managed /// swap is currently active with the wrong geometry it has to be deactivated first. That /// is our own swap, so the brief gap is acceptable; foreign swap stays up throughout. fn ensure_desired_swapfile(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> { let p = &desired.path; let ownership = inspect_swapfile_ownership(p, desired.size_bytes)?; match ownership { SwapfileOwnership::Foreign => anyhow::bail!( "{} already exists and there is no record of awg2-harness creating it; \ refusing to touch it. Remove it manually or point swap.path elsewhere", p.display() ), SwapfileOwnership::StaleOwnership => anyhow::bail!( "{} is not the file awg2-harness created - it was replaced since. Refusing to \ touch it. Remove it manually, or delete {SWAP_OWNERSHIP_PATH} to let \ awg2-harness adopt a freshly created swapfile", p.display() ), _ => {} } let active_entry = active.iter().find(|s| s.name == *p); let size_ok = ownership == SwapfileOwnership::ManagedValid; match (size_ok, active_entry) { // Right file, right size, already active with the right priority: nothing to do. (true, Some(entry)) if active_swap_size_matches(entry.size_bytes, desired.size_bytes) && entry.priority == desired.priority => { set_swapfile_mode(p)?; drop_legacy_ownership_sidecar(p); Ok(()) } // Right file, right size, but the active priority differs. A reactivation, not a // rebuild - the previous code fell through to a full recreate, so changing // swap.priority destroyed and rebuilt a perfectly good multi-gigabyte file. (true, Some(entry)) => { set_swapfile_mode(p)?; drop_legacy_ownership_sidecar(p); change_swap_priority(desired, entry.priority) } // Right file, not active: activate it. (true, None) => { set_swapfile_mode(p)?; drop_legacy_ownership_sidecar(p); swapon_at(p, desired.priority) } // Wrong size or no file at all, and nothing of ours is active: build in place. (false, None) => { if ownership.is_managed() { fs::remove_file(p) .map_err(|e| anyhow::anyhow!("failed to remove {}: {e}", p.display()))?; } create_fresh_swapfile(desired)?; drop_legacy_ownership_sidecar(p); Ok(()) } // Wrong size while our swap is live: replace it through a staging file. (false, Some(_)) => { replace_active_swapfile(desired)?; drop_legacy_ownership_sidecar(p); Ok(()) } } } /// Re-activates the managed swap at a new priority, restoring the old one on failure. /// /// `swapoff` followed by `swapon` is two steps, and the second can fail - a bad priority, /// a transient kernel error. Without the restore, changing one integer in the config could /// leave the host with no swap at all. fn change_swap_priority(desired: &DesiredSwap, previous_priority: i32) -> anyhow::Result<()> { let p = &desired.path; run("swapoff", &[p.to_string_lossy().as_ref()])?; let Err(e) = swapon_at(p, desired.priority) else { return Ok(()); }; match swapon_at(p, previous_priority) { Ok(()) => Err(anyhow::anyhow!( "failed to activate {} with priority {}: {e}; restored the previous priority {}", p.display(), desired.priority, previous_priority )), Err(re) => Err(anyhow::anyhow!( "failed to activate {} with priority {} ({e}) and could not restore priority {} \ ({re}); the host may currently have no swap", p.display(), desired.priority, previous_priority )), } } /// Builds and activates a swapfile where none of ours is running. /// /// The file is removed again if anything after its creation fails. Leaving it behind would /// produce an unowned regular file at the managed path, which the next run correctly /// classifies as `Foreign` and then refuses to touch - so a single transient `mkswap` /// failure would wedge the module until an operator intervened. fn create_fresh_swapfile(desired: &DesiredSwap) -> anyhow::Result<()> { let p = &desired.path; build_swapfile(p, desired.size_bytes)?; let prepared = record_ownership(p).and_then(|()| swapon_at(p, desired.priority)); if let Err(e) = prepared { // Created by this run inside an allowed root-owned location, so removing it is // safe and leaves the host exactly as it was found. if let Err(re) = fs::remove_file(p) { return Err(anyhow::anyhow!( "{e}; additionally failed to clean up the partially prepared {}: {re}", p.display() )); } let _ = ownership_file().remove(); return Err(e); } Ok(()) } /// Replaces a live managed swapfile with one of a different size. /// /// A live swapfile cannot be resized in place, so it has to be swapped out. Allocation and /// `mkswap` happen while the old swap is still serving. The old file is then kept aside as /// `.previous` rather than deleted, so every step after that point can be undone: until /// the new file is confirmed active, the original is still on disk and can be put back. /// The previous version deleted the old file before the new one was activated, so a /// failure at `rename` or `swapon` left the host with no swap and nothing to recover from. fn replace_active_swapfile(desired: &DesiredSwap) -> anyhow::Result<()> { let p = &desired.path; let staging = suffixed_path(p, STAGING_SUFFIX)?; let previous = suffixed_path(p, PREVIOUS_SUFFIX)?; remove_if_present(&staging)?; remove_if_present(&previous)?; // Built and activated while the old swap is still up: no gap during the slow part. build_swapfile(&staging, desired.size_bytes)?; if let Err(e) = swapon_at(&staging, desired.priority) { let _ = fs::remove_file(&staging); return Err(e); } // From here the old swap can be released, because the replacement is carrying load. run("swapoff", &[p.to_string_lossy().as_ref()])?; fs::rename(p, &previous).map_err(|e| { anyhow::anyhow!( "failed to set aside {} as {}: {e}", p.display(), previous.display() ) })?; // Short window with no allocation in it. Everything below rolls back to `previous`. let switched = switch_staging_into_place(desired, &staging, &previous); if let Err(e) = switched { return Err(restore_previous_swapfile(desired, &previous, e)); } // The replacement is confirmed active; the old file is no longer needed. if let Err(e) = fs::remove_file(&previous) { tracing::warn!( target: "swap", "swap replaced successfully but the old file {} could not be removed: {e}", previous.display() ); } Ok(()) } fn switch_staging_into_place( desired: &DesiredSwap, staging: &Path, previous: &Path, ) -> anyhow::Result<()> { let p = &desired.path; run("swapoff", &[staging.to_string_lossy().as_ref()])?; fs::rename(staging, p).map_err(|e| { anyhow::anyhow!( "failed to move {} into place at {}: {e} (previous swapfile kept at {})", staging.display(), p.display(), previous.display() ) })?; record_ownership(p)?; swapon_at(p, desired.priority)?; assert_some_swap_is_active(p) } /// Puts the original swapfile back after a failed replacement. /// /// Returns an error that carries both the original failure and the outcome of the /// restore, so the operator sees what went wrong *and* whether the host still has swap. fn restore_previous_swapfile( desired: &DesiredSwap, previous: &Path, cause: anyhow::Error, ) -> anyhow::Error { let p = &desired.path; let _ = remove_if_present(p); if let Err(e) = fs::rename(previous, p) { return anyhow::anyhow!( "{cause}; rollback failed: could not restore {} from {}: {e}. The host may \ currently have no swap", p.display(), previous.display() ); } if let Err(e) = record_ownership(p) { tracing::warn!(target: "swap", "restored swapfile but could not rewrite ownership: {e}"); } match swapon_at(p, desired.priority) { Ok(()) => anyhow::anyhow!("{cause}; rolled back to the previous swapfile"), Err(e) => anyhow::anyhow!( "{cause}; rollback restored {} but could not activate it: {e}. The host may \ currently have no swap", p.display() ), } } /// Refuses to report success while the host has no swap at all. fn assert_some_swap_is_active(expected: &Path) -> anyhow::Result<()> { let active = read_active_swaps()?; if active.iter().any(|s| s.name == expected) { return Ok(()); } anyhow::bail!( "{} is not present in the active swap list after activation", expected.display() ) } fn swapon_at(path: &Path, priority: i32) -> anyhow::Result<()> { run( "swapon", &["-p", &priority.to_string(), path.to_string_lossy().as_ref()], ) } fn remove_if_present(path: &Path) -> anyhow::Result<()> { match fs::remove_file(path) { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(anyhow::anyhow!("failed to remove {}: {e}", path.display())), } } fn suffixed_path(path: &Path, suffix: &str) -> anyhow::Result { let name = path .file_name() .ok_or_else(|| anyhow::anyhow!("invalid swap.path: {}", path.display()))?; let mut out = name.to_os_string(); out.push(suffix); Ok(path.with_file_name(out)) } /// Allocates a swapfile and formats it, with the restrictive mode applied before `mkswap` /// so the file is never briefly world-readable while it already holds swap structure. fn build_swapfile(path: &Path, size_bytes: u64) -> anyhow::Result<()> { create_swapfile(path, size_bytes)?; set_swapfile_mode(path)?; run("mkswap", &[path.to_string_lossy().as_ref()])?; set_swapfile_mode(path) } #[cfg(unix)] fn set_swapfile_mode(path: &Path) -> anyhow::Result<()> { fs::set_permissions(path, fs::Permissions::from_mode(0o600)) .map_err(|e| anyhow::anyhow!("failed to chmod 0600 {}: {e}", path.display())) } #[cfg(not(unix))] fn set_swapfile_mode(_path: &Path) -> anyhow::Result<()> { Ok(()) } fn create_swapfile(path: &Path, size_bytes: u64) -> anyhow::Result<()> { let size_mb = size_bytes / (1024 * 1024); let p = path.to_string_lossy().to_string(); let out = Command::new("fallocate") .args(["-l", &format!("{}M", size_mb), &p]) .output()?; if out.status.success() { return Ok(()); } let out_dd = Command::new("dd") .args([ "if=/dev/zero", &format!("of={p}"), "bs=1M", &format!("count={size_mb}"), "status=none", ]) .output()?; if !out_dd.status.success() { anyhow::bail!( "swapfile create failed (fallocate+dd): fallocate={}, dd={}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out_dd.stderr) ); } Ok(()) } fn rewrite_fstab_exclusive(desired: &DesiredSwap) -> anyhow::Result<()> { let tx = FstabTransaction::begin(Path::new(FSTAB_PATH))?; let rewritten = rewrite_fstab_content(tx.original(), &desired.path, desired.priority); tx.commit(&rewritten)?; Ok(()) } fn rewrite_fstab_content(content: &str, desired_path: &Path, priority: i32) -> String { let mut out = Vec::::new(); for line in content.lines() { let t = line.trim(); if t.is_empty() || t.starts_with('#') { out.push(line.to_string()); continue; } let cols: Vec<&str> = t.split_whitespace().collect(); if cols.len() >= 3 && cols[2] == "swap" { continue; } out.push(line.to_string()); } out.push( render_fstab_line(desired_path, priority) .trim_end() .to_string(), ); let mut s = out.join("\n"); if !s.ends_with('\n') { s.push('\n'); } s } /// Path goes through fstab escaping: fields are whitespace-separated, so an unescaped /// space in the path would shift every following column. fn render_fstab_line(path: &Path, priority: i32) -> String { format!( "{} none swap sw,pri={} 0 0\n", escape_field(&path.to_string_lossy()), priority ) } fn apply_swappiness(v: u8) -> anyhow::Result<()> { run("sysctl", &["-w", &format!("vm.swappiness={v}")])?; let current = read_current_swappiness()?; if current != v { anyhow::bail!("failed to apply vm.swappiness: got {current}, expected {v}"); } migrate_legacy_swappiness_file()?; swappiness_file().write(&render_swappiness_conf(v))?; let persisted = read_persisted_swappiness()?; if persisted != Some(v) { anyhow::bail!( "failed to persist vm.swappiness: got {:?}, expected {}", persisted, v ); } Ok(()) } pub fn read_current_swappiness() -> anyhow::Result { let out = Command::new("sysctl") .args(["-n", "vm.swappiness"]) .output()?; if !out.status.success() { anyhow::bail!("sysctl read vm.swappiness failed"); } let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); s.parse::() .with_context(|| format!("invalid vm.swappiness value: {s}")) } pub fn read_persisted_swappiness() -> anyhow::Result> { let Some(content) = swappiness_file().read()? else { return Ok(None); }; for line in content.lines() { let t = line.trim(); if t.is_empty() || t.starts_with('#') { continue; } let Some((k, v)) = t.split_once('=') else { continue; }; if k.trim() == "vm.swappiness" { return Ok(Some(v.trim().parse::()?)); } } Ok(None) } fn verify_exclusive_swap_state(desired: &DesiredSwap) -> anyhow::Result<()> { let active = read_active_swaps()?; if active.len() != 1 { anyhow::bail!( "swap verify failed: expected exactly one active swap, got {}", active.len() ); } let only = &active[0]; if only.name != desired.path { anyhow::bail!( "swap verify failed: active swap {} != desired {}", only.name.display(), desired.path.display() ); } if !active_swap_size_matches(only.size_bytes, desired.size_bytes) { anyhow::bail!( "swap verify failed: size mismatch {} != {}", only.size_bytes, desired.size_bytes ); } if only.priority != desired.priority { anyhow::bail!( "swap verify failed: priority mismatch {} != {}", only.priority, desired.priority ); } let entries = read_fstab_swap_entries("/etc/fstab")?; if entries.len() != 1 { anyhow::bail!( "swap verify failed: expected one fstab swap entry, got {}", entries.len() ); } let expected = render_fstab_line(&desired.path, desired.priority); if normalize_ws(&entries[0]) != normalize_ws(expected.trim_end()) { anyhow::bail!( "swap verify failed: fstab entry mismatch: got='{}' expected='{}'", entries[0], expected.trim_end() ); } let current = read_current_swappiness()?; if current != desired.swappiness { anyhow::bail!( "swap verify failed: vm.swappiness current={} expected={}", current, desired.swappiness ); } Ok(()) } /// Reads swap entries for inspection. /// /// Strict for the same reason the rewrite is: masking a read failure here would report /// "no swap entries in fstab", which reads as healthy drift-free state on a host whose /// fstab could not be read at all. fn read_fstab_swap_entries(path: &str) -> anyhow::Result> { let content = fstab::read_required(Path::new(path))?; let mut out = Vec::new(); for line in content.lines() { let t = line.trim(); if t.is_empty() || t.starts_with('#') { continue; } let cols: Vec<&str> = t.split_whitespace().collect(); if cols.len() >= 3 && cols[2] == "swap" { out.push(t.to_string()); } } Ok(out) } fn normalize_ws(s: &str) -> String { s.split_whitespace().collect::>().join(" ") } fn active_swap_size_matches(active_bytes: u64, desired_file_bytes: u64) -> bool { if active_bytes == desired_file_bytes { return true; } active_bytes < desired_file_bytes && desired_file_bytes - active_bytes <= 64 * 1024 } fn run(program: &str, args: &[&str]) -> anyhow::Result<()> { let out = Command::new(program).args(args).output()?; if !out.status.success() { anyhow::bail!( "{} {:?} failed: {}", program, args, String::from_utf8_lossy(&out.stderr) ); } Ok(()) } #[cfg(test)] mod tests { use super::*; fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("awg2_swap_{}_{}", name, std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).expect("scratch dir"); dir } #[test] fn swap_path_syntax_accepts_expected_production_values() { assert!(validate_swap_path_syntax("/swapfile").is_ok()); assert!(validate_swap_path_syntax("/var/lib/awg2-harness/swapfile").is_ok()); assert!(validate_swap_path_syntax("/var/lib/awg2-harness/swap.1").is_ok()); } /// An allow-list, not a deny-list: a path whose parent another user can write lets /// them redirect root-owned operations between preflight and use, which no amount of /// stat-ing beforehand can prevent. #[test] fn swap_path_syntax_rejects_paths_outside_root_owned_roots() { for bad in [ "/tmp/awg.swap", "/home/user/awg.swap", "/srv/data/swapfile", "/var/lib/swapfile", "/var/lib/awg2-harness", "/var/lib/awg2-harness/nested/swapfile", "/var/lib/awg2-harness-other/swapfile", "/swapfile2", ] { assert!( validate_swap_path_syntax(bad).is_err(), "must reject path outside the allowed roots: {bad}" ); } } /// The blacklist used to compare exact strings only, so a path that is lexically /// different but resolves to a forbidden file slipped through. #[test] fn swap_path_syntax_rejects_dot_dot_traversal() { for bad in [ "/etc/../etc/fstab", "/var/../etc/passwd", "/./swapfile", "/var/lib/../../etc/fstab", ] { assert!( validate_swap_path_syntax(bad).is_err(), "must reject traversal: {bad}" ); } } #[test] fn swap_path_syntax_rejects_dangerous_and_virtual_paths() { for bad in [ "/", "/etc", "/etc/fstab", "/etc/passwd", "/etc/shadow", "/dev", "/proc", "/sys", "/run", "/boot", "/proc/self/mem", "/sys/kernel", "/dev/sda", ] { assert!( validate_swap_path_syntax(bad).is_err(), "must reject dangerous path: {bad}" ); } } /// Unescaped whitespace would shift the fstab columns; control characters and /// backslashes have no legitimate use in a swapfile path. #[test] fn swap_path_syntax_rejects_whitespace_and_control_characters() { for bad in [ "/var/my swap", "/var/swap\tfile", "/var/swap\nfile", "/var/swap\\file", ] { assert!( validate_swap_path_syntax(bad).is_err(), "must reject: {bad:?}" ); } } #[test] fn swap_path_syntax_rejects_relative_and_directory_paths() { assert!(validate_swap_path_syntax("swapfile").is_err()); assert!(validate_swap_path_syntax("var/lib/swapfile").is_err()); assert!(validate_swap_path_syntax("/var/lib/").is_err()); } fn ident(device: u64, inode: u64) -> FileIdentity { FileIdentity { device, inode } } fn record(path: &str, device: u64, inode: u64) -> SwapOwnershipRecord { SwapOwnershipRecord { version: SWAP_OWNERSHIP_VERSION, path: PathBuf::from(path), identity: ident(device, inode), } } #[test] fn ownership_missing_when_file_absent() { let rec = record("/swapfile", 1, 2); assert_eq!( classify_ownership(Some(&rec), Path::new("/swapfile"), None, 1024), SwapfileOwnership::Missing ); } /// A pre-existing swapfile we have no record of belongs to someone else and must /// never be deleted or rewritten, whatever its size. #[test] fn ownership_foreign_without_record() { assert_eq!( classify_ownership( None, Path::new("/swapfile"), Some((ident(1, 2), 1024)), 1024 ), SwapfileOwnership::Foreign ); } #[test] fn ownership_managed_valid_and_wrong_size() { let rec = record("/swapfile", 1, 2); assert_eq!( classify_ownership( Some(&rec), Path::new("/swapfile"), Some((ident(1, 2), 1024)), 1024 ), SwapfileOwnership::ManagedValid ); assert_eq!( classify_ownership( Some(&rec), Path::new("/swapfile"), Some((ident(1, 2), 1024)), 2048 ), SwapfileOwnership::ManagedWrongSize ); } /// The data-loss scenario the sidecar allowed: our swapfile is deleted by hand, the /// ownership state survives, and someone puts an unrelated file at the same path. /// Matching on path alone would have called it managed and deleted it. #[test] fn ownership_is_stale_when_the_file_was_replaced() { let rec = record("/swapfile", 1, 2); assert_eq!( classify_ownership( Some(&rec), Path::new("/swapfile"), Some((ident(1, 999), 4096)), 1024 ), SwapfileOwnership::StaleOwnership ); } /// A record for a different managed path says nothing about this file. #[test] fn ownership_record_for_another_path_is_foreign() { let rec = record("/var/lib/awg2-harness/swapfile", 1, 2); assert_eq!( classify_ownership( Some(&rec), Path::new("/swapfile"), Some((ident(1, 2), 1024)), 1024 ), SwapfileOwnership::Foreign ); } /// Neither refusal state may ever authorise a delete. #[test] fn foreign_and_stale_are_never_managed() { assert!(!SwapfileOwnership::Foreign.is_managed()); assert!(!SwapfileOwnership::StaleOwnership.is_managed()); assert!(!SwapfileOwnership::Missing.is_managed()); assert!(SwapfileOwnership::ManagedValid.is_managed()); assert!(SwapfileOwnership::ManagedWrongSize.is_managed()); } #[test] fn ownership_record_round_trips() { let rec = record("/var/lib/awg2-harness/swapfile", 2049, 123456); let rendered = render_ownership_record(&rec); assert!(rendered.starts_with(SWAP_OWNERSHIP_MARKER)); assert_eq!(parse_ownership_record(&rendered).expect("parse"), rec); } #[test] fn ownership_record_rejects_malformed_and_unsupported() { for bad in [ "", "# marker only\n", "version = 1\npath = /swapfile\ndevice = 1\n", "version = 99\npath = /swapfile\ndevice = 1\ninode = 2\n", "version = x\npath = /swapfile\ndevice = 1\ninode = 2\n", ] { assert!( parse_ownership_record(bad).is_err(), "must reject record: {bad:?}" ); } } fn active(name: &str, size_bytes: u64, priority: i32) -> ActiveSwap { ActiveSwap { name: PathBuf::from(name), kind: "file".to_string(), size_bytes, priority, } } /// Changing only `swap.priority` used to fall through to a full recreate, destroying /// and rebuilding a correct multi-gigabyte file. It is a reactivation, nothing more. #[test] fn priority_only_change_does_not_need_a_new_file() { let desired = DesiredSwap { path: PathBuf::from("/swapfile"), size_bytes: 2048 * 1024 * 1024, priority: 5, swappiness: 1, }; let entry = active("/swapfile", desired.size_bytes, 1); // Same size, different priority: the file is fine, only the activation is wrong. assert!(active_swap_size_matches( entry.size_bytes, desired.size_bytes )); assert_ne!(entry.priority, desired.priority); } #[test] fn staging_path_is_a_sibling_of_the_swapfile() { let base = Path::new("/var/lib/awg2-harness/swapfile"); assert_eq!( suffixed_path(base, STAGING_SUFFIX).expect("staging"), PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness.new") ); assert_eq!( suffixed_path(base, PREVIOUS_SUFFIX).expect("previous"), PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness.previous") ); assert_eq!( suffixed_path(base, LEGACY_OWNERSHIP_SUFFIX).expect("sidecar"), PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness-owned") ); } /// The legacy sidecar is removed only when it carries the marker the old version /// wrote; any other file with that name belongs to the operator. #[test] fn legacy_sidecar_is_only_removed_when_it_carries_the_old_marker() { let dir = scratch("sidecar"); let swapfile = dir.join("swapfile"); let sidecar = dir.join("swapfile.awg2-harness-owned"); fs::write(&sidecar, "# something an operator wrote\n").expect("seed foreign"); drop_legacy_ownership_sidecar(&swapfile); assert!(sidecar.exists(), "a foreign file must not be removed"); fs::write(&sidecar, format!("{LEGACY_OWNERSHIP_MARKER}\n")).expect("seed ours"); drop_legacy_ownership_sidecar(&swapfile); assert!(!sidecar.exists(), "our own sidecar must be removed"); } /// Foreign swaps are identified by not being the managed path; the managed one is /// never included in the teardown set. #[test] fn managed_swap_is_never_in_the_foreign_set() { let desired = DesiredSwap { path: PathBuf::from("/swapfile"), size_bytes: 1024, priority: 1, swappiness: 1, }; let entries = [ active("/swapfile", 1024, 1), active("/dev/sda2", 4096, -2), active("/other/swap", 2048, 0), ]; let foreign: Vec<_> = entries .iter() .filter(|e| e.name != desired.path) .map(|e| e.name.display().to_string()) .collect(); assert_eq!(foreign, vec!["/dev/sda2", "/other/swap"]); } #[test] fn ownership_rejects_non_regular_file() { let dir = scratch("own_dir"); let p = dir.join("swapfile"); fs::create_dir(&p).expect("seed dir"); assert!(inspect_swapfile_ownership(&p, 1024).is_err()); } #[test] fn safe_path_check_rejects_missing_parent_directory() { let dir = scratch("no_parent"); let err = assert_path_chain_is_safe(&dir.join("nested").join("swapfile")).expect_err("must fail"); assert!(err.to_string().contains("parent directory does not exist")); } #[test] fn safe_path_check_accepts_existing_parent_chain() { let dir = scratch("parent_ok"); assert!(assert_path_chain_is_safe(&dir.join("swapfile")).is_ok()); } /// A symlink anywhere in the chain, not only at the final component, can redirect the /// operation at a file that was never configured. #[cfg(unix)] #[test] fn safe_path_check_rejects_symlinked_parent() { let dir = scratch("symlink_parent"); let real = dir.join("real"); fs::create_dir(&real).expect("real dir"); let link = dir.join("link"); std::os::unix::fs::symlink(&real, &link).expect("symlink"); let err = assert_path_chain_is_safe(&link.join("swapfile")).expect_err("must fail"); assert!(err.to_string().contains("traverses a symlink")); } #[test] fn parse_swapon_empty() { let got = parse_swapon_output("").expect("parse"); assert!(got.is_empty()); } #[test] fn parse_swapon_single_file() { let got = parse_swapon_output("/swapfile file 2147479552 1\n").expect("parse"); assert_eq!(got.len(), 1); assert_eq!(got[0].name, PathBuf::from("/swapfile")); assert_eq!(got[0].kind, "file"); assert_eq!(got[0].size_bytes, 2147479552); assert_eq!(got[0].priority, 1); } #[test] fn parse_swapon_multiple_entries() { let raw = "/swapfile file 2147479552 1\n/dev/zram0 partition 1073741824 100\n"; let got = parse_swapon_output(raw).expect("parse"); assert_eq!(got.len(), 2); } #[test] fn parse_swapon_debian13_columns_with_used_prio_uuid() { let got = parse_swapon_output( "/swapfile file 2147479552 0 1 c8db470e-b8fb-414a-8537-8fa405ebeadf\n", ) .expect("parse"); assert_eq!(got.len(), 1); assert_eq!(got[0].name, PathBuf::from("/swapfile")); assert_eq!(got[0].size_bytes, 2147479552); assert_eq!(got[0].priority, 1); } #[test] fn active_swap_size_accepts_mkswap_usable_size_delta() { // Debian 13: swapon may report 4096 bytes less than file size. assert!(active_swap_size_matches(2147479552, 2147483648)); assert!(active_swap_size_matches(2147483648, 2147483648)); assert!(!active_swap_size_matches(2146435072, 2147483648)); assert!(!active_swap_size_matches(2147487744, 2147483648)); } #[test] fn parse_swapon_rejects_malformed_size() { let err = parse_swapon_output("/swapfile file abc 1\n"); assert!(err.is_err()); } #[test] fn fstab_rewrite_removes_all_swap_entries() { let raw = "UUID=x / ext4 defaults 0 1\n/swap-old none swap sw 0 0\n/dev/zram0 none swap sw 0 0\n"; let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); assert!(!out.contains("/swap-old")); assert!(!out.contains("/dev/zram0")); assert!(out.contains("/swapfile none swap sw,pri=1 0 0")); } #[test] fn fstab_rewrite_preserves_comments_and_mounts() { let raw = "# head\nUUID=x / ext4 defaults 0 1\n\n# tail\n"; let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); assert!(out.contains("# head")); assert!(out.contains("UUID=x / ext4 defaults 0 1")); assert!(out.contains("# tail")); } #[test] fn fstab_rewrite_is_idempotent() { let raw = "UUID=x / ext4 defaults 0 1\n/swapfile none swap sw,pri=1 0 0\n"; let one = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); let two = rewrite_fstab_content(&one, Path::new("/swapfile"), 1); assert_eq!(one, two); } #[test] fn fstab_rewrite_adds_trailing_newline() { let raw = "UUID=x / ext4 defaults 0 1"; let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1); assert!(out.ends_with('\n')); } #[test] fn swappiness_conf_render_is_stable() { let rendered = render_swappiness_conf(1); assert!(rendered.starts_with(SWAPPINESS_MARKER)); assert!(rendered.contains("vm.swappiness = 1")); assert!(render_swappiness_conf(60).contains("vm.swappiness = 60")); } /// The rendered file must be recognised as ours by the same marker check that guards /// overwriting it, otherwise every reconcile would refuse to touch its own output. #[test] fn rendered_swappiness_conf_is_recognised_as_managed() { let dir = scratch("swappiness_marker"); let f = ManagedFile::new(dir.join("90-awg2-harness-swap.conf"), SWAPPINESS_MARKER); assert!(f.write(&render_swappiness_conf(1)).expect("write")); assert_eq!( f.ownership().expect("ownership"), crate::managed_file::Ownership::Managed ); assert!(!f.write(&render_swappiness_conf(1)).expect("idempotent")); } #[test] fn legacy_swappiness_file_is_removed_when_it_carries_the_old_marker() { let dir = scratch("legacy_ours"); let path = dir.join("99-awg2-harness.conf"); fs::write( &path, format!("{LEGACY_SWAPPINESS_MARKER}\nvm.swappiness = 1\n"), ) .expect("seed legacy"); let legacy = ManagedFile::new(path.clone(), LEGACY_SWAPPINESS_MARKER); drop_legacy_managed_file(&legacy).expect("migrate"); assert!(!path.exists()); } /// An administrator's file that happens to sit at the old path is not ours to delete. #[test] fn legacy_migration_leaves_a_foreign_file_alone() { let dir = scratch("legacy_foreign"); let path = dir.join("99-awg2-harness.conf"); fs::write(&path, "# hand written\nvm.swappiness = 30\n").expect("seed foreign"); let legacy = ManagedFile::new(path.clone(), LEGACY_SWAPPINESS_MARKER); drop_legacy_managed_file(&legacy).expect("must not fail"); assert_eq!( fs::read_to_string(&path).expect("intact"), "# hand written\nvm.swappiness = 30\n" ); } #[test] fn legacy_migration_is_a_no_op_when_nothing_is_there() { let dir = scratch("legacy_absent"); let legacy = ManagedFile::new(dir.join("99-awg2-harness.conf"), LEGACY_SWAPPINESS_MARKER); assert!(drop_legacy_managed_file(&legacy).is_ok()); } }