Commit Graph

3 Commits

Author SHA1 Message Date
founder 116b1ea91a fix(swap): roll back partial teardown, resize without a swapless gap, lock the disable path [P1]
Four defects from the follow-up audit.

1. Legacy migration swallowed every error.

   drop_legacy_managed_file was `let _ = file.remove(); Ok(())`, which tolerated not only
   the expected "that path is someone else's now" but also PermissionDenied, read errors
   and failed unlinks. If /etc/sysctl.d/99-awg2-harness.conf survived, it would keep
   winning over the new 90- file - it sorts later - while the reconcile reported success,
   so vm.swappiness would silently stay at the old value. The three cases are now
   distinguished: Absent is fine, Foreign warns and continues, Managed removes and any
   failure propagates.

2. The disable branches ran outside the lock.

   Both ensure_swap and ensure_sysctl_tuning checked `enabled` before acquiring their
   lock, so the cleanup path - which deletes managed files - was unsynchronised. A manual
   CLI invocation and the daemon's periodic reconcile could write and delete the same file
   concurrently. The lock is now taken first in both.

3. A failed teardown left foreign swaps half-disabled.

   disable_foreign_swaps aborted the loop on the first failure, so a host with two foreign
   swaps could end with the first one off and the second still up - a state nobody asked
   for and the error message did not describe. It now tracks what it disabled and
   re-enables it, in reverse order and with the original priority, before returning the
   error. The restore is best-effort and warns per entry rather than replacing the reported
   failure with a rollback failure.

4. Resizing dropped swap for the whole allocation.

   A live swapfile cannot be resized in place, and the previous code did swapoff, remove,
   fallocate/dd, mkswap, swapon. On a host with no other swap that is a swapless window
   spanning the slowest and most failure-prone part of the operation - on a machine that
   was given swap precisely because memory is tight, with no way back if any step failed.

   The replacement is now built and activated at a staging sibling while the old swap is
   still serving, and only then is the old file released. The remaining gap is
   swapoff/rename/swapon, which allocates nothing. A failure while staging removes the
   staging file and leaves the original swap untouched; a failure at the final rename says
   explicitly that the file is in place and can be activated with swapon.

   Related, same function: changing only swap.priority used to fall through to the full
   recreate path, destroying and rebuilding a correct multi-gigabyte file to change one
   integer. Size and priority are now separate decisions - a priority-only change is a
   reactivation.

The reconciliation is now decided by an explicit match over (size correct, currently
active) rather than a single `geometry_ok` flag, which is what hid the priority case.

Three tests added around the new decision surface: the priority-only case is not a resize,
the staging path is a sibling of the swapfile, and the managed swap is never part of the
foreign teardown set.

cargo test --workspace: 179 + 4 passed. clippy -D warnings clean.
2026-08-07 04:15:40 +05:00
founder c2cf194a68 fix(fs): refuse to write through symlinks, make fstab validation mandatory [P0/P1]
Three defects from the follow-up audit, all in code that runs as root.

1. The fstab backup was an arbitrary-file-overwrite primitive.

   write_file_synced used fs::File::create, which follows symlinks. A symlink planted at
   /etc/fstab.awg2-harness.bak would be followed and its target truncated and overwritten
   with the contents of /etc/fstab. Same class of bug as the sidecar write in swap.rs,
   which the next commit removes entirely.

   New module atomic.rs owns the write path for every file this package installs under
   /etc and /var/lib: stat the destination with symlink_metadata, refuse anything that is
   not a regular file or absent, create a sibling temp with create_new so a symlink at the
   temp path cannot be followed either, write, sync_all, set the mode, rename, flush the
   directory. fstab.rs and managed_file.rs both use it, which also removes the two
   near-identical write_atomic implementations they each carried.

2. Directory fsync failures were swallowed.

   Both modules document crash-durable writes, then ignored the result of syncing the
   parent directory - the step that makes the rename survive a crash. sync_parent_dir now
   returns Result and callers propagate it.

3. findmnt validation was optional in practice.

   verify_candidate had `Err(_) => Ok(())`, so a host without util-linux silently skipped
   the only structural check on a file that decides whether the machine boots. The package
   already depends on util-linux, so this is now a hard error, and findmnt joins the
   required host tools when swap.enabled is true - failing preflight rather than at rewrite
   time.

   To keep the transaction testable off Linux, commit takes the validator through an
   injected fn pointer; production always passes the real findmnt check. That seam also
   made it possible to test the rejection path directly, which was previously unreachable
   in tests.

Also in this commit, since they are the same class of defect:

- fstab rollback used fs::copy straight onto /etc/fstab, which can leave it truncated if
  it fails midway. It now writes the original - held in memory since begin, so the
  recovery path does not depend on re-reading the backup - through the same atomic
  no-follow path as a forward write.
- the pristine .original copy is checked to be a regular file when it already exists; a
  symlink or directory there means the recovery copy cannot be trusted, so the commit
  refuses instead of proceeding.
- ManagedFile decided ownership with `first.contains(marker)`, so a file whose first line
  read "# NOT Managed by awg2-harness sysctl_tuning" was treated as ours and became
  eligible for overwrite and removal. Now an exact match on the trimmed first line.
- systemctl daemon-reload after a successful fstab commit, since /etc/fstab feeds
  systemd's generators and not only mount. Best-effort with a warning: the change is
  already committed and verified, and failing the operation over a missed notification
  would be worse than a stale generator state the next boot resolves.

Thirteen tests added: symlinked destination refused with the victim asserted intact,
symlink at the temp path not followed, directory destination refused, mode applied, stale
temp cleared, rejected candidate leaves the target untouched, non-regular pristine copy
refused, symlinked backup path refused with the victim intact, exact marker matching
against three near-miss first lines, and trailing whitespace tolerated on our own marker.

cargo test --workspace: 171 + 4 passed. clippy -D warnings clean.
2026-08-07 04:04:01 +05:00
founder e9f0e29f75 refactor: one ownership model for every file written under /etc [P1]
Two modules wrote into /etc/sysctl.d and did it differently. sysctl_tuning refused to
touch a file whose first line lacked its marker and cleaned up on disable. swap wrote
/etc/sysctl.d/99-awg2-harness.conf with a different marker, no ownership check at all -
an administrator's file at that path was silently clobbered - and never removed it when
the module was turned off, so vm.swappiness stayed pinned forever.

New module managed_file.rs holds the rules once:

- a read that fails is never an absent file;
- ownership is Absent / Managed / Foreign, decided by the marker on the first line;
- write refuses Foreign, skips when the content already matches, and is otherwise atomic:
  temp sibling created with create_new, sync_all, mode 0644, rename, directory flush;
- remove refuses Foreign and treats Absent as success.

Both modules now go through it. sysctl_tuning loses its private write_atomic,
ensure_managed_or_absent and remove_managed_sysctl_file_at.

The swappiness file moves to /etc/sysctl.d/90-awg2-harness-swap.conf with the marker
"# Managed by awg2-harness swap", matching the 90- prefix every other managed file uses.
The old path is removed on first run of the new version, but only when it still carries
the previous "# Managed by awg2-harness" marker; a foreign file there is left alone and
does not fail the migration, since the current file lives elsewhere.

Disable semantics are now explicit: swap.enabled = false removes the managed file and
does nothing else. Runtime vm.swappiness is deliberately not reset - picking a value
would be inventing a policy - and active swap is not deactivated, because merely turning
a feature off should not trigger a destructive action. Previously ensure_swap returned
early and left the persisted file behind.

Also replaced a vacuous test: swappiness_conf_render_is_stable asserted that a string
literal equals a format! of the same literal, touching no production code at all. It now
exercises render_swappiness_conf, and a companion test proves the rendered file is
recognised as managed by the same marker check that guards overwriting it - otherwise
every reconcile would refuse to touch its own output.

Twelve tests: the three ownership states, refusal to overwrite and refusal to remove a
foreign file with content asserted intact, idempotent write, stale temp handling, an
unreadable path reported as an error, plus legacy migration in all three cases - ours,
foreign, absent.

cargo test --workspace: 148 + 4 passed. clippy -D warnings clean.
2026-08-06 22:50:19 +05:00