116b1ea91a4dc590420a7dd9d1c1d832fda1a5fc
3 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |