fix(swap): make the /etc/fstab rewrite transactional [P0]

Two release blockers, both in the most destructive operation the package performs.

1. An unreadable /etc/fstab was treated as an empty one.

   let content = fs::read_to_string(fstab_path).unwrap_or_default();

   PermissionDenied, an I/O error, a damaged filesystem and "file absent" all collapsed
   into an empty string. The rewrite then emitted a fstab containing only the managed swap
   line and installed it atomically and successfully, dropping the root and boot mounts.
   Consequence: a host that does not boot, guarded by one unwrap_or_default. The same
   masking was present in read_fstab_swap_entries, so an unreadable fstab was reported by
   status as "no swap entries", which reads as healthy drift-free state.

2. The backup was best-effort.

   let _ = fs::write(bak_path, &content);

   If it failed, the rewrite proceeded anyway. IMPLEMENTATION_SPEC and the acceptance
   checklist both asserted the backup exists. The scenario where the backup fails is
   exactly the scenario where it is needed.

New module fstab.rs owns the operation:

- read_required distinguishes NotFound from every other error and fails on both. A
  missing /etc/fstab on Debian is an anomaly, not a blank slate, so it is never created;
- a blank candidate is refused outright;
- the backup is mandatory and flushed before the temp file is created, so there is no
  window where the target is about to be replaced and no recovery copy exists;
- two copies are kept: .awg2-harness.bak is the state before the current rewrite,
  .awg2-harness.original is written once with create_new and holds the host as it was
  before this package first touched it;
- the temp file is a sibling of the target, created with create_new, sync_all'd, and
  inherits the original permissions; a stale temp from a killed run is removed explicitly
  rather than reused;
- the candidate is validated with findmnt --verify --tab-file before the swap. The step is
  skipped when findmnt cannot be spawned; a rejection is fatal;
- commit is an atomic rename followed by a directory fsync;
- after the rename the target is re-read and compared to what was written. A mismatch
  restores the backup and fails.

Also fixed: render_fstab_line interpolated the path raw. fstab fields are
whitespace-separated, so a space or tab in swap.path would shift every following column.
Paths now go through octal escaping (\040, \011, \012, \134).

Ten tests, all portable - the "unreadable but present" case is exercised by pointing the
reader at a directory, which fails as a non-NotFound error on every platform. Covered:
unreadable target is an error rather than an empty file, missing target is an error,
backup failure leaves the target byte-identical, blank candidate refused, unchanged
candidate is a no-op that writes no backup, the pristine copy survives repeated rewrites,
a stale temp does not block commit, and field escaping.

Docs updated: IMPLEMENTATION_SPEC section 17 gained the full rewrite contract, the
acceptance checklist gained seven checks including the two failure modes above.

cargo test --workspace: 124 + 4 passed, ten new. clippy -D warnings clean.
This commit is contained in:
2026-08-06 21:02:38 +05:00
parent 7254ebe9e4
commit 549fbcae31
5 changed files with 462 additions and 21 deletions
+18 -1
View File
@@ -535,10 +535,27 @@ Production-путь перезагрузки — внешний бинарь/с
- одна запись в `/etc/fstab` с настроенным приоритетом;
- `vm.swappiness` из конфига применён в runtime и сохранён persistently.
При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap-устройства и файлы выключаются через `swapoff`. Все не-комментарные swap-записи в `/etc/fstab` удаляются и заменяются одной управляемой записью AWG2; перед перезаписью создаётся резервная копия `/etc/fstab.awg2-harness.bak`. Обычные swap-файлы, не равные настроенному `swap.path`, могут быть удалены только после успешного `swapoff`. Блочные устройства не удаляются никогда — только выключаются и убираются из fstab. Операция обязана быть идемпотентной.
При `swap.enabled = true` AWG2 Harness владеет состоянием swap на хосте эксклюзивно. Целевое состояние — ровно одна активная swap-запись: `swap.path`. Все прочие активные swap-устройства и файлы выключаются через `swapoff`. Все не-комментарные swap-записи в `/etc/fstab` удаляются и заменяются одной управляемой записью AWG2. Блочные устройства не удаляются никогда — только выключаются и убираются из fstab. Операция обязана быть идемпотентной.
Создание файла: `fallocate`, при неудаче — fallback на `dd`.
### Перезапись `/etc/fstab`
Это самая разрушительная операция пакета, поэтому она транзакционная и вынесена в отдельный модуль `fstab.rs`. Контракт:
- ошибка чтения `/etc/fstab` **никогда** не интерпретируется как пустой файл. `PermissionDenied`, ошибка ввода-вывода и отсутствие файла различаются, и все три прекращают операцию. Отсутствие `/etc/fstab` на Debian-хосте считается аномалией, а не основанием создать его с нуля;
- пустой или состоящий из пробелов кандидат отклоняется;
- резервная копия обязательна. Если её не удалось записать, целевой файл не трогается вовсе — ситуация, в которой копия не пишется, и есть та, в которой она нужна;
- копий две: `/etc/fstab.awg2-harness.bak` перезаписывается перед каждой правкой и содержит состояние до неё, а `/etc/fstab.awg2-harness.original` создаётся один раз через `create_new` и хранит содержимое до первого вмешательства пакета;
- запись идёт во временный файл рядом с целевым, с `create_new`, `sync_all` и переносом прав с оригинала; устаревший временный файл от убитого процесса удаляется явно, а не переиспользуется;
- кандидат проверяется через `findmnt --verify --tab-file` до подмены. Если `findmnt` недоступен, шаг пропускается; если он отработал и отверг файл — операция прекращается;
- подмена — атомарный `rename`, после него каталог синхронизируется;
- после `rename` содержимое перечитывается и сверяется с записанным. Расхождение приводит к восстановлению из резервной копии.
Путь в записи fstab экранируется: поля разделяются пробелами, поэтому пробел или табуляция в пути сдвинули бы все последующие колонки.
Чтение swap-записей для `status` использует ту же строгую функцию чтения. Маскировка ошибки здесь показала бы «в fstab нет swap-записей», то есть здоровое состояние без drift, на хосте, где `/etc/fstab` вообще не читается.
## 18. Тестирование
Модульные тесты: