diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md index b4ca0c5..59ac552 100644 --- a/docs/ACCEPTANCE_CHECKLIST.md +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -106,10 +106,15 @@ - [ ] Если резервную копию записать не удалось, `/etc/fstab` остаётся неизменным. - [ ] `status` при нечитаемом `/etc/fstab` показывает ошибку, а не «нет swap-записей». - [ ] Путь swap с пробелом в имени попадает в `/etc/fstab` экранированным (`\040`) и корректно монтируется. +- [ ] Рядом с управляемым swapfile создан маркер `.awg2-harness-owned`. - [ ] Если до запуска был активен другой swap, после `ensure-swap` он выключен. - [ ] После `ensure-swap` активен ровно один swap: `/swapfile`. - [ ] Все старые swap entries удалены из `/etc/fstab`. -- [ ] Для block-device swap выполняется только `swapoff` и удаление `fstab` entry; block device не удаляется. +- [ ] Чужой swap **не удаляется как файл** — ни обычный файл, ни block device; выполняется только `swapoff` и удаление записи из `/etc/fstab`. +- [ ] Существующий по `swap.path` файл без маркера владения приводит к явной ошибке, а не к пересозданию; его содержимое остаётся нетронутым. +- [ ] Если создание или активация нового swap провалились, ранее активный swap **остаётся включённым** (проверяется, например, недостижимым размером `swap.size_mb`). +- [ ] `swap.path` с `..` (`/etc/../etc/fstab`), с пробелом, с управляющим символом или под `/proc`, `/sys`, `/dev` отвергается на `validate-config`. +- [ ] `swap.path`, у которого один из родительских каталогов — symlink, отвергается на `ensure-swap`. - [ ] `vm.swappiness` из конфига применён в runtime и сохранён persistently. - [ ] Повторный `ensure-swap` ничего не пересоздаёт и не добавляет дублей. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5cecd8e..0fd3b17 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -195,6 +195,10 @@ swappiness = 1 `swap.path`, `swap.size_mb`, `swap.priority`, `swap.swappiness` — параметры managed swap. При `swap.enabled=true` пакет приводит swap-state к exclusive состоянию: активен ровно один swap (`swap.path`), остальные выключаются и swap-entries в `/etc/fstab` пересобираются в одну managed строку. Рекомендуемый low priority — `1`, `swappiness = 1`. +Ограничения `swap.path`: абсолютный, без компонентов `.` и `..`, без пробелов, управляющих символов и обратных слэшей, не из списка системных путей (`/`, `/etc`, `/etc/fstab`, `/etc/passwd`, `/etc/shadow`, `/dev`, `/proc`, `/sys`, `/run`, `/boot`) и не под `/proc`, `/sys`, `/dev`. Дополнительно на этапе применения проверяется, что ни один родительский каталог не является symlink. + +Пакет создаёт рядом с swapfile маркер владения `.awg2-harness-owned`. Файл без этого маркера считается чужим: он никогда не пересоздаётся и не удаляется, вместо этого возвращается ошибка. Чужие активные swap только выключаются — их файлы пакет не удаляет. + ## Что не выносить в конфиг Не нужно выносить: diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md index 5146b76..c2e90ff 100644 --- a/docs/IMPLEMENTATION_SPEC.md +++ b/docs/IMPLEMENTATION_SPEC.md @@ -535,10 +535,52 @@ Production-путь перезагрузки — внешний бинарь/с - одна запись в `/etc/fstab` с настроенным приоритетом; - `vm.swappiness` из конфига применён в runtime и сохранён persistently. -При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap-устройства и файлы выключаются через `swapoff`. Все не-комментарные swap-записи в `/etc/fstab` удаляются и заменяются одной управляемой записью AWG2. Блочные устройства не удаляются никогда — только выключаются и убираются из fstab. Операция обязана быть идемпотентной. +При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap выключаются через `swapoff`, а их записи убираются из `/etc/fstab`. Операция обязана быть идемпотентной. Создание файла: `fallocate`, при неудаче — fallback на `dd`. +### Владение swapfile + +Пакет никогда не трогает файл, который не создавал. Рядом с управляемым swapfile лежит sidecar-маркер `.awg2-harness-owned` с первой строкой `# Managed by awg2-harness swap`. Без него отличить наш файл от созданного администратором невозможно, и правило «размер не совпал — пересоздать» превратилось бы в «молча уничтожить чужой файл». + +| Состояние | Условие | Действие | +| --- | --- | --- | +| `Missing` | файла нет | создать | +| `ManagedValid` | маркер есть, размер совпадает | переиспользовать | +| `ManagedWrongSize` | маркер есть, размер отличается | пересоздать | +| `Foreign` | файл есть, маркера нет | **ошибка**, файл не трогается | + +Чужие активные swap только выключаются через `swapoff`. Файлы не удаляются — ни обычные, ни блочные устройства. Удаление чужого swapfile не является решением этого пакета: эксклюзивное runtime-состояние достигается связкой `swapoff` плюс вычистка `/etc/fstab`, а файл оператор уберёт сам, когда сочтёт нужным. + +### Порядок операций + +Порядок принципиален. Управляемый swap создаётся, активируется и проверяется **до** того, как отключается любой чужой: + +```text +1. взять /run/awg2-harness/swap.lock (timeout 30 c) +2. preflight: разобрать конфиг, проверить безопасность пути, снять текущее состояние +3. подготовить управляемый swapfile (с учётом владения) +4. swapon управляемого swap +5. убедиться, что он реально появился в списке активных +6. только теперь swapoff чужих swap +7. перезаписать /etc/fstab транзакцией +8. применить swappiness +9. проверить итоговое состояние +``` + +Шаги 2–5 не выполняют ни одного деструктивного действия над чужим состоянием. Прежний порядок отключал чужие swap первым шагом, поэтому ошибка в `fallocate`, `dd`, `mkswap` или `swapon` оставляла хост вообще без swap. + +### Безопасность `swap.path` + +Проверка разделена на две части. Лексическая не обращается к файловой системе и потому пригодна для `validate-config` на хосте, где файла ещё нет: + +- путь абсолютный и не оканчивается на `/`; +- нет компонентов `.` и `..` — поэтому строка уже нормализована и список запрещённых путей нельзя обойти записью вида `/etc/../etc/fstab`; +- нет пробелов, управляющих символов и обратных слэшей; +- путь не входит в список запрещённых (`/`, `/etc`, `/etc/fstab`, `/etc/passwd`, `/etc/shadow`, `/dev`, `/proc`, `/sys`, `/run`, `/boot`) и не лежит под `/proc`, `/sys`, `/dev`. + +Файловая часть выполняется непосредственно перед использованием пути: каждый родительский компонент обязан существовать и не быть symlink. Проверки только финального компонента недостаточно — symlink в середине цепочки перенаправляет операцию на файл, который никогда не настраивался. + ### Перезапись `/etc/fstab` Это самая разрушительная операция пакета, поэтому она транзакционная и вынесена в отдельный модуль `fstab.rs`. Контракт: diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs index 568d19d..d06f8e5 100644 --- a/traffic_core/src/config.rs +++ b/traffic_core/src/config.rs @@ -250,13 +250,8 @@ impl Config { if self.swap.size_mb > 65536 { anyhow::bail!("swap.size_mb must be <= 65536 when swap.enabled=true"); } - if !self.swap.path.starts_with('/') { - anyhow::bail!("swap.path must be absolute"); - } - let dangerous = ["/", "/etc", "/etc/fstab", "/dev", "/proc", "/sys", "/run"]; - if dangerous.iter().any(|p| *p == self.swap.path) { - anyhow::bail!("swap.path is dangerous and forbidden: {}", self.swap.path); - } + // One implementation, shared with the runtime path check in swap.rs. + crate::swap::validate_swap_path_syntax(&self.swap.path)?; if self.swap.priority < -1 || self.swap.priority > 32767 { anyhow::bail!("swap.priority must be between -1 and 32767"); } diff --git a/traffic_core/src/swap.rs b/traffic_core/src/swap.rs index 06a6552..62c8b14 100644 --- a/traffic_core/src/swap.rs +++ b/traffic_core/src/swap.rs @@ -46,6 +46,31 @@ pub struct SwapStatus { pub drift_reason: Option, } +/// Sidecar that marks a swapfile as created and owned by this package. +/// +/// Without it there is no way to tell our swapfile from one an administrator made, and +/// "recreate because the size differs" would become "silently destroy someone else's +/// file". A file with no sidecar is never written to or deleted. +const OWNERSHIP_SUFFIX: &str = ".awg2-harness-owned"; +const OWNERSHIP_MARKER: &str = "# Managed by awg2-harness swap\n"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SwapfileOwnership { + /// Nothing at the path: safe to create. + Missing, + /// Ours, and already the requested size. + ManagedValid, + /// Ours, but the size differs: safe to recreate. + ManagedWrongSize, + /// Exists without our marker. Never touched. + Foreign, +} + +/// 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<()> { if !cfg.swap.enabled { return Ok(()); @@ -53,17 +78,162 @@ pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> { let _lock = FileLock::acquire_timeout("/run/awg2-harness/swap.lock", Duration::from_secs(30))?; + // 1. Preflight: nothing is modified here. let desired = DesiredSwap::from_config(cfg)?; + assert_swap_path_is_safe(&desired.path)?; let active_before = read_active_swaps()?; - disable_foreign_swaps(&desired, &active_before)?; - ensure_desired_swapfile(&desired)?; - rewrite_fstab_exclusive(&desired)?; + + // 2-3. Prepare and activate the managed swap. + ensure_desired_swapfile(&desired, &active_before)?; enable_desired_swap(&desired)?; + + // 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(()) } +/// 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}"); + } + } + // Checked after rejecting '.' and '..', so the string is already normalized and an + // exact comparison cannot be bypassed by a path like /etc/../etc/fstab. + const FORBIDDEN: &[&str] = &[ + "/", + "/etc", + "/etc/fstab", + "/etc/passwd", + "/etc/shadow", + "/dev", + "/proc", + "/sys", + "/run", + "/boot", + ]; + if FORBIDDEN.contains(&raw) { + anyhow::bail!("swap.path is dangerous and forbidden: {raw}"); + } + if raw.starts_with("/proc/") || raw.starts_with("/sys/") || raw.starts_with("/dev/") { + anyhow::bail!("swap.path must not live under /proc, /sys or /dev: {raw}"); + } + Ok(()) +} + +/// 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_marker_path(path: &Path) -> anyhow::Result { + let name = path + .file_name() + .ok_or_else(|| anyhow::anyhow!("invalid swap.path: {}", path.display()))?; + let mut marker = name.to_os_string(); + marker.push(OWNERSHIP_SUFFIX); + Ok(path.with_file_name(marker)) +} + +fn inspect_swapfile_ownership(path: &Path, desired_size: u64) -> anyhow::Result { + let md = match fs::symlink_metadata(path) { + Ok(v) => v, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(SwapfileOwnership::Missing); + } + Err(e) => anyhow::bail!("failed to stat {}: {e}", path.display()), + }; + 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()); + } + + let marker = ownership_marker_path(path)?; + let owned = match fs::read_to_string(&marker) { + Ok(content) => content.starts_with(OWNERSHIP_MARKER.trim_end()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => anyhow::bail!("failed to read {}: {e}", marker.display()), + }; + if !owned { + return Ok(SwapfileOwnership::Foreign); + } + if md.len() == desired_size { + Ok(SwapfileOwnership::ManagedValid) + } else { + Ok(SwapfileOwnership::ManagedWrongSize) + } +} + pub fn inspect_swap(cfg: &Config) -> anyhow::Result { let desired_path = PathBuf::from(cfg.swap.path.clone()); let active_entries = read_active_swaps()?; @@ -193,69 +363,84 @@ fn parse_swapon_output(raw: &str) -> anyhow::Result> { 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. 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 !active_swap_size_matches(entry.size_bytes, desired.size_bytes) - || entry.priority != desired.priority - { - run("swapoff", &[desired.path.to_string_lossy().as_ref()])?; + if entry.name == desired.path { + continue; } + run("swapoff", &[entry.name.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<()> { +/// 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; - if !p.is_absolute() { - anyhow::bail!("swap.path must be absolute: {}", p.display()); + let ownership = inspect_swapfile_ownership(p, desired.size_bytes)?; + + if ownership == SwapfileOwnership::Foreign { + anyhow::bail!( + "{} already exists and was not created by awg2-harness; \ + refusing to overwrite it. Remove it manually or point swap.path elsewhere", + 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()); - } + let desired_active = active.iter().find(|s| s.name == *p); + let geometry_ok = desired_active + .map(|s| { + active_swap_size_matches(s.size_bytes, desired.size_bytes) + && s.priority == desired.priority + }) + .unwrap_or(false); - if md.len() == desired.size_bytes { - need_create = false; - } else { - fs::remove_file(p)?; - } + if ownership == SwapfileOwnership::ManagedValid && (desired_active.is_none() || geometry_ok) { + write_ownership_marker(p)?; + set_swapfile_mode(p)?; + return Ok(()); } - if need_create { - create_swapfile(p, desired.size_bytes)?; - run("mkswap", &[p.to_string_lossy().as_ref()])?; + if desired_active.is_some() { + run("swapoff", &[p.to_string_lossy().as_ref()])?; } - #[cfg(unix)] - { - fs::set_permissions(p, fs::Permissions::from_mode(0o600))?; + if ownership != SwapfileOwnership::Missing { + fs::remove_file(p).map_err(|e| anyhow::anyhow!("failed to remove {}: {e}", p.display()))?; } + + create_swapfile(p, desired.size_bytes)?; + // Restrictive mode before mkswap, so the file is never briefly world-readable while + // it already contains swap structure. + set_swapfile_mode(p)?; + run("mkswap", &[p.to_string_lossy().as_ref()])?; + set_swapfile_mode(p)?; + write_ownership_marker(p)?; + Ok(()) +} + +fn write_ownership_marker(path: &Path) -> anyhow::Result<()> { + let marker = ownership_marker_path(path)?; + fs::write(&marker, OWNERSHIP_MARKER) + .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", marker.display())) +} + +#[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(()) } @@ -517,6 +702,177 @@ fn run(program: &str, args: &[&str]) -> anyhow::Result<()> { 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()); + } + + /// 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()); + } + + #[test] + fn ownership_missing_when_file_absent() { + let dir = scratch("own_missing"); + let got = inspect_swapfile_ownership(&dir.join("swapfile"), 1024).expect("inspect"); + assert_eq!(got, SwapfileOwnership::Missing); + } + + /// A pre-existing swapfile with no marker belongs to someone else and must never be + /// deleted or rewritten, whatever its size. + #[test] + fn ownership_foreign_without_marker() { + let dir = scratch("own_foreign"); + let p = dir.join("swapfile"); + fs::write(&p, vec![0u8; 1024]).expect("seed"); + let got = inspect_swapfile_ownership(&p, 1024).expect("inspect"); + assert_eq!(got, SwapfileOwnership::Foreign); + } + + #[test] + fn ownership_managed_valid_and_wrong_size() { + let dir = scratch("own_managed"); + let p = dir.join("swapfile"); + fs::write(&p, vec![0u8; 1024]).expect("seed"); + write_ownership_marker(&p).expect("marker"); + + assert_eq!( + inspect_swapfile_ownership(&p, 1024).expect("inspect"), + SwapfileOwnership::ManagedValid + ); + assert_eq!( + inspect_swapfile_ownership(&p, 2048).expect("inspect"), + SwapfileOwnership::ManagedWrongSize + ); + } + + #[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()); + } + + /// The guard that turns "wrong size, recreate" into a refusal rather than data loss. + #[test] + fn foreign_swapfile_is_never_recreated() { + let dir = scratch("foreign_guard"); + let p = dir.join("swapfile"); + fs::write(&p, b"someone elses data").expect("seed"); + + let desired = DesiredSwap { + path: p.clone(), + size_bytes: 4096, + priority: 1, + swappiness: 1, + }; + let err = ensure_desired_swapfile(&desired, &[]).expect_err("must refuse"); + assert!(err.to_string().contains("not created by awg2-harness")); + assert_eq!( + fs::read(&p).expect("still there"), + b"someone elses data".to_vec() + ); + } + + #[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");