fa44ffbb24
1. The state directory was created too late and never verified. ensure_state_dir only ran when the ownership record was written, well after assert_swap_path_is_safe had already required every parent of swap.path to exist. On a clean host that meant a perfectly legal /var/lib/awg2-harness/swapfile was rejected because the directory this package is supposed to create did not exist yet. Worse, when the directory did exist the check only confirmed it was a directory. A pre-existing /var/lib/awg2-harness that was group- or world-writable defeated the whole point of restricting swap.path to root-owned roots: another user could still swap the swapfile out from under a root-owned fallocate or mkswap. ensure_state_dir now runs first in ensure_swap and asserts what the restriction actually depends on: the parent is a real directory and not a symlink, the state directory itself is not a symlink, it is owned by uid 0, and it is not writable by group or others. A mode that is merely narrower than 0700 is tightened rather than rejected; anything group- or world-writable is a hard error, because the safety argument for the whole allow-list rests on it. The units also declare StateDirectory=awg2-harness with mode 0700 and RuntimeDirectory=awg2-harness, so in the common case systemd creates and owns both and the binary only has to verify. The runtime checks stay regardless: the CLI subcommands run outside the service lifecycle. 2. The fstab copies could be more permissive than the file they mirror. The backup and pristine copies were written at a hardcoded 0644 with a comment claiming they must not be wider than the original. On a host where /etc/fstab is 0600 they were, and the rollback then handed that wider mode to the live file too. FstabTransaction now captures the live mode when it opens and uses it for the backup, the pristine copy, the candidate and the rollback. One test added, unix-only since it asserts a mode: a 0600 fstab produces 0600 copies and stays 0600 after the commit. cargo test --workspace: 180 + 4 passed. clippy -D warnings clean. Unit files not verified here - no systemd on this host; tools/build.sh runs systemd-analyze verify on the build machine.
1767 lines
62 KiB
Rust
1767 lines
62 KiB
Rust
use std::{
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
process::Command,
|
|
time::Duration,
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
use anyhow::Context;
|
|
|
|
use crate::{
|
|
config::Config,
|
|
fstab::{self, FSTAB_PATH, FstabTransaction, escape_field},
|
|
lock::FileLock,
|
|
managed_file::{ManagedFile, Ownership},
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ActiveSwap {
|
|
pub name: PathBuf,
|
|
pub kind: String,
|
|
pub size_bytes: u64,
|
|
pub priority: i32,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct DesiredSwap {
|
|
path: PathBuf,
|
|
size_bytes: u64,
|
|
priority: i32,
|
|
swappiness: u8,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SwapStatus {
|
|
pub enabled: bool,
|
|
pub desired_path: PathBuf,
|
|
pub active_entries: Vec<ActiveSwap>,
|
|
pub desired_active: bool,
|
|
pub exclusive: bool,
|
|
pub fstab_swap_entries: Vec<String>,
|
|
pub fstab_exclusive: bool,
|
|
pub current_swappiness: Option<u8>,
|
|
pub persisted_swappiness: Option<u8>,
|
|
pub drift_reason: Option<String>,
|
|
}
|
|
|
|
/// Root-only directory holding this package's persistent state.
|
|
pub const STATE_DIR: &str = "/var/lib/awg2-harness";
|
|
const STATE_DIR_PREFIX: &str = "/var/lib/awg2-harness/";
|
|
/// Historical location, kept so existing deployments do not need a config change.
|
|
const DEFAULT_SWAP_PATH: &str = "/swapfile";
|
|
/// Records which swapfile we created, by identity rather than by location.
|
|
const SWAP_OWNERSHIP_PATH: &str = "/var/lib/awg2-harness/swap-ownership.conf";
|
|
const SWAP_OWNERSHIP_MARKER: &str = "# Managed by awg2-harness swap-ownership";
|
|
const SWAP_OWNERSHIP_VERSION: u32 = 1;
|
|
/// Sidecar written by earlier versions next to the swapfile. Only ever removed, and only
|
|
/// when it carries the marker that version wrote.
|
|
const LEGACY_OWNERSHIP_SUFFIX: &str = ".awg2-harness-owned";
|
|
const LEGACY_OWNERSHIP_MARKER: &str = "# Managed by awg2-harness swap";
|
|
/// Replacement being built while the current swapfile is still active.
|
|
const STAGING_SUFFIX: &str = ".awg2-harness.new";
|
|
/// The outgoing swapfile, kept until the replacement is confirmed active.
|
|
const PREVIOUS_SUFFIX: &str = ".awg2-harness.previous";
|
|
|
|
/// Identity of a file on disk, independent of its name.
|
|
///
|
|
/// Path alone is not identity: a file can be deleted and a different one created at the
|
|
/// same path. `(device, inode)` is what actually distinguishes the swapfile we made from
|
|
/// whatever now occupies its path.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct FileIdentity {
|
|
device: u64,
|
|
inode: u64,
|
|
}
|
|
|
|
/// What we recorded when we created the managed swapfile.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct SwapOwnershipRecord {
|
|
version: u32,
|
|
path: PathBuf,
|
|
identity: FileIdentity,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum SwapfileOwnership {
|
|
/// Nothing at the path: safe to create.
|
|
Missing,
|
|
/// Ours, confirmed by identity, and already the requested size.
|
|
ManagedValid,
|
|
/// Ours, confirmed by identity, but the size differs: safe to recreate.
|
|
ManagedWrongSize,
|
|
/// A file exists and we have no record of creating it. Never touched.
|
|
Foreign,
|
|
/// We have a record, but the file at the path is not the one we created - it was
|
|
/// replaced behind our back. Refused exactly like `Foreign`; kept separate so the
|
|
/// error can say which situation the operator is in.
|
|
StaleOwnership,
|
|
}
|
|
|
|
impl SwapfileOwnership {
|
|
fn is_managed(self) -> bool {
|
|
matches!(self, Self::ManagedValid | Self::ManagedWrongSize)
|
|
}
|
|
}
|
|
|
|
/// Persisted `vm.swappiness`, owned by the swap module.
|
|
///
|
|
/// Lives at `90-` like every other file this package manages. It used to be written to
|
|
/// `/etc/sysctl.d/99-awg2-harness.conf` with a different marker, no ownership check before
|
|
/// overwriting, and no cleanup on disable.
|
|
const SWAPPINESS_CONF_PATH: &str = "/etc/sysctl.d/90-awg2-harness-swap.conf";
|
|
const SWAPPINESS_MARKER: &str = "# Managed by awg2-harness swap";
|
|
/// Written by versions before the unified managed-file handling.
|
|
const LEGACY_SWAPPINESS_CONF_PATH: &str = "/etc/sysctl.d/99-awg2-harness.conf";
|
|
const LEGACY_SWAPPINESS_MARKER: &str = "# Managed by awg2-harness";
|
|
|
|
fn swappiness_file() -> ManagedFile {
|
|
ManagedFile::new(SWAPPINESS_CONF_PATH, SWAPPINESS_MARKER)
|
|
}
|
|
|
|
fn legacy_swappiness_file() -> ManagedFile {
|
|
ManagedFile::new(LEGACY_SWAPPINESS_CONF_PATH, LEGACY_SWAPPINESS_MARKER)
|
|
}
|
|
|
|
fn render_swappiness_conf(v: u8) -> String {
|
|
format!("{SWAPPINESS_MARKER}\n# Source: /etc/awg2-harness/config.toml\nvm.swappiness = {v}\n")
|
|
}
|
|
|
|
/// Drops the pre-rename file, but only when it still carries the old marker.
|
|
///
|
|
/// A file an administrator put at that path is left alone, which is why the removal goes
|
|
/// through the same ownership check as everything else.
|
|
fn migrate_legacy_swappiness_file() -> anyhow::Result<()> {
|
|
drop_legacy_managed_file(&legacy_swappiness_file())
|
|
}
|
|
|
|
/// Removes a file left by an older version.
|
|
///
|
|
/// Only a foreign file is tolerated - that path belongs to someone else now and the
|
|
/// current file lives elsewhere, so there is nothing to migrate. Everything else is
|
|
/// reported: swallowing `PermissionDenied` or a failed unlink would leave
|
|
/// `/etc/sysctl.d/99-awg2-harness.conf` in place, and it sorts *after* the new `90-` file,
|
|
/// so the stale value would keep winning while the reconcile reported success.
|
|
fn drop_legacy_managed_file(file: &ManagedFile) -> anyhow::Result<()> {
|
|
match file.ownership()? {
|
|
Ownership::Absent => Ok(()),
|
|
Ownership::Foreign => {
|
|
tracing::warn!(
|
|
target: "swap",
|
|
"legacy path {} holds a file we do not own; leaving it alone",
|
|
file.path().display()
|
|
);
|
|
Ok(())
|
|
}
|
|
Ownership::Managed => file.remove().map(|_| ()),
|
|
}
|
|
}
|
|
|
|
/// Brings host swap to the desired state as a transaction.
|
|
///
|
|
/// Ordering is the point: the managed swap is created, activated and verified *before*
|
|
/// any foreign swap is disabled. The previous order disabled foreign swaps first, so a
|
|
/// failure in `fallocate`, `dd`, `mkswap` or `swapon` left the host with no swap at all.
|
|
pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> {
|
|
// Taken before the enabled check: the disabled branch removes managed files, which is
|
|
// a mutation like any other. Leaving it outside the lock let a manual CLI run and the
|
|
// daemon's reconcile write and delete the same file at the same time.
|
|
let _lock = FileLock::acquire_timeout("/run/awg2-harness/swap.lock", Duration::from_secs(30))?;
|
|
|
|
if !cfg.swap.enabled {
|
|
return remove_managed_swap_state();
|
|
}
|
|
|
|
// 1. Preflight. The state directory is created and verified first: on a clean host
|
|
// /var/lib/awg2-harness does not exist yet, and the path check below requires every
|
|
// parent to be present.
|
|
ensure_state_dir()?;
|
|
let desired = DesiredSwap::from_config(cfg)?;
|
|
assert_swap_path_is_safe(&desired.path)?;
|
|
let active_before = read_active_swaps()?;
|
|
|
|
// 2-3. Prepare and activate the managed swap. Activation belongs to this step so that
|
|
// every transition can roll back while it still owns both the old and the new file.
|
|
ensure_desired_swapfile(&desired, &active_before)?;
|
|
|
|
// 4. Confirm it is really live before touching anything else.
|
|
let active_after = read_active_swaps()?;
|
|
if !active_after.iter().any(|s| s.name == desired.path) {
|
|
anyhow::bail!(
|
|
"managed swap {} did not become active; foreign swap left untouched",
|
|
desired.path.display()
|
|
);
|
|
}
|
|
|
|
// 5. Only now is it safe to take the others down.
|
|
disable_foreign_swaps(&desired, &active_after)?;
|
|
|
|
// 6. Persist and verify.
|
|
rewrite_fstab_exclusive(&desired)?;
|
|
apply_swappiness(desired.swappiness)?;
|
|
verify_exclusive_swap_state(&desired)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Undoes the persistent part of the module when `swap.enabled = false`.
|
|
///
|
|
/// Only the managed file is removed. The runtime `vm.swappiness` is deliberately left as
|
|
/// it is: forcing an arbitrary value would be inventing a policy the operator never asked
|
|
/// for. The kernel default applies again after reboot, and `status` reports that the value
|
|
/// is no longer managed. Active swap is not disabled either - turning it off would be a
|
|
/// destructive action triggered by merely disabling a feature.
|
|
fn remove_managed_swap_state() -> anyhow::Result<()> {
|
|
migrate_legacy_swappiness_file()?;
|
|
swappiness_file().remove()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Lexical validation of `swap.path`, with no filesystem access.
|
|
///
|
|
/// Kept side-effect free so `validate-config` can run it on a host where the file does not
|
|
/// exist yet. Operates on the string rather than [`Path`] because the target is always a
|
|
/// Linux path, regardless of where validation runs.
|
|
pub fn validate_swap_path_syntax(raw: &str) -> anyhow::Result<()> {
|
|
if !raw.starts_with('/') {
|
|
anyhow::bail!("swap.path must be absolute: {raw}");
|
|
}
|
|
if raw.ends_with('/') {
|
|
anyhow::bail!("swap.path must not be a directory path: {raw}");
|
|
}
|
|
if let Some(bad) = raw
|
|
.chars()
|
|
.find(|c| c.is_whitespace() || c.is_control() || *c == '\\')
|
|
{
|
|
anyhow::bail!(
|
|
"swap.path must not contain whitespace or control characters: {raw:?} contains {bad:?}"
|
|
);
|
|
}
|
|
for component in raw.split('/') {
|
|
if component == "." || component == ".." {
|
|
anyhow::bail!("swap.path must not contain '.' or '..' components: {raw}");
|
|
}
|
|
}
|
|
// Allow-list, not deny-list. A deny-list only rules out the paths someone thought of;
|
|
// what actually matters is whether the parent directory can be written by anyone but
|
|
// root. Between the preflight check and `fallocate`/`dd`/`mkswap` running, a
|
|
// world-writable parent lets an unprivileged user swap the final component for a
|
|
// symlink and redirect a root-owned operation - a TOCTOU that no amount of stat-ing
|
|
// before use can close. Restricting the location to directories only root can write
|
|
// removes the window instead of trying to detect it.
|
|
if raw == DEFAULT_SWAP_PATH {
|
|
return Ok(());
|
|
}
|
|
if let Some(rest) = raw.strip_prefix(STATE_DIR_PREFIX)
|
|
&& !rest.is_empty()
|
|
&& !rest.contains('/')
|
|
{
|
|
return Ok(());
|
|
}
|
|
anyhow::bail!(
|
|
"swap.path must be {DEFAULT_SWAP_PATH} or a file directly under {STATE_DIR}; got {raw}. \
|
|
Both are root-owned locations; an arbitrary path can have a parent directory that \
|
|
another user may write, which would let them redirect the swapfile operations"
|
|
);
|
|
}
|
|
|
|
/// Filesystem-level checks performed just before the path is used.
|
|
///
|
|
/// A symlink anywhere in the chain, not only at the final component, can redirect the
|
|
/// whole operation at a file we never intended to touch.
|
|
fn assert_swap_path_is_safe(path: &Path) -> anyhow::Result<()> {
|
|
validate_swap_path_syntax(&path.to_string_lossy())?;
|
|
assert_path_chain_is_safe(path)
|
|
}
|
|
|
|
/// Walks the parent chain and refuses any symlink or missing directory in it.
|
|
///
|
|
/// Split from the syntax check so it can be exercised independently of the Linux path
|
|
/// grammar.
|
|
fn assert_path_chain_is_safe(path: &Path) -> anyhow::Result<()> {
|
|
let mut walked = PathBuf::new();
|
|
let components: Vec<_> = path.components().collect();
|
|
for component in components.iter().take(components.len().saturating_sub(1)) {
|
|
walked.push(component);
|
|
match fs::symlink_metadata(&walked) {
|
|
Ok(md) if md.file_type().is_symlink() => {
|
|
anyhow::bail!(
|
|
"swap.path traverses a symlink at {}; refusing to continue",
|
|
walked.display()
|
|
);
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
anyhow::bail!(
|
|
"swap.path parent directory does not exist: {}",
|
|
walked.display()
|
|
);
|
|
}
|
|
Err(e) => {
|
|
anyhow::bail!("failed to stat {}: {e}", walked.display());
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ownership_file() -> ManagedFile {
|
|
ManagedFile::new(SWAP_OWNERSHIP_PATH, SWAP_OWNERSHIP_MARKER)
|
|
}
|
|
|
|
fn render_ownership_record(rec: &SwapOwnershipRecord) -> String {
|
|
format!(
|
|
"{SWAP_OWNERSHIP_MARKER}\nversion = {}\npath = {}\ndevice = {}\ninode = {}\n",
|
|
rec.version,
|
|
rec.path.display(),
|
|
rec.identity.device,
|
|
rec.identity.inode
|
|
)
|
|
}
|
|
|
|
fn parse_ownership_record(raw: &str) -> anyhow::Result<SwapOwnershipRecord> {
|
|
let mut version = None;
|
|
let mut path = None;
|
|
let mut device = None;
|
|
let mut inode = None;
|
|
for line in raw.lines() {
|
|
let t = line.trim();
|
|
if t.is_empty() || t.starts_with('#') {
|
|
continue;
|
|
}
|
|
let Some((k, v)) = t.split_once('=') else {
|
|
continue;
|
|
};
|
|
let v = v.trim();
|
|
match k.trim() {
|
|
"version" => version = v.parse::<u32>().ok(),
|
|
"path" => path = Some(PathBuf::from(v)),
|
|
"device" => device = v.parse::<u64>().ok(),
|
|
"inode" => inode = v.parse::<u64>().ok(),
|
|
_ => {}
|
|
}
|
|
}
|
|
let (Some(version), Some(path), Some(device), Some(inode)) = (version, path, device, inode)
|
|
else {
|
|
anyhow::bail!("swap ownership record is incomplete or malformed");
|
|
};
|
|
if version != SWAP_OWNERSHIP_VERSION {
|
|
anyhow::bail!("unsupported swap ownership record version: {version}");
|
|
}
|
|
Ok(SwapOwnershipRecord {
|
|
version,
|
|
path,
|
|
identity: FileIdentity { device, inode },
|
|
})
|
|
}
|
|
|
|
/// Decides ownership from a recorded state and the file actually on disk.
|
|
///
|
|
/// Pure on purpose: the interesting cases - a stale record pointing at a file someone
|
|
/// else created, a record for a different path - are the ones that must never authorise a
|
|
/// delete, and keeping the decision free of filesystem access makes them directly
|
|
/// testable.
|
|
fn classify_ownership(
|
|
recorded: Option<&SwapOwnershipRecord>,
|
|
actual_path: &Path,
|
|
actual: Option<(FileIdentity, u64)>,
|
|
desired_size: u64,
|
|
) -> SwapfileOwnership {
|
|
let Some((identity, size)) = actual else {
|
|
return SwapfileOwnership::Missing;
|
|
};
|
|
let Some(rec) = recorded else {
|
|
return SwapfileOwnership::Foreign;
|
|
};
|
|
if rec.path != actual_path {
|
|
// The record describes a different managed path; this file is not ours.
|
|
return SwapfileOwnership::Foreign;
|
|
}
|
|
if rec.identity != identity {
|
|
// Right path, wrong file: ours was replaced. Never a licence to delete.
|
|
return SwapfileOwnership::StaleOwnership;
|
|
}
|
|
if size == desired_size {
|
|
SwapfileOwnership::ManagedValid
|
|
} else {
|
|
SwapfileOwnership::ManagedWrongSize
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn identity_of(md: &fs::Metadata) -> FileIdentity {
|
|
use std::os::unix::fs::MetadataExt;
|
|
FileIdentity {
|
|
device: md.dev(),
|
|
inode: md.ino(),
|
|
}
|
|
}
|
|
|
|
/// Windows has no stable dev/inode pair through `Metadata`; this target exists only so the
|
|
/// pure logic above stays testable off Linux.
|
|
#[cfg(not(unix))]
|
|
fn identity_of(_md: &fs::Metadata) -> FileIdentity {
|
|
FileIdentity {
|
|
device: 0,
|
|
inode: 0,
|
|
}
|
|
}
|
|
|
|
fn read_ownership_record() -> anyhow::Result<Option<SwapOwnershipRecord>> {
|
|
let Some(raw) = ownership_file().read()? else {
|
|
return Ok(None);
|
|
};
|
|
parse_ownership_record(&raw).map(Some)
|
|
}
|
|
|
|
fn inspect_swapfile_ownership(path: &Path, desired_size: u64) -> anyhow::Result<SwapfileOwnership> {
|
|
let actual = match fs::symlink_metadata(path) {
|
|
Ok(md) => {
|
|
if md.file_type().is_symlink() {
|
|
anyhow::bail!("swap.path is a symlink: {}", path.display());
|
|
}
|
|
if !md.file_type().is_file() {
|
|
anyhow::bail!("swap.path is not a regular file: {}", path.display());
|
|
}
|
|
Some((identity_of(&md), md.len()))
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
|
Err(e) => anyhow::bail!("failed to stat {}: {e}", path.display()),
|
|
};
|
|
|
|
let recorded = read_ownership_record()?;
|
|
Ok(classify_ownership(
|
|
recorded.as_ref(),
|
|
path,
|
|
actual,
|
|
desired_size,
|
|
))
|
|
}
|
|
|
|
/// Records the identity of the swapfile we just created.
|
|
///
|
|
/// Written after the file exists, so the recorded `(device, inode)` describes the real
|
|
/// file. Until this succeeds the swapfile is not considered managed.
|
|
fn record_ownership(path: &Path) -> anyhow::Result<()> {
|
|
ensure_state_dir()?;
|
|
let md = fs::metadata(path)
|
|
.map_err(|e| anyhow::anyhow!("failed to stat {} after create: {e}", path.display()))?;
|
|
let rec = SwapOwnershipRecord {
|
|
version: SWAP_OWNERSHIP_VERSION,
|
|
path: path.to_path_buf(),
|
|
identity: identity_of(&md),
|
|
};
|
|
ownership_file().write(&render_ownership_record(&rec))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Creates the state directory if needed and asserts it is safe to keep state in.
|
|
///
|
|
/// Run before `swap.path` is validated, for two reasons. On a clean host
|
|
/// `/var/lib/awg2-harness` does not exist yet, so a perfectly legal
|
|
/// `/var/lib/awg2-harness/swapfile` would be rejected by the parent-chain check for a
|
|
/// directory this package is supposed to create. And an existing directory has to be
|
|
/// verified, not just assumed: if it is group- or world-writable, the whole point of
|
|
/// restricting `swap.path` to root-owned roots is gone, because another user could swap
|
|
/// the swapfile out from under a root-owned operation.
|
|
pub fn ensure_state_dir() -> anyhow::Result<()> {
|
|
let dir = Path::new(STATE_DIR);
|
|
|
|
// The parent must exist and must not be a symlink, or the directory we create could
|
|
// land somewhere else entirely.
|
|
if let Some(parent) = dir.parent() {
|
|
match fs::symlink_metadata(parent) {
|
|
Ok(md) if md.file_type().is_symlink() => {
|
|
anyhow::bail!("{} is a symlink; refusing to use it", parent.display())
|
|
}
|
|
Ok(md) if !md.file_type().is_dir() => {
|
|
anyhow::bail!("{} is not a directory", parent.display())
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) => anyhow::bail!("failed to stat {}: {e}", parent.display()),
|
|
}
|
|
}
|
|
|
|
match fs::symlink_metadata(dir) {
|
|
Ok(md) if md.file_type().is_symlink() => {
|
|
anyhow::bail!("{STATE_DIR} is a symlink; refusing to use it")
|
|
}
|
|
Ok(md) if !md.file_type().is_dir() => {
|
|
anyhow::bail!("{STATE_DIR} exists and is not a directory")
|
|
}
|
|
Ok(md) => return assert_state_dir_is_private(dir, &md),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => anyhow::bail!("failed to stat {STATE_DIR}: {e}"),
|
|
}
|
|
|
|
fs::create_dir(dir).map_err(|e| anyhow::anyhow!("failed to create {STATE_DIR}: {e}"))?;
|
|
set_dir_mode(dir)?;
|
|
let md = fs::symlink_metadata(dir)
|
|
.map_err(|e| anyhow::anyhow!("failed to stat {STATE_DIR} after create: {e}"))?;
|
|
assert_state_dir_is_private(dir, &md)
|
|
}
|
|
|
|
/// Refuses a state directory anyone but root can write into.
|
|
#[cfg(unix)]
|
|
fn assert_state_dir_is_private(dir: &Path, md: &fs::Metadata) -> anyhow::Result<()> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
if md.uid() != 0 {
|
|
anyhow::bail!(
|
|
"{} must be owned by root, got uid {}",
|
|
dir.display(),
|
|
md.uid()
|
|
);
|
|
}
|
|
let mode = md.permissions().mode() & 0o777;
|
|
if mode & 0o022 != 0 {
|
|
anyhow::bail!(
|
|
"{} is writable by group or others (mode {mode:04o}); refusing to keep state there",
|
|
dir.display()
|
|
);
|
|
}
|
|
if mode != 0o700 {
|
|
set_dir_mode(dir)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn assert_state_dir_is_private(_dir: &Path, _md: &fs::Metadata) -> anyhow::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn set_dir_mode(dir: &Path) -> anyhow::Result<()> {
|
|
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
|
|
.map_err(|e| anyhow::anyhow!("failed to chmod 0700 {}: {e}", dir.display()))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn set_dir_mode(_dir: &Path) -> anyhow::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Removes the sidecar written next to the swapfile by earlier versions.
|
|
///
|
|
/// Only removed, never trusted: ownership now lives in the state directory. A sidecar at
|
|
/// a path we do not own is left alone.
|
|
fn drop_legacy_ownership_sidecar(swap_path: &Path) {
|
|
let Ok(sidecar) = suffixed_path(swap_path, LEGACY_OWNERSHIP_SUFFIX) else {
|
|
return;
|
|
};
|
|
// Goes through the same marker check as every other managed file. Removing any regular
|
|
// file that happened to match the name would delete an operator's file purely because
|
|
// of what it was called.
|
|
let file = ManagedFile::new(sidecar.clone(), LEGACY_OWNERSHIP_MARKER);
|
|
match file.ownership() {
|
|
Ok(Ownership::Absent) => {}
|
|
Ok(Ownership::Managed) => {
|
|
if let Err(e) = file.remove() {
|
|
tracing::warn!(
|
|
target: "swap",
|
|
"could not remove legacy ownership sidecar {}: {e}",
|
|
sidecar.display()
|
|
);
|
|
}
|
|
}
|
|
Ok(Ownership::Foreign) => tracing::warn!(
|
|
target: "swap",
|
|
"{} exists but is not the legacy ownership sidecar; leaving it alone",
|
|
sidecar.display()
|
|
),
|
|
Err(e) => tracing::warn!(
|
|
target: "swap",
|
|
"could not inspect {}: {e}",
|
|
sidecar.display()
|
|
),
|
|
}
|
|
}
|
|
|
|
pub fn inspect_swap(cfg: &Config) -> anyhow::Result<SwapStatus> {
|
|
let desired_path = PathBuf::from(cfg.swap.path.clone());
|
|
let active_entries = read_active_swaps()?;
|
|
let desired_active = active_entries.iter().any(|s| s.name == desired_path);
|
|
let exclusive = active_entries.len() == 1 && desired_active;
|
|
let fstab_swap_entries = read_fstab_swap_entries("/etc/fstab")?;
|
|
let expected_line = render_fstab_line(&desired_path, cfg.swap.priority);
|
|
let fstab_exclusive = fstab_swap_entries.len() == 1
|
|
&& normalize_ws(&fstab_swap_entries[0]) == normalize_ws(expected_line.trim_end());
|
|
let current_swappiness = read_current_swappiness().ok();
|
|
let persisted_swappiness = read_persisted_swappiness()?;
|
|
|
|
let drift_reason = if !cfg.swap.enabled {
|
|
None
|
|
} else if !exclusive {
|
|
if let Some(foreign) = active_entries.iter().find(|s| s.name != desired_path) {
|
|
Some(format!("foreign active swap: {}", foreign.name.display()))
|
|
} else {
|
|
Some("desired swap is not exclusively active".to_string())
|
|
}
|
|
} else if !fstab_exclusive {
|
|
Some("/etc/fstab swap entries are not exclusive".to_string())
|
|
} else if current_swappiness != Some(cfg.swap.swappiness) {
|
|
Some(format!(
|
|
"runtime swappiness mismatch: current={:?} desired={}",
|
|
current_swappiness, cfg.swap.swappiness
|
|
))
|
|
} else if persisted_swappiness != Some(cfg.swap.swappiness) {
|
|
Some(format!(
|
|
"persisted swappiness mismatch: persisted={:?} desired={}",
|
|
persisted_swappiness, cfg.swap.swappiness
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(SwapStatus {
|
|
enabled: cfg.swap.enabled,
|
|
desired_path,
|
|
active_entries,
|
|
desired_active,
|
|
exclusive,
|
|
fstab_swap_entries,
|
|
fstab_exclusive,
|
|
current_swappiness,
|
|
persisted_swappiness,
|
|
drift_reason,
|
|
})
|
|
}
|
|
|
|
impl DesiredSwap {
|
|
fn from_config(cfg: &Config) -> anyhow::Result<Self> {
|
|
let path = PathBuf::from(cfg.swap.path.clone());
|
|
if !path.is_absolute() {
|
|
anyhow::bail!("swap.path must be absolute: {}", path.display());
|
|
}
|
|
let size_bytes = cfg
|
|
.swap
|
|
.size_mb
|
|
.checked_mul(1024 * 1024)
|
|
.ok_or_else(|| anyhow::anyhow!("swap.size_mb overflow"))?;
|
|
Ok(Self {
|
|
path,
|
|
size_bytes,
|
|
priority: cfg.swap.priority,
|
|
swappiness: cfg.swap.swappiness,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn read_active_swaps() -> anyhow::Result<Vec<ActiveSwap>> {
|
|
let out = Command::new("swapon")
|
|
.args([
|
|
"--show",
|
|
"--bytes",
|
|
"--raw",
|
|
"--noheadings",
|
|
"--output",
|
|
"NAME,TYPE,SIZE,PRIO",
|
|
])
|
|
.output()?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"swapon --show failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
parse_swapon_output(&String::from_utf8_lossy(&out.stdout))
|
|
}
|
|
|
|
fn parse_swapon_output(raw: &str) -> anyhow::Result<Vec<ActiveSwap>> {
|
|
let mut out = Vec::new();
|
|
for line in raw.lines() {
|
|
let t = line.trim();
|
|
if t.is_empty() {
|
|
continue;
|
|
}
|
|
let parts: Vec<&str> = t.split_whitespace().collect();
|
|
if parts.len() < 4 {
|
|
anyhow::bail!("malformed swapon row: {t}");
|
|
}
|
|
let size_bytes = parts[2]
|
|
.parse::<u64>()
|
|
.map_err(|_| anyhow::anyhow!("malformed swapon size: {}", parts[2]))?;
|
|
|
|
// Debian 13/util-linux may include USED and UUID despite the requested
|
|
// output set. Accept both the old 4-column form:
|
|
// NAME TYPE SIZE PRIO
|
|
// and the observed 5/6-column form:
|
|
// NAME TYPE SIZE USED PRIO [UUID]
|
|
let priority_index = if parts.len() >= 5 && parts[4].parse::<i32>().is_ok() {
|
|
4
|
|
} else {
|
|
3
|
|
};
|
|
|
|
let priority = parts[priority_index]
|
|
.parse::<i32>()
|
|
.map_err(|_| anyhow::anyhow!("malformed swapon priority: {}", parts[priority_index]))?;
|
|
out.push(ActiveSwap {
|
|
name: PathBuf::from(parts[0]),
|
|
kind: parts[1].to_string(),
|
|
size_bytes,
|
|
priority,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Takes foreign swap offline. The file itself is never deleted.
|
|
///
|
|
/// Destroying a swapfile an administrator created is not this package's call: `swapoff`
|
|
/// plus removal from `/etc/fstab` already achieves the exclusive runtime state, and the
|
|
/// file can be removed by hand once the operator agrees.
|
|
/// Takes foreign swap offline, restoring what it already disabled if a later one fails.
|
|
///
|
|
/// Without the rollback a host with two foreign swaps could end up with the first one
|
|
/// disabled and the second still up, because the error aborted the loop and nothing put
|
|
/// the first back. That is a state neither the operator nor the next reconcile asked for.
|
|
fn disable_foreign_swaps(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> {
|
|
let mut disabled: Vec<&ActiveSwap> = Vec::new();
|
|
for entry in active {
|
|
if entry.name == desired.path {
|
|
continue;
|
|
}
|
|
if let Err(e) = run("swapoff", &[entry.name.to_string_lossy().as_ref()]) {
|
|
reenable_swaps(&disabled);
|
|
return Err(anyhow::anyhow!(
|
|
"failed to disable foreign swap {}: {e}",
|
|
entry.name.display()
|
|
));
|
|
}
|
|
disabled.push(entry);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Best-effort restore after a failed teardown: report what could not be brought back
|
|
/// rather than masking it, but never turn the rollback itself into the reported error.
|
|
fn reenable_swaps(entries: &[&ActiveSwap]) {
|
|
for entry in entries.iter().rev() {
|
|
if let Err(e) = run(
|
|
"swapon",
|
|
&[
|
|
"-p",
|
|
&entry.priority.to_string(),
|
|
entry.name.to_string_lossy().as_ref(),
|
|
],
|
|
) {
|
|
tracing::warn!(
|
|
target: "swap",
|
|
"could not re-enable {} while rolling back: {e}",
|
|
entry.name.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Creates or validates the managed swapfile without ever touching an unowned one.
|
|
///
|
|
/// `active` is needed because a live swapfile cannot be resized in place: if the managed
|
|
/// swap is currently active with the wrong geometry it has to be deactivated first. That
|
|
/// is our own swap, so the brief gap is acceptable; foreign swap stays up throughout.
|
|
fn ensure_desired_swapfile(desired: &DesiredSwap, active: &[ActiveSwap]) -> anyhow::Result<()> {
|
|
let p = &desired.path;
|
|
let ownership = inspect_swapfile_ownership(p, desired.size_bytes)?;
|
|
|
|
match ownership {
|
|
SwapfileOwnership::Foreign => anyhow::bail!(
|
|
"{} already exists and there is no record of awg2-harness creating it; \
|
|
refusing to touch it. Remove it manually or point swap.path elsewhere",
|
|
p.display()
|
|
),
|
|
SwapfileOwnership::StaleOwnership => anyhow::bail!(
|
|
"{} is not the file awg2-harness created - it was replaced since. Refusing to \
|
|
touch it. Remove it manually, or delete {SWAP_OWNERSHIP_PATH} to let \
|
|
awg2-harness adopt a freshly created swapfile",
|
|
p.display()
|
|
),
|
|
_ => {}
|
|
}
|
|
|
|
let active_entry = active.iter().find(|s| s.name == *p);
|
|
let size_ok = ownership == SwapfileOwnership::ManagedValid;
|
|
|
|
match (size_ok, active_entry) {
|
|
// Right file, right size, already active with the right priority: nothing to do.
|
|
(true, Some(entry))
|
|
if active_swap_size_matches(entry.size_bytes, desired.size_bytes)
|
|
&& entry.priority == desired.priority =>
|
|
{
|
|
set_swapfile_mode(p)?;
|
|
drop_legacy_ownership_sidecar(p);
|
|
Ok(())
|
|
}
|
|
// Right file, right size, but the active priority differs. A reactivation, not a
|
|
// rebuild - the previous code fell through to a full recreate, so changing
|
|
// swap.priority destroyed and rebuilt a perfectly good multi-gigabyte file.
|
|
(true, Some(entry)) => {
|
|
set_swapfile_mode(p)?;
|
|
drop_legacy_ownership_sidecar(p);
|
|
change_swap_priority(desired, entry.priority)
|
|
}
|
|
// Right file, not active: activate it.
|
|
(true, None) => {
|
|
set_swapfile_mode(p)?;
|
|
drop_legacy_ownership_sidecar(p);
|
|
swapon_at(p, desired.priority)
|
|
}
|
|
// Wrong size or no file at all, and nothing of ours is active: build in place.
|
|
(false, None) => {
|
|
if ownership.is_managed() {
|
|
fs::remove_file(p)
|
|
.map_err(|e| anyhow::anyhow!("failed to remove {}: {e}", p.display()))?;
|
|
}
|
|
create_fresh_swapfile(desired)?;
|
|
drop_legacy_ownership_sidecar(p);
|
|
Ok(())
|
|
}
|
|
// Wrong size while our swap is live: replace it through a staging file.
|
|
(false, Some(_)) => {
|
|
replace_active_swapfile(desired)?;
|
|
drop_legacy_ownership_sidecar(p);
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Re-activates the managed swap at a new priority, restoring the old one on failure.
|
|
///
|
|
/// `swapoff` followed by `swapon` is two steps, and the second can fail - a bad priority,
|
|
/// a transient kernel error. Without the restore, changing one integer in the config could
|
|
/// leave the host with no swap at all.
|
|
fn change_swap_priority(desired: &DesiredSwap, previous_priority: i32) -> anyhow::Result<()> {
|
|
let p = &desired.path;
|
|
run("swapoff", &[p.to_string_lossy().as_ref()])?;
|
|
|
|
let Err(e) = swapon_at(p, desired.priority) else {
|
|
return Ok(());
|
|
};
|
|
match swapon_at(p, previous_priority) {
|
|
Ok(()) => Err(anyhow::anyhow!(
|
|
"failed to activate {} with priority {}: {e}; restored the previous priority {}",
|
|
p.display(),
|
|
desired.priority,
|
|
previous_priority
|
|
)),
|
|
Err(re) => Err(anyhow::anyhow!(
|
|
"failed to activate {} with priority {} ({e}) and could not restore priority {} \
|
|
({re}); the host may currently have no swap",
|
|
p.display(),
|
|
desired.priority,
|
|
previous_priority
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Builds and activates a swapfile where none of ours is running.
|
|
///
|
|
/// The file is removed again if anything after its creation fails. Leaving it behind would
|
|
/// produce an unowned regular file at the managed path, which the next run correctly
|
|
/// classifies as `Foreign` and then refuses to touch - so a single transient `mkswap`
|
|
/// failure would wedge the module until an operator intervened.
|
|
fn create_fresh_swapfile(desired: &DesiredSwap) -> anyhow::Result<()> {
|
|
let p = &desired.path;
|
|
build_swapfile(p, desired.size_bytes)?;
|
|
|
|
let prepared = record_ownership(p).and_then(|()| swapon_at(p, desired.priority));
|
|
if let Err(e) = prepared {
|
|
// Created by this run inside an allowed root-owned location, so removing it is
|
|
// safe and leaves the host exactly as it was found.
|
|
if let Err(re) = fs::remove_file(p) {
|
|
return Err(anyhow::anyhow!(
|
|
"{e}; additionally failed to clean up the partially prepared {}: {re}",
|
|
p.display()
|
|
));
|
|
}
|
|
let _ = ownership_file().remove();
|
|
return Err(e);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Replaces a live managed swapfile with one of a different size.
|
|
///
|
|
/// A live swapfile cannot be resized in place, so it has to be swapped out. Allocation and
|
|
/// `mkswap` happen while the old swap is still serving. The old file is then kept aside as
|
|
/// `.previous` rather than deleted, so every step after that point can be undone: until
|
|
/// the new file is confirmed active, the original is still on disk and can be put back.
|
|
/// The previous version deleted the old file before the new one was activated, so a
|
|
/// failure at `rename` or `swapon` left the host with no swap and nothing to recover from.
|
|
fn replace_active_swapfile(desired: &DesiredSwap) -> anyhow::Result<()> {
|
|
let p = &desired.path;
|
|
let staging = suffixed_path(p, STAGING_SUFFIX)?;
|
|
let previous = suffixed_path(p, PREVIOUS_SUFFIX)?;
|
|
|
|
remove_if_present(&staging)?;
|
|
remove_if_present(&previous)?;
|
|
|
|
// Built and activated while the old swap is still up: no gap during the slow part.
|
|
build_swapfile(&staging, desired.size_bytes)?;
|
|
if let Err(e) = swapon_at(&staging, desired.priority) {
|
|
let _ = fs::remove_file(&staging);
|
|
return Err(e);
|
|
}
|
|
|
|
// From here the old swap can be released, because the replacement is carrying load.
|
|
run("swapoff", &[p.to_string_lossy().as_ref()])?;
|
|
fs::rename(p, &previous).map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"failed to set aside {} as {}: {e}",
|
|
p.display(),
|
|
previous.display()
|
|
)
|
|
})?;
|
|
|
|
// Short window with no allocation in it. Everything below rolls back to `previous`.
|
|
let switched = switch_staging_into_place(desired, &staging, &previous);
|
|
if let Err(e) = switched {
|
|
return Err(restore_previous_swapfile(desired, &previous, e));
|
|
}
|
|
|
|
// The replacement is confirmed active; the old file is no longer needed.
|
|
if let Err(e) = fs::remove_file(&previous) {
|
|
tracing::warn!(
|
|
target: "swap",
|
|
"swap replaced successfully but the old file {} could not be removed: {e}",
|
|
previous.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn switch_staging_into_place(
|
|
desired: &DesiredSwap,
|
|
staging: &Path,
|
|
previous: &Path,
|
|
) -> anyhow::Result<()> {
|
|
let p = &desired.path;
|
|
run("swapoff", &[staging.to_string_lossy().as_ref()])?;
|
|
fs::rename(staging, p).map_err(|e| {
|
|
anyhow::anyhow!(
|
|
"failed to move {} into place at {}: {e} (previous swapfile kept at {})",
|
|
staging.display(),
|
|
p.display(),
|
|
previous.display()
|
|
)
|
|
})?;
|
|
record_ownership(p)?;
|
|
swapon_at(p, desired.priority)?;
|
|
assert_some_swap_is_active(p)
|
|
}
|
|
|
|
/// Puts the original swapfile back after a failed replacement.
|
|
///
|
|
/// Returns an error that carries both the original failure and the outcome of the
|
|
/// restore, so the operator sees what went wrong *and* whether the host still has swap.
|
|
fn restore_previous_swapfile(
|
|
desired: &DesiredSwap,
|
|
previous: &Path,
|
|
cause: anyhow::Error,
|
|
) -> anyhow::Error {
|
|
let p = &desired.path;
|
|
let _ = remove_if_present(p);
|
|
if let Err(e) = fs::rename(previous, p) {
|
|
return anyhow::anyhow!(
|
|
"{cause}; rollback failed: could not restore {} from {}: {e}. The host may \
|
|
currently have no swap",
|
|
p.display(),
|
|
previous.display()
|
|
);
|
|
}
|
|
if let Err(e) = record_ownership(p) {
|
|
tracing::warn!(target: "swap", "restored swapfile but could not rewrite ownership: {e}");
|
|
}
|
|
match swapon_at(p, desired.priority) {
|
|
Ok(()) => anyhow::anyhow!("{cause}; rolled back to the previous swapfile"),
|
|
Err(e) => anyhow::anyhow!(
|
|
"{cause}; rollback restored {} but could not activate it: {e}. The host may \
|
|
currently have no swap",
|
|
p.display()
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Refuses to report success while the host has no swap at all.
|
|
fn assert_some_swap_is_active(expected: &Path) -> anyhow::Result<()> {
|
|
let active = read_active_swaps()?;
|
|
if active.iter().any(|s| s.name == expected) {
|
|
return Ok(());
|
|
}
|
|
anyhow::bail!(
|
|
"{} is not present in the active swap list after activation",
|
|
expected.display()
|
|
)
|
|
}
|
|
|
|
fn swapon_at(path: &Path, priority: i32) -> anyhow::Result<()> {
|
|
run(
|
|
"swapon",
|
|
&["-p", &priority.to_string(), path.to_string_lossy().as_ref()],
|
|
)
|
|
}
|
|
|
|
fn remove_if_present(path: &Path) -> anyhow::Result<()> {
|
|
match fs::remove_file(path) {
|
|
Ok(()) => Ok(()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(anyhow::anyhow!("failed to remove {}: {e}", path.display())),
|
|
}
|
|
}
|
|
|
|
fn suffixed_path(path: &Path, suffix: &str) -> anyhow::Result<PathBuf> {
|
|
let name = path
|
|
.file_name()
|
|
.ok_or_else(|| anyhow::anyhow!("invalid swap.path: {}", path.display()))?;
|
|
let mut out = name.to_os_string();
|
|
out.push(suffix);
|
|
Ok(path.with_file_name(out))
|
|
}
|
|
|
|
/// Allocates a swapfile and formats it, with the restrictive mode applied before `mkswap`
|
|
/// so the file is never briefly world-readable while it already holds swap structure.
|
|
fn build_swapfile(path: &Path, size_bytes: u64) -> anyhow::Result<()> {
|
|
create_swapfile(path, size_bytes)?;
|
|
set_swapfile_mode(path)?;
|
|
run("mkswap", &[path.to_string_lossy().as_ref()])?;
|
|
set_swapfile_mode(path)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn set_swapfile_mode(path: &Path) -> anyhow::Result<()> {
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
|
.map_err(|e| anyhow::anyhow!("failed to chmod 0600 {}: {e}", path.display()))
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn set_swapfile_mode(_path: &Path) -> anyhow::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
fn create_swapfile(path: &Path, size_bytes: u64) -> anyhow::Result<()> {
|
|
let size_mb = size_bytes / (1024 * 1024);
|
|
let p = path.to_string_lossy().to_string();
|
|
|
|
let out = Command::new("fallocate")
|
|
.args(["-l", &format!("{}M", size_mb), &p])
|
|
.output()?;
|
|
if out.status.success() {
|
|
return Ok(());
|
|
}
|
|
|
|
let out_dd = Command::new("dd")
|
|
.args([
|
|
"if=/dev/zero",
|
|
&format!("of={p}"),
|
|
"bs=1M",
|
|
&format!("count={size_mb}"),
|
|
"status=none",
|
|
])
|
|
.output()?;
|
|
if !out_dd.status.success() {
|
|
anyhow::bail!(
|
|
"swapfile create failed (fallocate+dd): fallocate={}, dd={}",
|
|
String::from_utf8_lossy(&out.stderr),
|
|
String::from_utf8_lossy(&out_dd.stderr)
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn rewrite_fstab_exclusive(desired: &DesiredSwap) -> anyhow::Result<()> {
|
|
let tx = FstabTransaction::begin(Path::new(FSTAB_PATH))?;
|
|
let rewritten = rewrite_fstab_content(tx.original(), &desired.path, desired.priority);
|
|
tx.commit(&rewritten)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn rewrite_fstab_content(content: &str, desired_path: &Path, priority: i32) -> String {
|
|
let mut out = Vec::<String>::new();
|
|
for line in content.lines() {
|
|
let t = line.trim();
|
|
if t.is_empty() || t.starts_with('#') {
|
|
out.push(line.to_string());
|
|
continue;
|
|
}
|
|
let cols: Vec<&str> = t.split_whitespace().collect();
|
|
if cols.len() >= 3 && cols[2] == "swap" {
|
|
continue;
|
|
}
|
|
out.push(line.to_string());
|
|
}
|
|
out.push(
|
|
render_fstab_line(desired_path, priority)
|
|
.trim_end()
|
|
.to_string(),
|
|
);
|
|
let mut s = out.join("\n");
|
|
if !s.ends_with('\n') {
|
|
s.push('\n');
|
|
}
|
|
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 {
|
|
format!(
|
|
"{} none swap sw,pri={} 0 0\n",
|
|
escape_field(&path.to_string_lossy()),
|
|
priority
|
|
)
|
|
}
|
|
|
|
fn apply_swappiness(v: u8) -> anyhow::Result<()> {
|
|
run("sysctl", &["-w", &format!("vm.swappiness={v}")])?;
|
|
let current = read_current_swappiness()?;
|
|
if current != v {
|
|
anyhow::bail!("failed to apply vm.swappiness: got {current}, expected {v}");
|
|
}
|
|
|
|
migrate_legacy_swappiness_file()?;
|
|
swappiness_file().write(&render_swappiness_conf(v))?;
|
|
|
|
let persisted = read_persisted_swappiness()?;
|
|
if persisted != Some(v) {
|
|
anyhow::bail!(
|
|
"failed to persist vm.swappiness: got {:?}, expected {}",
|
|
persisted,
|
|
v
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn read_current_swappiness() -> anyhow::Result<u8> {
|
|
let out = Command::new("sysctl")
|
|
.args(["-n", "vm.swappiness"])
|
|
.output()?;
|
|
if !out.status.success() {
|
|
anyhow::bail!("sysctl read vm.swappiness failed");
|
|
}
|
|
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
|
s.parse::<u8>()
|
|
.with_context(|| format!("invalid vm.swappiness value: {s}"))
|
|
}
|
|
|
|
pub fn read_persisted_swappiness() -> anyhow::Result<Option<u8>> {
|
|
let Some(content) = swappiness_file().read()? else {
|
|
return Ok(None);
|
|
};
|
|
for line in content.lines() {
|
|
let t = line.trim();
|
|
if t.is_empty() || t.starts_with('#') {
|
|
continue;
|
|
}
|
|
let Some((k, v)) = t.split_once('=') else {
|
|
continue;
|
|
};
|
|
if k.trim() == "vm.swappiness" {
|
|
return Ok(Some(v.trim().parse::<u8>()?));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
|
|
fn verify_exclusive_swap_state(desired: &DesiredSwap) -> anyhow::Result<()> {
|
|
let active = read_active_swaps()?;
|
|
if active.len() != 1 {
|
|
anyhow::bail!(
|
|
"swap verify failed: expected exactly one active swap, got {}",
|
|
active.len()
|
|
);
|
|
}
|
|
let only = &active[0];
|
|
if only.name != desired.path {
|
|
anyhow::bail!(
|
|
"swap verify failed: active swap {} != desired {}",
|
|
only.name.display(),
|
|
desired.path.display()
|
|
);
|
|
}
|
|
if !active_swap_size_matches(only.size_bytes, desired.size_bytes) {
|
|
anyhow::bail!(
|
|
"swap verify failed: size mismatch {} != {}",
|
|
only.size_bytes,
|
|
desired.size_bytes
|
|
);
|
|
}
|
|
if only.priority != desired.priority {
|
|
anyhow::bail!(
|
|
"swap verify failed: priority mismatch {} != {}",
|
|
only.priority,
|
|
desired.priority
|
|
);
|
|
}
|
|
|
|
let entries = read_fstab_swap_entries("/etc/fstab")?;
|
|
if entries.len() != 1 {
|
|
anyhow::bail!(
|
|
"swap verify failed: expected one fstab swap entry, got {}",
|
|
entries.len()
|
|
);
|
|
}
|
|
let expected = render_fstab_line(&desired.path, desired.priority);
|
|
if normalize_ws(&entries[0]) != normalize_ws(expected.trim_end()) {
|
|
anyhow::bail!(
|
|
"swap verify failed: fstab entry mismatch: got='{}' expected='{}'",
|
|
entries[0],
|
|
expected.trim_end()
|
|
);
|
|
}
|
|
|
|
let current = read_current_swappiness()?;
|
|
if current != desired.swappiness {
|
|
anyhow::bail!(
|
|
"swap verify failed: vm.swappiness current={} expected={}",
|
|
current,
|
|
desired.swappiness
|
|
);
|
|
}
|
|
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>> {
|
|
let content = fstab::read_required(Path::new(path))?;
|
|
let mut out = Vec::new();
|
|
for line in content.lines() {
|
|
let t = line.trim();
|
|
if t.is_empty() || t.starts_with('#') {
|
|
continue;
|
|
}
|
|
let cols: Vec<&str> = t.split_whitespace().collect();
|
|
if cols.len() >= 3 && cols[2] == "swap" {
|
|
out.push(t.to_string());
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn normalize_ws(s: &str) -> String {
|
|
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
}
|
|
|
|
fn active_swap_size_matches(active_bytes: u64, desired_file_bytes: u64) -> bool {
|
|
if active_bytes == desired_file_bytes {
|
|
return true;
|
|
}
|
|
active_bytes < desired_file_bytes && desired_file_bytes - active_bytes <= 64 * 1024
|
|
}
|
|
|
|
fn run(program: &str, args: &[&str]) -> anyhow::Result<()> {
|
|
let out = Command::new(program).args(args).output()?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"{} {:?} failed: {}",
|
|
program,
|
|
args,
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn scratch(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("awg2_swap_{}_{}", name, std::process::id()));
|
|
let _ = fs::remove_dir_all(&dir);
|
|
fs::create_dir_all(&dir).expect("scratch dir");
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn swap_path_syntax_accepts_expected_production_values() {
|
|
assert!(validate_swap_path_syntax("/swapfile").is_ok());
|
|
assert!(validate_swap_path_syntax("/var/lib/awg2-harness/swapfile").is_ok());
|
|
assert!(validate_swap_path_syntax("/var/lib/awg2-harness/swap.1").is_ok());
|
|
}
|
|
|
|
/// An allow-list, not a deny-list: a path whose parent another user can write lets
|
|
/// them redirect root-owned operations between preflight and use, which no amount of
|
|
/// stat-ing beforehand can prevent.
|
|
#[test]
|
|
fn swap_path_syntax_rejects_paths_outside_root_owned_roots() {
|
|
for bad in [
|
|
"/tmp/awg.swap",
|
|
"/home/user/awg.swap",
|
|
"/srv/data/swapfile",
|
|
"/var/lib/swapfile",
|
|
"/var/lib/awg2-harness",
|
|
"/var/lib/awg2-harness/nested/swapfile",
|
|
"/var/lib/awg2-harness-other/swapfile",
|
|
"/swapfile2",
|
|
] {
|
|
assert!(
|
|
validate_swap_path_syntax(bad).is_err(),
|
|
"must reject path outside the allowed roots: {bad}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The blacklist used to compare exact strings only, so a path that is lexically
|
|
/// different but resolves to a forbidden file slipped through.
|
|
#[test]
|
|
fn swap_path_syntax_rejects_dot_dot_traversal() {
|
|
for bad in [
|
|
"/etc/../etc/fstab",
|
|
"/var/../etc/passwd",
|
|
"/./swapfile",
|
|
"/var/lib/../../etc/fstab",
|
|
] {
|
|
assert!(
|
|
validate_swap_path_syntax(bad).is_err(),
|
|
"must reject traversal: {bad}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn swap_path_syntax_rejects_dangerous_and_virtual_paths() {
|
|
for bad in [
|
|
"/",
|
|
"/etc",
|
|
"/etc/fstab",
|
|
"/etc/passwd",
|
|
"/etc/shadow",
|
|
"/dev",
|
|
"/proc",
|
|
"/sys",
|
|
"/run",
|
|
"/boot",
|
|
"/proc/self/mem",
|
|
"/sys/kernel",
|
|
"/dev/sda",
|
|
] {
|
|
assert!(
|
|
validate_swap_path_syntax(bad).is_err(),
|
|
"must reject dangerous path: {bad}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Unescaped whitespace would shift the fstab columns; control characters and
|
|
/// backslashes have no legitimate use in a swapfile path.
|
|
#[test]
|
|
fn swap_path_syntax_rejects_whitespace_and_control_characters() {
|
|
for bad in [
|
|
"/var/my swap",
|
|
"/var/swap\tfile",
|
|
"/var/swap\nfile",
|
|
"/var/swap\\file",
|
|
] {
|
|
assert!(
|
|
validate_swap_path_syntax(bad).is_err(),
|
|
"must reject: {bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn swap_path_syntax_rejects_relative_and_directory_paths() {
|
|
assert!(validate_swap_path_syntax("swapfile").is_err());
|
|
assert!(validate_swap_path_syntax("var/lib/swapfile").is_err());
|
|
assert!(validate_swap_path_syntax("/var/lib/").is_err());
|
|
}
|
|
|
|
fn ident(device: u64, inode: u64) -> FileIdentity {
|
|
FileIdentity { device, inode }
|
|
}
|
|
|
|
fn record(path: &str, device: u64, inode: u64) -> SwapOwnershipRecord {
|
|
SwapOwnershipRecord {
|
|
version: SWAP_OWNERSHIP_VERSION,
|
|
path: PathBuf::from(path),
|
|
identity: ident(device, inode),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_missing_when_file_absent() {
|
|
let rec = record("/swapfile", 1, 2);
|
|
assert_eq!(
|
|
classify_ownership(Some(&rec), Path::new("/swapfile"), None, 1024),
|
|
SwapfileOwnership::Missing
|
|
);
|
|
}
|
|
|
|
/// A pre-existing swapfile we have no record of belongs to someone else and must
|
|
/// never be deleted or rewritten, whatever its size.
|
|
#[test]
|
|
fn ownership_foreign_without_record() {
|
|
assert_eq!(
|
|
classify_ownership(
|
|
None,
|
|
Path::new("/swapfile"),
|
|
Some((ident(1, 2), 1024)),
|
|
1024
|
|
),
|
|
SwapfileOwnership::Foreign
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_managed_valid_and_wrong_size() {
|
|
let rec = record("/swapfile", 1, 2);
|
|
assert_eq!(
|
|
classify_ownership(
|
|
Some(&rec),
|
|
Path::new("/swapfile"),
|
|
Some((ident(1, 2), 1024)),
|
|
1024
|
|
),
|
|
SwapfileOwnership::ManagedValid
|
|
);
|
|
assert_eq!(
|
|
classify_ownership(
|
|
Some(&rec),
|
|
Path::new("/swapfile"),
|
|
Some((ident(1, 2), 1024)),
|
|
2048
|
|
),
|
|
SwapfileOwnership::ManagedWrongSize
|
|
);
|
|
}
|
|
|
|
/// The data-loss scenario the sidecar allowed: our swapfile is deleted by hand, the
|
|
/// ownership state survives, and someone puts an unrelated file at the same path.
|
|
/// Matching on path alone would have called it managed and deleted it.
|
|
#[test]
|
|
fn ownership_is_stale_when_the_file_was_replaced() {
|
|
let rec = record("/swapfile", 1, 2);
|
|
assert_eq!(
|
|
classify_ownership(
|
|
Some(&rec),
|
|
Path::new("/swapfile"),
|
|
Some((ident(1, 999), 4096)),
|
|
1024
|
|
),
|
|
SwapfileOwnership::StaleOwnership
|
|
);
|
|
}
|
|
|
|
/// A record for a different managed path says nothing about this file.
|
|
#[test]
|
|
fn ownership_record_for_another_path_is_foreign() {
|
|
let rec = record("/var/lib/awg2-harness/swapfile", 1, 2);
|
|
assert_eq!(
|
|
classify_ownership(
|
|
Some(&rec),
|
|
Path::new("/swapfile"),
|
|
Some((ident(1, 2), 1024)),
|
|
1024
|
|
),
|
|
SwapfileOwnership::Foreign
|
|
);
|
|
}
|
|
|
|
/// Neither refusal state may ever authorise a delete.
|
|
#[test]
|
|
fn foreign_and_stale_are_never_managed() {
|
|
assert!(!SwapfileOwnership::Foreign.is_managed());
|
|
assert!(!SwapfileOwnership::StaleOwnership.is_managed());
|
|
assert!(!SwapfileOwnership::Missing.is_managed());
|
|
assert!(SwapfileOwnership::ManagedValid.is_managed());
|
|
assert!(SwapfileOwnership::ManagedWrongSize.is_managed());
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_record_round_trips() {
|
|
let rec = record("/var/lib/awg2-harness/swapfile", 2049, 123456);
|
|
let rendered = render_ownership_record(&rec);
|
|
assert!(rendered.starts_with(SWAP_OWNERSHIP_MARKER));
|
|
assert_eq!(parse_ownership_record(&rendered).expect("parse"), rec);
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_record_rejects_malformed_and_unsupported() {
|
|
for bad in [
|
|
"",
|
|
"# marker only\n",
|
|
"version = 1\npath = /swapfile\ndevice = 1\n",
|
|
"version = 99\npath = /swapfile\ndevice = 1\ninode = 2\n",
|
|
"version = x\npath = /swapfile\ndevice = 1\ninode = 2\n",
|
|
] {
|
|
assert!(
|
|
parse_ownership_record(bad).is_err(),
|
|
"must reject record: {bad:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn active(name: &str, size_bytes: u64, priority: i32) -> ActiveSwap {
|
|
ActiveSwap {
|
|
name: PathBuf::from(name),
|
|
kind: "file".to_string(),
|
|
size_bytes,
|
|
priority,
|
|
}
|
|
}
|
|
|
|
/// Changing only `swap.priority` used to fall through to a full recreate, destroying
|
|
/// and rebuilding a correct multi-gigabyte file. It is a reactivation, nothing more.
|
|
#[test]
|
|
fn priority_only_change_does_not_need_a_new_file() {
|
|
let desired = DesiredSwap {
|
|
path: PathBuf::from("/swapfile"),
|
|
size_bytes: 2048 * 1024 * 1024,
|
|
priority: 5,
|
|
swappiness: 1,
|
|
};
|
|
let entry = active("/swapfile", desired.size_bytes, 1);
|
|
|
|
// Same size, different priority: the file is fine, only the activation is wrong.
|
|
assert!(active_swap_size_matches(
|
|
entry.size_bytes,
|
|
desired.size_bytes
|
|
));
|
|
assert_ne!(entry.priority, desired.priority);
|
|
}
|
|
|
|
#[test]
|
|
fn staging_path_is_a_sibling_of_the_swapfile() {
|
|
let base = Path::new("/var/lib/awg2-harness/swapfile");
|
|
assert_eq!(
|
|
suffixed_path(base, STAGING_SUFFIX).expect("staging"),
|
|
PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness.new")
|
|
);
|
|
assert_eq!(
|
|
suffixed_path(base, PREVIOUS_SUFFIX).expect("previous"),
|
|
PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness.previous")
|
|
);
|
|
assert_eq!(
|
|
suffixed_path(base, LEGACY_OWNERSHIP_SUFFIX).expect("sidecar"),
|
|
PathBuf::from("/var/lib/awg2-harness/swapfile.awg2-harness-owned")
|
|
);
|
|
}
|
|
|
|
/// The legacy sidecar is removed only when it carries the marker the old version
|
|
/// wrote; any other file with that name belongs to the operator.
|
|
#[test]
|
|
fn legacy_sidecar_is_only_removed_when_it_carries_the_old_marker() {
|
|
let dir = scratch("sidecar");
|
|
let swapfile = dir.join("swapfile");
|
|
let sidecar = dir.join("swapfile.awg2-harness-owned");
|
|
|
|
fs::write(&sidecar, "# something an operator wrote\n").expect("seed foreign");
|
|
drop_legacy_ownership_sidecar(&swapfile);
|
|
assert!(sidecar.exists(), "a foreign file must not be removed");
|
|
|
|
fs::write(&sidecar, format!("{LEGACY_OWNERSHIP_MARKER}\n")).expect("seed ours");
|
|
drop_legacy_ownership_sidecar(&swapfile);
|
|
assert!(!sidecar.exists(), "our own sidecar must be removed");
|
|
}
|
|
|
|
/// Foreign swaps are identified by not being the managed path; the managed one is
|
|
/// never included in the teardown set.
|
|
#[test]
|
|
fn managed_swap_is_never_in_the_foreign_set() {
|
|
let desired = DesiredSwap {
|
|
path: PathBuf::from("/swapfile"),
|
|
size_bytes: 1024,
|
|
priority: 1,
|
|
swappiness: 1,
|
|
};
|
|
let entries = [
|
|
active("/swapfile", 1024, 1),
|
|
active("/dev/sda2", 4096, -2),
|
|
active("/other/swap", 2048, 0),
|
|
];
|
|
let foreign: Vec<_> = entries
|
|
.iter()
|
|
.filter(|e| e.name != desired.path)
|
|
.map(|e| e.name.display().to_string())
|
|
.collect();
|
|
assert_eq!(foreign, vec!["/dev/sda2", "/other/swap"]);
|
|
}
|
|
|
|
#[test]
|
|
fn ownership_rejects_non_regular_file() {
|
|
let dir = scratch("own_dir");
|
|
let p = dir.join("swapfile");
|
|
fs::create_dir(&p).expect("seed dir");
|
|
assert!(inspect_swapfile_ownership(&p, 1024).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn safe_path_check_rejects_missing_parent_directory() {
|
|
let dir = scratch("no_parent");
|
|
let err =
|
|
assert_path_chain_is_safe(&dir.join("nested").join("swapfile")).expect_err("must fail");
|
|
assert!(err.to_string().contains("parent directory does not exist"));
|
|
}
|
|
|
|
#[test]
|
|
fn safe_path_check_accepts_existing_parent_chain() {
|
|
let dir = scratch("parent_ok");
|
|
assert!(assert_path_chain_is_safe(&dir.join("swapfile")).is_ok());
|
|
}
|
|
|
|
/// A symlink anywhere in the chain, not only at the final component, can redirect the
|
|
/// operation at a file that was never configured.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn safe_path_check_rejects_symlinked_parent() {
|
|
let dir = scratch("symlink_parent");
|
|
let real = dir.join("real");
|
|
fs::create_dir(&real).expect("real dir");
|
|
let link = dir.join("link");
|
|
std::os::unix::fs::symlink(&real, &link).expect("symlink");
|
|
|
|
let err = assert_path_chain_is_safe(&link.join("swapfile")).expect_err("must fail");
|
|
assert!(err.to_string().contains("traverses a symlink"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_swapon_empty() {
|
|
let got = parse_swapon_output("").expect("parse");
|
|
assert!(got.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_swapon_single_file() {
|
|
let got = parse_swapon_output("/swapfile file 2147479552 1\n").expect("parse");
|
|
assert_eq!(got.len(), 1);
|
|
assert_eq!(got[0].name, PathBuf::from("/swapfile"));
|
|
assert_eq!(got[0].kind, "file");
|
|
assert_eq!(got[0].size_bytes, 2147479552);
|
|
assert_eq!(got[0].priority, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_swapon_multiple_entries() {
|
|
let raw = "/swapfile file 2147479552 1\n/dev/zram0 partition 1073741824 100\n";
|
|
let got = parse_swapon_output(raw).expect("parse");
|
|
assert_eq!(got.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_swapon_debian13_columns_with_used_prio_uuid() {
|
|
let got = parse_swapon_output(
|
|
"/swapfile file 2147479552 0 1 c8db470e-b8fb-414a-8537-8fa405ebeadf\n",
|
|
)
|
|
.expect("parse");
|
|
assert_eq!(got.len(), 1);
|
|
assert_eq!(got[0].name, PathBuf::from("/swapfile"));
|
|
assert_eq!(got[0].size_bytes, 2147479552);
|
|
assert_eq!(got[0].priority, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn active_swap_size_accepts_mkswap_usable_size_delta() {
|
|
// Debian 13: swapon may report 4096 bytes less than file size.
|
|
assert!(active_swap_size_matches(2147479552, 2147483648));
|
|
assert!(active_swap_size_matches(2147483648, 2147483648));
|
|
assert!(!active_swap_size_matches(2146435072, 2147483648));
|
|
assert!(!active_swap_size_matches(2147487744, 2147483648));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_swapon_rejects_malformed_size() {
|
|
let err = parse_swapon_output("/swapfile file abc 1\n");
|
|
assert!(err.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn fstab_rewrite_removes_all_swap_entries() {
|
|
let raw =
|
|
"UUID=x / ext4 defaults 0 1\n/swap-old none swap sw 0 0\n/dev/zram0 none swap sw 0 0\n";
|
|
let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1);
|
|
assert!(!out.contains("/swap-old"));
|
|
assert!(!out.contains("/dev/zram0"));
|
|
assert!(out.contains("/swapfile none swap sw,pri=1 0 0"));
|
|
}
|
|
|
|
#[test]
|
|
fn fstab_rewrite_preserves_comments_and_mounts() {
|
|
let raw = "# head\nUUID=x / ext4 defaults 0 1\n\n# tail\n";
|
|
let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1);
|
|
assert!(out.contains("# head"));
|
|
assert!(out.contains("UUID=x / ext4 defaults 0 1"));
|
|
assert!(out.contains("# tail"));
|
|
}
|
|
|
|
#[test]
|
|
fn fstab_rewrite_is_idempotent() {
|
|
let raw = "UUID=x / ext4 defaults 0 1\n/swapfile none swap sw,pri=1 0 0\n";
|
|
let one = rewrite_fstab_content(raw, Path::new("/swapfile"), 1);
|
|
let two = rewrite_fstab_content(&one, Path::new("/swapfile"), 1);
|
|
assert_eq!(one, two);
|
|
}
|
|
|
|
#[test]
|
|
fn fstab_rewrite_adds_trailing_newline() {
|
|
let raw = "UUID=x / ext4 defaults 0 1";
|
|
let out = rewrite_fstab_content(raw, Path::new("/swapfile"), 1);
|
|
assert!(out.ends_with('\n'));
|
|
}
|
|
|
|
#[test]
|
|
fn swappiness_conf_render_is_stable() {
|
|
let rendered = render_swappiness_conf(1);
|
|
assert!(rendered.starts_with(SWAPPINESS_MARKER));
|
|
assert!(rendered.contains("vm.swappiness = 1"));
|
|
assert!(render_swappiness_conf(60).contains("vm.swappiness = 60"));
|
|
}
|
|
|
|
/// The rendered file must be recognised as ours by the same marker check that guards
|
|
/// overwriting it, otherwise every reconcile would refuse to touch its own output.
|
|
#[test]
|
|
fn rendered_swappiness_conf_is_recognised_as_managed() {
|
|
let dir = scratch("swappiness_marker");
|
|
let f = ManagedFile::new(dir.join("90-awg2-harness-swap.conf"), SWAPPINESS_MARKER);
|
|
assert!(f.write(&render_swappiness_conf(1)).expect("write"));
|
|
assert_eq!(
|
|
f.ownership().expect("ownership"),
|
|
crate::managed_file::Ownership::Managed
|
|
);
|
|
assert!(!f.write(&render_swappiness_conf(1)).expect("idempotent"));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_swappiness_file_is_removed_when_it_carries_the_old_marker() {
|
|
let dir = scratch("legacy_ours");
|
|
let path = dir.join("99-awg2-harness.conf");
|
|
fs::write(
|
|
&path,
|
|
format!("{LEGACY_SWAPPINESS_MARKER}\nvm.swappiness = 1\n"),
|
|
)
|
|
.expect("seed legacy");
|
|
|
|
let legacy = ManagedFile::new(path.clone(), LEGACY_SWAPPINESS_MARKER);
|
|
drop_legacy_managed_file(&legacy).expect("migrate");
|
|
assert!(!path.exists());
|
|
}
|
|
|
|
/// An administrator's file that happens to sit at the old path is not ours to delete.
|
|
#[test]
|
|
fn legacy_migration_leaves_a_foreign_file_alone() {
|
|
let dir = scratch("legacy_foreign");
|
|
let path = dir.join("99-awg2-harness.conf");
|
|
fs::write(&path, "# hand written\nvm.swappiness = 30\n").expect("seed foreign");
|
|
|
|
let legacy = ManagedFile::new(path.clone(), LEGACY_SWAPPINESS_MARKER);
|
|
drop_legacy_managed_file(&legacy).expect("must not fail");
|
|
assert_eq!(
|
|
fs::read_to_string(&path).expect("intact"),
|
|
"# hand written\nvm.swappiness = 30\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_migration_is_a_no_op_when_nothing_is_there() {
|
|
let dir = scratch("legacy_absent");
|
|
let legacy = ManagedFile::new(dir.join("99-awg2-harness.conf"), LEGACY_SWAPPINESS_MARKER);
|
|
assert!(drop_legacy_managed_file(&legacy).is_ok());
|
|
}
|
|
}
|