fix(swap): never touch an unowned swapfile, activate before deactivating [P0]

Two more release blockers in the swap module.

1. Foreign files could be destroyed.

   swap.path was validated against a blacklist of exact strings. /etc/../etc/fstab is not
   string-equal to /etc/fstab but resolves to it, and nothing normalized the path or
   checked the parent chain for symlinks. ensure_desired_swapfile then removed any
   existing regular file whose size did not match the configured one, so a mistyped or
   hostile path meant deleting /etc/passwd, an application config, or any other regular
   file. disable_foreign_swaps additionally deleted foreign swapfiles outright.

   Ownership is now explicit. A sidecar marker <swap.path>.awg2-harness-owned sits next to
   the managed swapfile, and inspect_swapfile_ownership classifies the path as Missing,
   ManagedValid, ManagedWrongSize or Foreign. Foreign is always a hard error - the file is
   never resized, rewritten or removed. Foreign active swaps are now only swapped off;
   deleting an administrator's swapfile is not this package's call, and swapoff plus the
   fstab rewrite already produce the exclusive runtime state.

   Path validation is split in two. The lexical half rejects relative paths, '.' and '..'
   components, whitespace, control characters and backslashes, the system path list, and
   anything under /proc, /sys or /dev; it touches no filesystem, so validate-config stays
   side-effect free on a host where the file does not exist yet. It runs after '..' is
   rejected, so the string is already normalized and the blacklist cannot be bypassed. The
   filesystem half runs just before use and refuses a symlink anywhere in the parent chain,
   not only at the final component.

   One deliberate deviation from the audit: arbitrary absolute paths stay allowed rather
   than being restricted to a fixed managed location. Pinning the path would break
   deployments that already configured their own, and the ownership marker is the guard
   that actually prevents data loss - a wrong path can now only fail, never destroy.

2. The migration order left the host swapless on failure.

   ensure_swap ran disable_foreign_swaps first, then created the swapfile, then activated
   it. Any failure in fallocate, dd, mkswap or swapon after that first step left the host
   with no swap at all - on a box provisioned with swap precisely because it is short on
   memory.

   The sequence is now a transaction: preflight, prepare the managed swapfile, swapon,
   verify it actually appears in the active list, and only then swapoff the others,
   rewrite fstab, apply swappiness and verify. Nothing destructive happens until the
   replacement is confirmed live. The one unavoidable gap is deactivating our own swap to
   resize it, which cannot be done in place; foreign swap stays up throughout.

   chmod 0600 now also happens before mkswap, so the file is never briefly world-readable
   while it already holds swap structure.

config.rs no longer carries its own copy of the path blacklist and calls the shared
validator.

Twelve tests: the four ownership states, refusal to recreate a foreign file with its
content asserted intact afterwards, '..' traversal, dangerous and virtual paths,
whitespace and control characters, relative and directory paths, a missing parent
directory, an accepted chain, and a symlinked parent (unix only).

Docs updated: IMPLEMENTATION_SPEC section 17 gained the ownership table, the ordered
transaction and the path rules; CONFIGURATION and the acceptance checklist gained the
operator-facing constraints and nine checks.

cargo test --workspace: 136 + 4 passed. clippy -D warnings clean.
This commit is contained in:
2026-08-06 21:07:55 +05:00
parent 549fbcae31
commit a895295701
5 changed files with 461 additions and 59 deletions
+2 -7
View File
@@ -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");
}