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
+6
View File
@@ -100,6 +100,12 @@
- [ ] `ensure-swap` создаёт файл по пути `swap.path` (в примере `/swapfile`) размером `swap.size_mb` (в примере 2048 MB). - [ ] `ensure-swap` создаёт файл по пути `swap.path` (в примере `/swapfile`) размером `swap.size_mb` (в примере 2048 MB).
- [ ] Права на swap-файл — `0600`. - [ ] Права на swap-файл — `0600`.
- [ ] Перед перезаписью `/etc/fstab` создана резервная копия `/etc/fstab.awg2-harness.bak`. - [ ] Перед перезаписью `/etc/fstab` создана резервная копия `/etc/fstab.awg2-harness.bak`.
- [ ] После первой правки существует `/etc/fstab.awg2-harness.original` и он **не** меняется при последующих запусках.
- [ ] Нечитаемый `/etc/fstab` (например, `chmod 000`) приводит к явной ошибке, а не к перезаписи файла одной managed-строкой.
- [ ] Отсутствующий `/etc/fstab` приводит к ошибке, а не к созданию нового файла.
- [ ] Если резервную копию записать не удалось, `/etc/fstab` остаётся неизменным.
- [ ] `status` при нечитаемом `/etc/fstab` показывает ошибку, а не «нет swap-записей».
- [ ] Путь swap с пробелом в имени попадает в `/etc/fstab` экранированным (`\040`) и корректно монтируется.
- [ ] Если до запуска был активен другой swap, после `ensure-swap` он выключен. - [ ] Если до запуска был активен другой swap, после `ensure-swap` он выключен.
- [ ] После `ensure-swap` активен ровно один swap: `/swapfile`. - [ ] После `ensure-swap` активен ровно один swap: `/swapfile`.
- [ ] Все старые swap entries удалены из `/etc/fstab`. - [ ] Все старые swap entries удалены из `/etc/fstab`.
+18 -1
View File
@@ -535,10 +535,27 @@ Production-путь перезагрузки — внешний бинарь/с
- одна запись в `/etc/fstab` с настроенным приоритетом; - одна запись в `/etc/fstab` с настроенным приоритетом;
- `vm.swappiness` из конфига применён в runtime и сохранён persistently. - `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`. Создание файла: `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. Тестирование ## 18. Тестирование
Модульные тесты: Модульные тесты:
+416
View File
@@ -0,0 +1,416 @@
//! Transactional rewrite of `/etc/fstab`.
//!
//! This is the most destructive operation in the package: a bad write produces a host
//! that does not boot. Every step is therefore explicit about failure.
//!
//! Two rules drive the design:
//!
//! 1. A read that fails is never treated as an empty file. Collapsing `PermissionDenied`,
//! an I/O error and "file absent" into an empty string would make the rewrite emit a
//! fstab containing only the managed swap entry, dropping the root and boot mounts.
//! 2. The backup is mandatory. If it cannot be written, nothing is changed at all - the
//! situation where the backup fails is exactly the situation where it is needed.
use std::{
fs,
io::{ErrorKind, Write},
path::{Path, PathBuf},
process::Command,
};
pub const FSTAB_PATH: &str = "/etc/fstab";
/// Rolling backup: the state immediately before the most recent rewrite.
const BACKUP_SUFFIX: &str = ".awg2-harness.bak";
/// Written once, with `create_new`, and never overwritten afterwards: the fstab as it was
/// before this package touched the host for the first time.
const ORIGINAL_SUFFIX: &str = ".awg2-harness.original";
const TEMP_SUFFIX: &str = ".awg2-harness.tmp";
/// Reads a file that must exist.
///
/// An absent `/etc/fstab` on a Debian host is an anomaly, not a blank slate, so it is an
/// error rather than a reason to synthesise one.
pub fn read_required(path: &Path) -> anyhow::Result<String> {
match fs::read_to_string(path) {
Ok(content) => Ok(content),
Err(e) if e.kind() == ErrorKind::NotFound => Err(anyhow::anyhow!(
"{} does not exist; refusing to create or rewrite it",
path.display()
)),
Err(e) => Err(anyhow::anyhow!("failed to read {}: {e}", path.display())),
}
}
/// Escapes the characters `fstab` treats as field separators.
///
/// Fields are whitespace-separated, so a path containing a space or tab would silently
/// shift every following column. `mount` decodes these octal escapes back.
pub fn escape_field(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
match ch {
' ' => out.push_str("\\040"),
'\t' => out.push_str("\\011"),
'\n' => out.push_str("\\012"),
'\\' => out.push_str("\\134"),
_ => out.push(ch),
}
}
out
}
/// An in-progress rewrite. Nothing is modified until [`FstabTransaction::commit`].
pub struct FstabTransaction {
target: PathBuf,
original: String,
}
impl FstabTransaction {
pub fn begin(target: &Path) -> anyhow::Result<Self> {
let original = read_required(target)?;
Ok(Self {
target: target.to_path_buf(),
original,
})
}
pub fn original(&self) -> &str {
&self.original
}
/// Applies `candidate` atomically, or leaves the target byte-for-byte unchanged.
///
/// Order matters: the backup is written and flushed before the temp file is created,
/// so there is no window in which the target is about to be replaced and no recovery
/// copy exists.
pub fn commit(self, candidate: &str) -> anyhow::Result<bool> {
if candidate == self.original {
return Ok(false);
}
if candidate.trim().is_empty() {
anyhow::bail!(
"refusing to write an empty {}: candidate content is blank",
self.target.display()
);
}
self.write_backups()?;
let temp = sibling_path(&self.target, TEMP_SUFFIX)?;
if let Err(e) = self.write_candidate(&temp, candidate) {
let _ = fs::remove_file(&temp);
return Err(e);
}
if let Err(e) = verify_candidate(&temp) {
let _ = fs::remove_file(&temp);
return Err(e);
}
fs::rename(&temp, &self.target).map_err(|e| {
let _ = fs::remove_file(&temp);
anyhow::anyhow!(
"failed to replace {} with {}: {e}",
self.target.display(),
temp.display()
)
})?;
sync_parent_dir(&self.target);
// Past the commit point: confirm what actually landed, and put the previous
// content back if it is not what we wrote.
match fs::read_to_string(&self.target) {
Ok(written) if written == candidate => Ok(true),
Ok(_) => {
self.restore_backup()?;
Err(anyhow::anyhow!(
"{} does not match the written content after rename; restored backup",
self.target.display()
))
}
Err(e) => {
self.restore_backup()?;
Err(anyhow::anyhow!(
"failed to re-read {} after rename: {e}; restored backup",
self.target.display()
))
}
}
}
fn write_backups(&self) -> anyhow::Result<()> {
let backup = sibling_path(&self.target, BACKUP_SUFFIX)?;
write_file_synced(&backup, &self.original)
.map_err(|e| anyhow::anyhow!("failed to write backup {}: {e}", backup.display()))?;
let original = sibling_path(&self.target, ORIGINAL_SUFFIX)?;
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&original)
{
Ok(mut f) => {
f.write_all(self.original.as_bytes()).map_err(|e| {
anyhow::anyhow!("failed to write pristine copy {}: {e}", original.display())
})?;
f.sync_all()
.map_err(|e| anyhow::anyhow!("failed to flush {}: {e}", original.display()))?;
}
// Already captured by an earlier run: that copy is the one to keep.
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => {
return Err(anyhow::anyhow!(
"failed to create pristine copy {}: {e}",
original.display()
));
}
}
Ok(())
}
fn write_candidate(&self, temp: &Path, candidate: &str) -> anyhow::Result<()> {
// Stale temp from a killed run must not be silently reused.
match fs::remove_file(temp) {
Ok(()) => {}
Err(e) if e.kind() == ErrorKind::NotFound => {}
Err(e) => {
return Err(anyhow::anyhow!(
"failed to clear stale {}: {e}",
temp.display()
));
}
}
let mut f = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(temp)
.map_err(|e| anyhow::anyhow!("failed to create {}: {e}", temp.display()))?;
f.write_all(candidate.as_bytes())
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", temp.display()))?;
f.sync_all()
.map_err(|e| anyhow::anyhow!("failed to flush {}: {e}", temp.display()))?;
drop(f);
copy_permissions(&self.target, temp)?;
Ok(())
}
fn restore_backup(&self) -> anyhow::Result<()> {
let backup = sibling_path(&self.target, BACKUP_SUFFIX)?;
fs::copy(&backup, &self.target).map_err(|e| {
anyhow::anyhow!(
"rollback failed: could not restore {} from {}: {e}",
self.target.display(),
backup.display()
)
})?;
sync_parent_dir(&self.target);
Ok(())
}
}
fn sibling_path(target: &Path, suffix: &str) -> anyhow::Result<PathBuf> {
let name = target
.file_name()
.ok_or_else(|| anyhow::anyhow!("invalid fstab path: {}", target.display()))?;
let mut sibling = name.to_os_string();
sibling.push(suffix);
Ok(target.with_file_name(sibling))
}
fn write_file_synced(path: &Path, content: &str) -> std::io::Result<()> {
let mut f = fs::File::create(path)?;
f.write_all(content.as_bytes())?;
f.sync_all()
}
/// Asks `findmnt` whether the candidate parses as a valid tab file.
///
/// Skipped when `findmnt` cannot be spawned; when it does run, a rejection is fatal.
fn verify_candidate(temp: &Path) -> anyhow::Result<()> {
let out = Command::new("findmnt")
.args(["--verify", "--tab-file"])
.arg(temp)
.output();
match out {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(anyhow::anyhow!(
"candidate fstab rejected by findmnt --verify: {}{}",
String::from_utf8_lossy(&o.stdout).trim(),
String::from_utf8_lossy(&o.stderr).trim()
)),
Err(_) => Ok(()),
}
}
#[cfg(unix)]
fn copy_permissions(from: &Path, to: &Path) -> anyhow::Result<()> {
let md = fs::metadata(from)
.map_err(|e| anyhow::anyhow!("failed to stat {}: {e}", from.display()))?;
fs::set_permissions(to, md.permissions())
.map_err(|e| anyhow::anyhow!("failed to set permissions on {}: {e}", to.display()))
}
#[cfg(not(unix))]
fn copy_permissions(_from: &Path, _to: &Path) -> anyhow::Result<()> {
Ok(())
}
/// Best-effort: the rename already made the change visible, and there is no useful
/// recovery if flushing the directory entry fails.
#[cfg(unix)]
fn sync_parent_dir(target: &Path) {
if let Some(dir) = target.parent()
&& let Ok(f) = fs::File::open(dir)
{
let _ = f.sync_all();
}
}
#[cfg(not(unix))]
fn sync_parent_dir(_target: &Path) {}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("awg2_fstab_{}_{}", name, std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("scratch dir");
dir
}
fn seed(dir: &Path, content: &str) -> PathBuf {
let p = dir.join("fstab");
fs::write(&p, content).expect("seed");
p
}
const SAMPLE: &str = "UUID=aaaa / ext4 defaults 0 1\nUUID=bbbb /boot ext4 defaults 0 2\n";
/// The regression that motivates this module: an unreadable fstab must never be
/// treated as empty, because the rewrite would then drop every real mount.
#[test]
fn unreadable_target_is_an_error_not_an_empty_file() {
let dir = scratch("unreadable");
// A directory is readable as a path but never as a string, on every platform.
let err = read_required(&dir).expect_err("must fail");
assert!(!err.to_string().contains("does not exist"));
}
#[test]
fn missing_target_is_an_error() {
let dir = scratch("missing");
let err = read_required(&dir.join("nope")).expect_err("must fail");
assert!(err.to_string().contains("does not exist"));
}
#[test]
fn begin_fails_when_target_is_missing() {
let dir = scratch("begin_missing");
assert!(FstabTransaction::begin(&dir.join("nope")).is_err());
}
#[test]
fn commit_writes_candidate_and_backup() {
let dir = scratch("commit");
let target = seed(&dir, SAMPLE);
let candidate = format!("{SAMPLE}/swapfile none swap sw,pri=1 0 0\n");
let tx = FstabTransaction::begin(&target).expect("begin");
assert!(tx.commit(&candidate).expect("commit"));
assert_eq!(fs::read_to_string(&target).expect("read"), candidate);
let backup = dir.join(format!("fstab{BACKUP_SUFFIX}"));
assert_eq!(fs::read_to_string(backup).expect("backup"), SAMPLE);
let original = dir.join(format!("fstab{ORIGINAL_SUFFIX}"));
assert_eq!(fs::read_to_string(original).expect("original"), SAMPLE);
}
#[test]
fn unchanged_candidate_is_a_no_op() {
let dir = scratch("noop");
let target = seed(&dir, SAMPLE);
let tx = FstabTransaction::begin(&target).expect("begin");
assert!(!tx.commit(SAMPLE).expect("commit"));
assert!(!dir.join(format!("fstab{BACKUP_SUFFIX}")).exists());
}
#[test]
fn empty_candidate_is_refused() {
let dir = scratch("empty");
let target = seed(&dir, SAMPLE);
let tx = FstabTransaction::begin(&target).expect("begin");
let err = tx.commit(" \n").expect_err("must refuse");
assert!(err.to_string().contains("refusing to write an empty"));
assert_eq!(fs::read_to_string(&target).expect("read"), SAMPLE);
}
/// The pristine copy records the host as it was before the package first ran, so a
/// later rewrite must not overwrite it with an already-managed fstab.
#[test]
fn pristine_copy_is_written_once() {
let dir = scratch("pristine");
let target = seed(&dir, SAMPLE);
let first = format!("{SAMPLE}/swapfile none swap sw,pri=1 0 0\n");
FstabTransaction::begin(&target)
.expect("begin")
.commit(&first)
.expect("commit");
let second = format!("{SAMPLE}/swapfile none swap sw,pri=5 0 0\n");
FstabTransaction::begin(&target)
.expect("begin")
.commit(&second)
.expect("commit");
let original = dir.join(format!("fstab{ORIGINAL_SUFFIX}"));
assert_eq!(fs::read_to_string(original).expect("original"), SAMPLE);
let backup = dir.join(format!("fstab{BACKUP_SUFFIX}"));
assert_eq!(fs::read_to_string(backup).expect("backup"), first);
}
#[test]
fn stale_temp_file_does_not_block_commit() {
let dir = scratch("stale");
let target = seed(&dir, SAMPLE);
fs::write(dir.join(format!("fstab{TEMP_SUFFIX}")), "garbage").expect("stale temp");
let candidate = format!("{SAMPLE}/swapfile none swap sw,pri=1 0 0\n");
FstabTransaction::begin(&target)
.expect("begin")
.commit(&candidate)
.expect("commit");
assert_eq!(fs::read_to_string(&target).expect("read"), candidate);
assert!(!dir.join(format!("fstab{TEMP_SUFFIX}")).exists());
}
/// Backup lives in the target directory: an unwritable directory must stop the
/// transaction before the target is touched.
#[test]
fn backup_failure_leaves_target_untouched() {
let dir = scratch("backup_fail");
let target = seed(&dir, SAMPLE);
// Occupy the backup name with a directory so writing the backup cannot succeed.
fs::create_dir(dir.join(format!("fstab{BACKUP_SUFFIX}"))).expect("blocker");
let tx = FstabTransaction::begin(&target).expect("begin");
let err = tx
.commit(&format!("{SAMPLE}/swapfile none swap sw,pri=1 0 0\n"))
.expect_err("must fail");
assert!(err.to_string().contains("failed to write backup"));
assert_eq!(fs::read_to_string(&target).expect("read"), SAMPLE);
}
#[test]
fn escapes_fstab_field_separators() {
assert_eq!(escape_field("/var/my swap"), "/var/my\\040swap");
assert_eq!(escape_field("/a\tb"), "/a\\011b");
assert_eq!(escape_field("/a\\b"), "/a\\134b");
assert_eq!(escape_field("/swapfile"), "/swapfile");
}
}
+1
View File
@@ -4,6 +4,7 @@ mod daemon;
mod docker; mod docker;
mod error; mod error;
mod firewall; mod firewall;
mod fstab;
mod iface; mod iface;
mod lock; mod lock;
mod netns; mod netns;
+21 -20
View File
@@ -1,6 +1,5 @@
use std::{ use std::{
fs, fs,
io::Write,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
time::Duration, time::Duration,
@@ -11,7 +10,11 @@ use std::os::unix::fs::PermissionsExt;
use anyhow::Context; use anyhow::Context;
use crate::{config::Config, lock::FileLock}; use crate::{
config::Config,
fstab::{self, FSTAB_PATH, FstabTransaction, escape_field},
lock::FileLock,
};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveSwap { pub struct ActiveSwap {
@@ -287,22 +290,9 @@ fn create_swapfile(path: &Path, size_bytes: u64) -> anyhow::Result<()> {
} }
fn rewrite_fstab_exclusive(desired: &DesiredSwap) -> anyhow::Result<()> { fn rewrite_fstab_exclusive(desired: &DesiredSwap) -> anyhow::Result<()> {
let fstab_path = "/etc/fstab"; let tx = FstabTransaction::begin(Path::new(FSTAB_PATH))?;
let bak_path = "/etc/fstab.awg2-harness.bak"; let rewritten = rewrite_fstab_content(tx.original(), &desired.path, desired.priority);
let tmp_path = "/etc/fstab.awg2-harness.tmp"; tx.commit(&rewritten)?;
let content = fs::read_to_string(fstab_path).unwrap_or_default();
let rewritten = rewrite_fstab_content(&content, &desired.path, desired.priority);
if rewritten == content {
return Ok(());
}
let _ = fs::write(bak_path, &content);
let mut f = fs::File::create(tmp_path)?;
f.write_all(rewritten.as_bytes())?;
f.sync_all()?;
drop(f);
fs::rename(tmp_path, fstab_path)?;
Ok(()) Ok(())
} }
@@ -332,8 +322,14 @@ fn rewrite_fstab_content(content: &str, desired_path: &Path, priority: i32) -> S
s s
} }
/// Path goes through fstab escaping: fields are whitespace-separated, so an unescaped
/// space in the path would shift every following column.
fn render_fstab_line(path: &Path, priority: i32) -> String { fn render_fstab_line(path: &Path, priority: i32) -> String {
format!("{} none swap sw,pri={} 0 0\n", path.display(), priority) format!(
"{} none swap sw,pri={} 0 0\n",
escape_field(&path.to_string_lossy()),
priority
)
} }
fn enable_desired_swap(desired: &DesiredSwap) -> anyhow::Result<()> { fn enable_desired_swap(desired: &DesiredSwap) -> anyhow::Result<()> {
@@ -472,8 +468,13 @@ fn verify_exclusive_swap_state(desired: &DesiredSwap) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
/// Reads swap entries for inspection.
///
/// Strict for the same reason the rewrite is: masking a read failure here would report
/// "no swap entries in fstab", which reads as healthy drift-free state on a host whose
/// fstab could not be read at all.
fn read_fstab_swap_entries(path: &str) -> anyhow::Result<Vec<String>> { fn read_fstab_swap_entries(path: &str) -> anyhow::Result<Vec<String>> {
let content = fs::read_to_string(path).unwrap_or_default(); let content = fstab::read_required(Path::new(path))?;
let mut out = Vec::new(); let mut out = Vec::new();
for line in content.lines() { for line in content.lines() {
let t = line.trim(); let t = line.trim();