use std::{ fs, io::Write, path::{Path, PathBuf}, process::Command, time::Duration, }; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use anyhow::Context; use crate::{config::Config, lock::FileLock}; #[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, } pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> { if !cfg.swap.enabled { return Ok(()); } let _lock = FileLock::acquire_timeout("/run/awg2-harness/swap.lock", Duration::from_secs(30))?; let desired = DesiredSwap::from_config(cfg)?; let active_before = read_active_swaps()?; disable_foreign_swaps(&desired, &active_before)?; ensure_desired_swapfile(&desired)?; rewrite_fstab_exclusive(&desired)?; enable_desired_swap(&desired)?; apply_swappiness(desired.swappiness)?; verify_exclusive_swap_state(&desired)?; Ok(()) } 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) } fn disable_foreign_swaps(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> { for entry in active { if entry.name != desired.path { run("swapoff", &[entry.name.to_string_lossy().as_ref()])?; maybe_delete_foreign_swapfile(entry, &desired.path)?; } else if entry.size_bytes != desired.size_bytes || entry.priority != desired.priority { run("swapoff", &[desired.path.to_string_lossy().as_ref()])?; } } Ok(()) } fn maybe_delete_foreign_swapfile(entry: &ActiveSwap, desired_path: &Path) -> anyhow::Result<()> { if entry.name == desired_path { return Ok(()); } let md = match fs::symlink_metadata(&entry.name) { Ok(v) => v, Err(_) => return Ok(()), }; if md.file_type().is_symlink() { return Ok(()); } if md.file_type().is_file() { let _ = fs::remove_file(&entry.name); } Ok(()) } fn ensure_desired_swapfile(desired: &DesiredSwap) -> anyhow::Result<()> { let p = &desired.path; if !p.is_absolute() { anyhow::bail!("swap.path must be absolute: {}", p.display()); } let mut need_create = true; if p.exists() { let md = fs::symlink_metadata(p)?; if md.file_type().is_symlink() { anyhow::bail!("desired swap path is symlink: {}", p.display()); } if !md.file_type().is_file() { anyhow::bail!("desired swap path is not regular file: {}", p.display()); } if md.len() == desired.size_bytes { need_create = false; } else { fs::remove_file(p)?; } } if need_create { create_swapfile(p, desired.size_bytes)?; run("mkswap", &[p.to_string_lossy().as_ref()])?; } #[cfg(unix)] { fs::set_permissions(p, fs::Permissions::from_mode(0o600))?; } 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 fstab_path = "/etc/fstab"; let bak_path = "/etc/fstab.awg2-harness.bak"; let tmp_path = "/etc/fstab.awg2-harness.tmp"; let content = fs::read_to_string(fstab_path).unwrap_or_default(); let rewritten = rewrite_fstab_content(&content, &desired.path, desired.priority); if rewritten == content { return Ok(()); } let _ = fs::write(bak_path, &content); let mut f = fs::File::create(tmp_path)?; f.write_all(rewritten.as_bytes())?; f.sync_all()?; drop(f); fs::rename(tmp_path, fstab_path)?; 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 } fn render_fstab_line(path: &Path, priority: i32) -> String { format!("{} none swap sw,pri={} 0 0\n", path.display(), priority) } fn enable_desired_swap(desired: &DesiredSwap) -> anyhow::Result<()> { let active = read_active_swaps()?; if let Some(found) = active.iter().find(|v| v.name == desired.path) && found.size_bytes == desired.size_bytes && found.priority == desired.priority { return Ok(()); } run( "swapon", &[ "-p", &desired.priority.to_string(), desired.path.to_string_lossy().as_ref(), ], )?; Ok(()) } 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}"); } let conf_path = "/etc/sysctl.d/99-awg2-harness.conf"; let expected = format!("# Managed by awg2-harness\nvm.swappiness = {v}\n"); let current_file = fs::read_to_string(conf_path).unwrap_or_default(); if current_file != expected { fs::write(conf_path, expected)?; } 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 conf_path = "/etc/sysctl.d/99-awg2-harness.conf"; let content = match fs::read_to_string(conf_path) { Ok(v) => v, Err(_) => 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 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(()) } fn read_fstab_swap_entries(path: &str) -> anyhow::Result> { let content = fs::read_to_string(path).unwrap_or_default(); 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 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::*; #[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 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 expected = "# Managed by awg2-harness\nvm.swappiness = 1\n"; assert_eq!( expected, format!("# Managed by awg2-harness\nvm.swappiness = {}\n", 1) ); } }