diff --git a/hive-priv/src/storage.rs b/hive-priv/src/storage.rs index da20acf9..cd50f0d7 100644 --- a/hive-priv/src/storage.rs +++ b/hive-priv/src/storage.rs @@ -20,19 +20,19 @@ use crate::shared::{container_system_name, validate_agent_name, validate_credent /// `EnsureAgentSubvolume`. pub(crate) async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> { validate_agent_name(agent_name)?; - ensure_agent_subvolume(agent_name).await + ensure_agent_subvolume(&Env::real(), agent_name).await } /// `DeleteAgentSubvolume`. pub(crate) async fn exec_delete_agent_subvolume(agent_name: &str) -> Result<(String, String)> { validate_agent_name(agent_name)?; - delete_agent_subvolume(agent_name).await + delete_agent_subvolume(&Env::real(), agent_name).await } /// `ReadSubvolumeUsage`. pub(crate) async fn exec_read_subvolume_usage(agent_name: &str) -> Result<(String, String)> { validate_agent_name(agent_name)?; - read_subvolume_usage(agent_name).await + read_subvolume_usage(&Env::real(), agent_name).await } /// `MeasureAgentDisk`. @@ -47,13 +47,13 @@ pub(crate) async fn exec_set_subvolume_quota( limit_bytes: Option, ) -> Result<(String, String)> { validate_agent_name(agent_name)?; - set_subvolume_quota(agent_name, limit_bytes).await + set_subvolume_quota(&Env::real(), agent_name, limit_bytes).await } /// `UpgradeAgentSubvolume`. pub(crate) async fn exec_upgrade_agent_subvolume(agent_name: &str) -> Result<(String, String)> { validate_agent_name(agent_name)?; - upgrade_agent_subvolume(agent_name).await + upgrade_agent_subvolume(&Env::real(), agent_name).await } /// `SnapshotAgentSubvolume`. @@ -63,7 +63,7 @@ pub(crate) async fn exec_snapshot_agent_subvolume( ) -> Result<(String, String)> { validate_agent_name(agent_name)?; validate_snapshot_name(snapshot_name)?; - snapshot_agent_subvolume(agent_name, snapshot_name).await + snapshot_agent_subvolume(&Env::real(), agent_name, snapshot_name).await } /// `DeleteAgentSnapshot`. @@ -73,7 +73,7 @@ pub(crate) async fn exec_delete_agent_snapshot( ) -> Result<(String, String)> { validate_agent_name(agent_name)?; validate_snapshot_name(snapshot_name)?; - delete_agent_snapshot(agent_name, snapshot_name).await + delete_agent_snapshot(&Env::real(), agent_name, snapshot_name).await } /// `SendAgentSnapshotToFile`. @@ -90,6 +90,7 @@ pub(crate) async fn exec_send_agent_snapshot_to_file( } validate_credential_name(dest_file_name)?; send_agent_snapshot_to_file( + &Env::real(), agent_name, snapshot_name, parent_snapshot_name, @@ -111,7 +112,14 @@ pub(crate) async fn exec_send_agent_snapshot_to_fd( validate_snapshot_name(parent)?; } let dest = fd.context("no descriptor to stream into")?; - send_agent_snapshot_to_fd(agent_name, snapshot_name, parent_snapshot_name, dest).await + send_agent_snapshot_to_fd( + &Env::real(), + agent_name, + snapshot_name, + parent_snapshot_name, + dest, + ) + .await } /// A btrfs snapshot label must start with `hive-` — this doubles as an @@ -157,17 +165,53 @@ fn is_on_btrfs(path: &Path) -> Result { fn is_btrfs_subvolume(path: &Path) -> bool { use std::os::unix::fs::MetadataExt as _; let on_btrfs = is_on_btrfs(path).unwrap_or(false); - let ino_match = std::fs::metadata(path).is_ok_and(|m| m.ino() == BTRFS_SUBVOL_ROOT_INO); - on_btrfs && ino_match + let ino = std::fs::metadata(path).ok().map(|m| m.ino()); + is_subvolume_root(on_btrfs, ino) +} + +/// A subvolume root is a directory on btrfs whose inode is +/// [`BTRFS_SUBVOL_ROOT_INO`]; a missing path (`None`) is none. +fn is_subvolume_root(on_btrfs: bool, ino: Option) -> bool { + on_btrfs && ino == Some(BTRFS_SUBVOL_ROOT_INO) +} + +/// What the storage ops act on and run. [`Env::real`] is production; tests +/// point the roots at a scratch tree and swap the btrfs probes and program, +/// so no root, btrfs filesystem or `btrfs` binary is needed. +struct Env<'a> { + /// Holds one state root per agent (`AGENT_STATE_ROOT`). + state_root: &'a Path, + /// Where snapshot exports are written (`MIGRATE_STAGING_ROOT`). + staging_root: &'a Path, + /// A fresh `btrfs` command; the ops append their own arguments. + btrfs: &'a (dyn Fn() -> Command + Sync), + on_btrfs: fn(&Path) -> Result, + is_subvolume: fn(&Path) -> bool, +} + +fn real_btrfs() -> Command { + Command::new("btrfs") +} + +impl Env<'static> { + fn real() -> Self { + Self { + state_root: Path::new(AGENT_STATE_ROOT), + staging_root: Path::new(MIGRATE_STAGING_ROOT), + btrfs: &real_btrfs, + on_btrfs: is_on_btrfs, + is_subvolume: is_btrfs_subvolume, + } + } } /// `EnsureAgentSubvolume` — make the agent's state root a btrfs subvolume /// when the FS supports it. Idempotent + progressive: no-op when the root /// already exists or the FS isn't btrfs. See the wire doc on the variant. -async fn ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> { +async fn ensure_agent_subvolume(env: &Env<'_>, agent_name: &str) -> Result<(String, String)> { use std::os::unix::fs::MetadataExt as _; - let root = PathBuf::from(AGENT_STATE_ROOT); + let root = env.state_root; let agent_root = root.join(agent_name); // Progressive: existing agents (plain dir OR already a subvol) are left @@ -179,13 +223,13 @@ async fn ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> { // Only btrfs supports subvolumes; on anything else hive-c0re's normal // `create_dir_all` makes a plain dir (the pre-subvolume behaviour). The // parent must exist for both statfs and `btrfs subvolume create`. - std::fs::create_dir_all(&root) + std::fs::create_dir_all(root) .with_context(|| format!("create agents root {}", root.display()))?; - if !is_on_btrfs(&root)? { + if !(env.on_btrfs)(root)? { return Ok((String::new(), String::new())); } - let out = Command::new("btrfs") + let out = (env.btrfs)() .args(["subvolume", "create"]) .arg(&agent_root) .output() @@ -207,7 +251,7 @@ async fn ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> { // c0re-side exists-check would then skip re-running this op on retry, // wedging the agent. So on any failure roll the subvol back and bail — the // create path surfaces a clear error and a retry starts clean. - let chown_result = std::fs::metadata(&root) + let chown_result = std::fs::metadata(root) .with_context(|| format!("stat agents root {} for ownership", root.display())) .and_then(|meta| { std::os::unix::fs::chown(&agent_root, Some(meta.uid()), Some(meta.gid())).with_context( @@ -216,7 +260,7 @@ async fn ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> { }); if let Err(e) = chown_result { // Best-effort rollback so we never leave a root-owned subvol behind. - let _ = Command::new("btrfs") + let _ = (env.btrfs)() .args(["subvolume", "delete"]) .arg(&agent_root) .output() @@ -233,12 +277,12 @@ async fn ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> { /// `DeleteAgentSubvolume` — delete the agent's state root iff it's a btrfs /// subvolume (purge path only). No-op for plain dirs / missing paths; /// hive-c0re's own `remove_dir_all` covers those. See the wire doc. -async fn delete_agent_subvolume(agent_name: &str) -> Result<(String, String)> { - let agent_root = PathBuf::from(AGENT_STATE_ROOT).join(agent_name); - if !is_btrfs_subvolume(&agent_root) { +async fn delete_agent_subvolume(env: &Env<'_>, agent_name: &str) -> Result<(String, String)> { + let agent_root = env.state_root.join(agent_name); + if !(env.is_subvolume)(&agent_root) { return Ok((String::new(), String::new())); } - let out = Command::new("btrfs") + let out = (env.btrfs)() .args(["subvolume", "delete"]) .arg(&agent_root) .output() @@ -259,10 +303,14 @@ async fn delete_agent_subvolume(agent_name: &str) -> Result<(String, String)> { /// holding `AGENT_STATE_ROOT`. Idempotent + statfs-gated (no-op off btrfs). /// Operator opt-in only; see the wire doc. pub(crate) async fn ensure_btrfs_quota() -> Result<(String, String)> { - let root = PathBuf::from(AGENT_STATE_ROOT); - std::fs::create_dir_all(&root) + ensure_btrfs_quota_in(&Env::real()).await +} + +async fn ensure_btrfs_quota_in(env: &Env<'_>) -> Result<(String, String)> { + let root = env.state_root; + std::fs::create_dir_all(root) .with_context(|| format!("create agents root {}", root.display()))?; - if !is_on_btrfs(&root)? { + if !(env.on_btrfs)(root)? { // Non-btrfs host: quota/qgroups don't apply. No-op success so the // operator-facing verb degrades cleanly. return Ok(( @@ -270,9 +318,9 @@ pub(crate) async fn ensure_btrfs_quota() -> Result<(String, String)> { "filesystem is not btrfs — quota not applicable".to_owned(), )); } - let out = Command::new("btrfs") + let out = (env.btrfs)() .args(["quota", "enable"]) - .arg(&root) + .arg(root) .output() .await .with_context(|| format!("spawn btrfs quota enable {}", root.display()))?; @@ -293,9 +341,9 @@ pub(crate) async fn ensure_btrfs_quota() -> Result<(String, String)> { /// excluding ancestral qgroups (per btrfs-qgroup-show(8)) — so it scopes /// to this subvolume and never mixes in other agents'. hive-c0re then /// selects the level-0 (`0/`) leaf row. See the wire doc. -async fn read_subvolume_usage(agent_name: &str) -> Result<(String, String)> { - let agent_root = PathBuf::from(AGENT_STATE_ROOT).join(agent_name); - let out = Command::new("btrfs") +async fn read_subvolume_usage(env: &Env<'_>, agent_name: &str) -> Result<(String, String)> { + let agent_root = env.state_root.join(agent_name); + let out = (env.btrfs)() .args(["qgroup", "show", "-f", "--raw"]) .arg(&agent_root) .output() @@ -334,16 +382,23 @@ fn agent_disk_paths(agent_name: &str) -> [PathBuf; 3] { /// returning the grand total in bytes. An agent with none of them has no /// measurable size, which is an error rather than a total of 0. async fn measure_agent_disk(agent_name: &str) -> Result<(String, String)> { + let paths = existing_paths(agent_disk_paths(agent_name))?; + let total = du_total(agent_name, du_command(&paths)?, MEASURE_AGENT_DISK_TIMEOUT).await?; + Ok((total.to_string(), String::new())) +} + +/// The members of `candidates` that exist, in order. A dangling link counts +/// (it is looked at, not followed); only a missing path is skipped. +fn existing_paths(candidates: impl IntoIterator) -> Result> { let mut paths = Vec::new(); - for path in agent_disk_paths(agent_name) { + for path in candidates { match std::fs::symlink_metadata(&path) { Ok(_) => paths.push(path), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(e).with_context(|| format!("stat {}", path.display())), } } - let total = du_total(agent_name, du_command(&paths)?, MEASURE_AGENT_DISK_TIMEOUT).await?; - Ok((total.to_string(), String::new())) + Ok(paths) } /// `du -s -x -b -c -P -- `. `-x` keeps each walk on its operand's @@ -404,9 +459,9 @@ fn parse_du_total(stdout: &str) -> Option { /// upgrade: try `btrfs subvolume delete` (in case it's a half-created /// subvolume) then a plain recursive remove. Both failures are ignored — /// the path may simply not exist. -async fn cleanup_stale_path(path: &Path) { +async fn cleanup_stale_path(env: &Env<'_>, path: &Path) { if path.exists() { - let _ = Command::new("btrfs") + let _ = (env.btrfs)() .args(["subvolume", "delete"]) .arg(path) .output() @@ -420,11 +475,11 @@ async fn cleanup_stale_path(path: &Path) { /// ownership/permissions/xattrs, then match the subvolume root's owner + mode /// to the original. On any failure the partially-staged `tmp` is cleaned up /// (so the caller can bail with the original dir still untouched). -async fn stage_upgrade_subvolume(agent_root: &Path, tmp: &Path) -> Result<()> { +async fn stage_upgrade_subvolume(env: &Env<'_>, agent_root: &Path, tmp: &Path) -> Result<()> { use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; // Fresh subvolume to receive the copy. - let out = Command::new("btrfs") + let out = (env.btrfs)() .args(["subvolume", "create"]) .arg(tmp) .output() @@ -451,7 +506,7 @@ async fn stage_upgrade_subvolume(agent_root: &Path, tmp: &Path) -> Result<()> { .await .with_context(|| format!("spawn cp into {}", tmp.display()))?; if !copy.status.success() { - cleanup_stale_path(tmp).await; + cleanup_stale_path(env, tmp).await; bail!( "copy {} -> {} failed (original left untouched): {}", agent_root.display(), @@ -474,7 +529,7 @@ async fn stage_upgrade_subvolume(agent_root: &Path, tmp: &Path) -> Result<()> { Ok(()) }); if let Err(e) = apply { - cleanup_stale_path(tmp).await; + cleanup_stale_path(env, tmp).await; return Err(e.context("upgrade aborted before swap; original left untouched")); } Ok(()) @@ -488,8 +543,8 @@ async fn stage_upgrade_subvolume(agent_root: &Path, tmp: &Path) -> Result<()> { /// ([`stage_upgrade_subvolume`]), then rename the original aside and the /// subvolume into place, then remove the original. Any failure before the /// rename-swap leaves the original dir untouched. -async fn upgrade_agent_subvolume(agent_name: &str) -> Result<(String, String)> { - let root = PathBuf::from(AGENT_STATE_ROOT); +async fn upgrade_agent_subvolume(env: &Env<'_>, agent_name: &str) -> Result<(String, String)> { + let root = env.state_root; let agent_root = root.join(agent_name); if !agent_root.exists() { @@ -514,10 +569,10 @@ async fn upgrade_agent_subvolume(agent_name: &str) -> Result<(String, String)> { ); } // Idempotent: already a subvolume → nothing to do. - if is_btrfs_subvolume(&agent_root) { + if (env.is_subvolume)(&agent_root) { return Ok((String::new(), String::new())); } - if !is_on_btrfs(&root)? { + if !(env.on_btrfs)(root)? { bail!( "{} is not on btrfs — subvolumes are unsupported, cannot upgrade", root.display() @@ -530,17 +585,17 @@ async fn upgrade_agent_subvolume(agent_name: &str) -> Result<(String, String)> { let tmp = root.join(format!(".{agent_name}.migrating")); let old = root.join(format!(".{agent_name}.old")); // Clear any debris from a previously interrupted run before starting. - cleanup_stale_path(&tmp).await; - cleanup_stale_path(&old).await; + cleanup_stale_path(env, &tmp).await; + cleanup_stale_path(env, &old).await; - stage_upgrade_subvolume(&agent_root, &tmp).await?; + stage_upgrade_subvolume(env, &agent_root, &tmp).await?; // Swap. `rename` is atomic within a filesystem. The window between the // two renames is the only unsafe point: a crash there leaves the agent // root missing but both `.old` (original) and the new subvolume present // — recoverable by hand, hence the loud logging. if let Err(e) = std::fs::rename(&agent_root, &old) { - cleanup_stale_path(&tmp).await; + cleanup_stale_path(env, &tmp).await; return Err(anyhow::Error::new(e).context(format!( "rename {} -> {} failed; original left untouched", agent_root.display(), @@ -550,7 +605,7 @@ async fn upgrade_agent_subvolume(agent_name: &str) -> Result<(String, String)> { if let Err(e) = std::fs::rename(&tmp, &agent_root) { // Restore the original from its renamed-aside copy. let restored = std::fs::rename(&old, &agent_root).is_ok(); - cleanup_stale_path(&tmp).await; + cleanup_stale_path(env, &tmp).await; return Err(anyhow::Error::new(e).context(format!( "rename {} -> {} failed; original {}", tmp.display(), @@ -584,32 +639,33 @@ async fn upgrade_agent_subvolume(agent_name: &str) -> Result<(String, String)> { /// Derive a snapshot's path from the agent name + label: a dot-prefixed /// sibling of the agent's state root so it can never collide with a real /// agent directory (`validate_agent_name` rejects dot-prefixed names). -fn snapshot_path(agent_name: &str, snapshot_name: &str) -> PathBuf { - PathBuf::from(AGENT_STATE_ROOT).join(format!(".{agent_name}.snapshot.{snapshot_name}")) +fn snapshot_path(root: &Path, agent_name: &str, snapshot_name: &str) -> PathBuf { + root.join(format!(".{agent_name}.snapshot.{snapshot_name}")) } /// `SnapshotAgentSubvolume` — create a read-only btrfs snapshot of an /// agent's state subvolume, for `btrfs send` to stream from during /// inter-hive migration. See the wire doc. async fn snapshot_agent_subvolume( + env: &Env<'_>, agent_name: &str, snapshot_name: &str, ) -> Result<(String, String)> { - let agent_root = PathBuf::from(AGENT_STATE_ROOT).join(agent_name); - if !is_btrfs_subvolume(&agent_root) { + let agent_root = env.state_root.join(agent_name); + if !(env.is_subvolume)(&agent_root) { bail!( "{} is not a btrfs subvolume — nothing to snapshot (run `hivectl agent subvol upgrade` first)", agent_root.display() ); } - let snap = snapshot_path(agent_name, snapshot_name); + let snap = snapshot_path(env.state_root, agent_name, snapshot_name); if snap.exists() { bail!( "snapshot {} already exists — delete it first or pick a different name", snap.display() ); } - let out = Command::new("btrfs") + let out = (env.btrfs)() .args(["subvolume", "snapshot", "-r"]) .arg(&agent_root) .arg(&snap) @@ -639,12 +695,16 @@ async fn snapshot_agent_subvolume( /// `DeleteAgentSnapshot` — delete a previously-created read-only snapshot. /// No-op if the path doesn't exist. See the wire doc. -async fn delete_agent_snapshot(agent_name: &str, snapshot_name: &str) -> Result<(String, String)> { - let snap = snapshot_path(agent_name, snapshot_name); +async fn delete_agent_snapshot( + env: &Env<'_>, + agent_name: &str, + snapshot_name: &str, +) -> Result<(String, String)> { + let snap = snapshot_path(env.state_root, agent_name, snapshot_name); if !snap.exists() { return Ok((String::new(), String::new())); } - let out = Command::new("btrfs") + let out = (env.btrfs)() .args(["subvolume", "delete"]) .arg(&snap) .output() @@ -755,24 +815,25 @@ impl Drop for PartialExportGuard<'_> { /// inter-hive migration transport; see `PrivRequest::SendAgentSnapshotToFile` /// for the cross-hive follow-up. async fn send_agent_snapshot_to_file( + env: &Env<'_>, agent_name: &str, snapshot_name: &str, parent_name: Option<&str>, dest_file_name: &str, ) -> Result<(String, String)> { - let snap = snapshot_path(agent_name, snapshot_name); - let parent_path = parent_name.map(|parent| snapshot_path(agent_name, parent)); + let snap = snapshot_path(env.state_root, agent_name, snapshot_name); + let parent_path = parent_name.map(|parent| snapshot_path(env.state_root, agent_name, parent)); - std::fs::create_dir_all(MIGRATE_STAGING_ROOT) - .with_context(|| format!("create {MIGRATE_STAGING_ROOT}"))?; - let dest = Path::new(MIGRATE_STAGING_ROOT).join(dest_file_name); + let staging = env.staging_root; + std::fs::create_dir_all(staging).with_context(|| format!("create {}", staging.display()))?; + let dest = staging.join(dest_file_name); // Preconditions are checked *inside* this call, before `dest` is // created — see `open_export_dest`'s doc comment for why that ordering // matters and is kept in one place. let dest_file = open_export_dest(&snap, parent_path.as_deref(), &dest)?; let cleanup = PartialExportGuard::new(&dest); - let mut cmd = Command::new("btrfs"); + let mut cmd = (env.btrfs)(); cmd.arg("send"); if let Some(parent_path) = &parent_path { cmd.arg("-p").arg(parent_path); @@ -814,12 +875,13 @@ async fn send_agent_snapshot_to_file( /// decision — and keeps everyone out of the data path once `btrfs send` /// starts, which matters at multi-gigabyte sizes. async fn send_agent_snapshot_to_fd( + env: &Env<'_>, agent_name: &str, snapshot_name: &str, parent_name: Option<&str>, dest: OwnedFd, ) -> Result<(String, String)> { - let snap = snapshot_path(agent_name, snapshot_name); + let snap = snapshot_path(env.state_root, agent_name, snapshot_name); if !snap.exists() { bail!( "snapshot {} does not exist — create it with `subvol snapshot create` first", @@ -827,10 +889,10 @@ async fn send_agent_snapshot_to_fd( ); } - let mut cmd = Command::new("btrfs"); + let mut cmd = (env.btrfs)(); cmd.arg("send"); if let Some(parent) = parent_name { - let parent_path = snapshot_path(agent_name, parent); + let parent_path = snapshot_path(env.state_root, agent_name, parent); if !parent_path.exists() { bail!( "parent snapshot {} does not exist — pick an existing parent or omit it for a full send", @@ -870,12 +932,13 @@ async fn send_agent_snapshot_to_fd( /// subvolume (`btrfs qgroup limit <…/agent_name>`). See the /// wire doc. async fn set_subvolume_quota( + env: &Env<'_>, agent_name: &str, limit_bytes: Option, ) -> Result<(String, String)> { - let agent_root = PathBuf::from(AGENT_STATE_ROOT).join(agent_name); + let agent_root = env.state_root.join(agent_name); let limit = limit_bytes.map_or_else(|| "none".to_owned(), |n| n.to_string()); - let out = Command::new("btrfs") + let out = (env.btrfs)() .args(["qgroup", "limit", &limit]) .arg(&agent_root) .output() @@ -894,10 +957,20 @@ async fn set_subvolume_quota( #[cfg(test)] mod tests { use super::{ - Command, agent_disk_paths, du_command, du_total, exec_measure_agent_disk, open_export_dest, - parse_du_total, validate_snapshot_name, + Command, Env, agent_disk_paths, cleanup_stale_path, delete_agent_snapshot, + delete_agent_subvolume, du_command, du_total, ensure_agent_subvolume, + ensure_btrfs_quota_in, exec_delete_agent_snapshot, exec_delete_agent_subvolume, + exec_ensure_agent_subvolume, exec_measure_agent_disk, exec_read_subvolume_usage, + exec_send_agent_snapshot_to_fd, exec_send_agent_snapshot_to_file, exec_set_subvolume_quota, + exec_snapshot_agent_subvolume, exec_upgrade_agent_subvolume, existing_paths, + is_btrfs_subvolume, is_on_btrfs, is_subvolume_root, open_export_dest, parse_du_total, + read_subvolume_usage, send_agent_snapshot_to_fd, send_agent_snapshot_to_file, + set_subvolume_quota, snapshot_agent_subvolume, snapshot_path, upgrade_agent_subvolume, + validate_snapshot_name, }; - use std::path::PathBuf; + use crate::shared::test_support::scratch; + use crate::shared::validate_agent_name; + use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; @@ -1082,4 +1155,826 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); assert!(total.unwrap() >= 5); } + + // --- name and path decisions ------------------------------------------- + + #[test] + fn snapshot_labels_are_a_strict_allow_list() { + for ok in ["hive-x", "hive-", "hive-a_b-1", "hive-A9"] { + validate_snapshot_name(ok).unwrap_or_else(|e| panic!("{ok:?} must pass: {e}")); + } + for bad in [ + "", + "hive", + "xhive-a", + "Hive-a", + "hive-a.b", + "hive-..", + "hive-../x", + "hive-a/b", + "hive-a b", + "hive-a\0b", + "hive-é", + ] { + assert!( + validate_snapshot_name(bad).is_err(), + "{bad:?} must be rejected" + ); + } + } + + /// A snapshot is a dot-prefixed sibling of the agent dirs, so no agent + /// name can be the same entry, and it never leaves the state root. + #[test] + fn snapshot_path_is_a_dot_sibling_no_agent_can_own() { + let root = Path::new("/state"); + let p = snapshot_path(root, "iris", "hive-x"); + assert_eq!(p, PathBuf::from("/state/.iris.snapshot.hive-x")); + assert_eq!(p.parent(), Some(root)); + let leaf = p.file_name().unwrap().to_str().unwrap(); + assert!( + validate_agent_name(leaf).is_err(), + "{leaf:?} would be a valid agent dir" + ); + assert_ne!(p, snapshot_path(root, "other", "hive-x")); + assert_ne!(p, snapshot_path(root, "iris", "hive-y")); + } + + #[test] + fn a_subvolume_root_needs_btrfs_and_inode_256() { + assert!(is_subvolume_root(true, Some(256))); + assert!(!is_subvolume_root(false, Some(256))); + assert!(!is_subvolume_root(true, Some(255))); + assert!(!is_subvolume_root(true, Some(257))); + assert!(!is_subvolume_root(true, None)); + } + + #[test] + fn btrfs_probes_on_real_paths() { + assert!(!is_on_btrfs(Path::new("/proc")).unwrap()); + let err = is_on_btrfs(Path::new("/nonexistent-hive-priv-test")).unwrap_err(); + assert!(err.to_string().contains("statfs"), "{err}"); + let err = is_on_btrfs(Path::new("/pro\0c")).unwrap_err(); + assert!(err.to_string().contains("interior null"), "{err}"); + assert!(!is_btrfs_subvolume(Path::new("/proc"))); + assert!(!is_btrfs_subvolume(Path::new( + "/nonexistent-hive-priv-test" + ))); + } + + /// Every op refuses an agent name that could leave its directory before + /// any path is built. The control: a valid name gets past validation and + /// stops at the next check instead. + #[tokio::test] + async fn every_op_validates_the_agent_name_first() { + for bad in ["", "..", "../etc", "a/b", "/etc", "Iris", "a.b", "a\0b"] { + let want = |r: anyhow::Result<(String, String)>, op: &str| { + let err = r.expect_err(&format!("{op}({bad:?}) must be rejected")); + assert!( + err.to_string().contains("invalid name"), + "{op}({bad:?}): {err}" + ); + }; + want(exec_ensure_agent_subvolume(bad).await, "ensure"); + want(exec_delete_agent_subvolume(bad).await, "delete"); + want(exec_read_subvolume_usage(bad).await, "usage"); + want(exec_set_subvolume_quota(bad, Some(1)).await, "quota"); + want(exec_upgrade_agent_subvolume(bad).await, "upgrade"); + want( + exec_snapshot_agent_subvolume(bad, "hive-x").await, + "snapshot", + ); + want( + exec_delete_agent_snapshot(bad, "hive-x").await, + "delete-snapshot", + ); + want( + exec_send_agent_snapshot_to_file(bad, "hive-x", None, "out").await, + "send-file", + ); + want( + exec_send_agent_snapshot_to_fd(bad, "hive-x", None, None).await, + "send-fd", + ); + } + let err = exec_send_agent_snapshot_to_fd("iris", "hive-x", None, None) + .await + .unwrap_err(); + assert!(err.to_string().contains("no descriptor"), "{err}"); + } + + #[tokio::test] + async fn every_snapshot_op_validates_the_labels_and_destination() { + for bad in ["iris", "hive", "hive-../x", "hive-a/b", "hive-a.b", ""] { + let want = |r: anyhow::Result<(String, String)>, op: &str| { + let err = r.expect_err(&format!("{op}({bad:?}) must be rejected")); + let msg = err.to_string(); + assert!( + msg.contains("invalid snapshot label") + || msg.contains("invalid credential name"), + "{op}({bad:?}): {msg}" + ); + }; + want(exec_snapshot_agent_subvolume("iris", bad).await, "snapshot"); + want( + exec_delete_agent_snapshot("iris", bad).await, + "delete-snapshot", + ); + want( + exec_send_agent_snapshot_to_file("iris", bad, None, "out").await, + "send-file snapshot", + ); + want( + exec_send_agent_snapshot_to_file("iris", "hive-x", Some(bad), "out").await, + "send-file parent", + ); + want( + exec_send_agent_snapshot_to_fd("iris", bad, None, None).await, + "send-fd snapshot", + ); + want( + exec_send_agent_snapshot_to_fd("iris", "hive-x", Some(bad), None).await, + "send-fd parent", + ); + } + for bad in ["", ".", "..", "../x", "a/b", "a.b", "/etc/passwd"] { + let err = exec_send_agent_snapshot_to_file("iris", "hive-x", None, bad) + .await + .expect_err(&format!("dest {bad:?} must be rejected")); + assert!( + err.to_string().contains("invalid credential name"), + "{bad:?}: {err}" + ); + } + } + + #[test] + fn measured_paths_skip_only_the_missing_ones() { + let dir = scratch(); + let file = dir.join("file"); + std::fs::write(&file, b"x").unwrap(); + let dangling = dir.join("dangling"); + std::os::unix::fs::symlink(dir.join("gone"), &dangling).unwrap(); + let missing = dir.join("missing"); + let got = existing_paths([missing.clone(), dangling.clone(), file.clone(), dir.clone()]); + assert_eq!(got.unwrap(), [dangling, file, dir]); + assert!(existing_paths([missing]).unwrap().is_empty()); + } + + // --- the ops against a scratch tree and a fake btrfs ------------------- + + /// Scratch state root, staging root and the log the fake btrfs writes. + struct Fx { + state: PathBuf, + staging: PathBuf, + log: PathBuf, + } + + impl Fx { + fn new() -> Self { + let top = scratch().canonicalize().unwrap(); + let state = top.join("agents"); + std::fs::create_dir_all(&state).unwrap(); + Self { + state, + staging: top.join("staging"), + log: top.join("btrfs.log"), + } + } + + fn agent(&self, name: &str) -> PathBuf { + self.state.join(name) + } + + fn snap(&self, agent: &str, label: &str) -> PathBuf { + self.state.join(format!(".{agent}.snapshot.{label}")) + } + + /// The fake btrfs's invocations, one argument string each. + fn calls(&self) -> Vec { + std::fs::read_to_string(&self.log) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + + /// An [`Env`] over this tree, running `btrfs` and the two probes given. + fn env<'a>( + &'a self, + btrfs: &'a (dyn Fn() -> Command + Sync), + on_btrfs: fn(&Path) -> anyhow::Result, + is_subvolume: fn(&Path) -> bool, + ) -> Env<'a> { + Env { + state_root: &self.state, + staging_root: &self.staging, + btrfs, + on_btrfs, + is_subvolume, + } + } + + /// A `btrfs` that logs its arguments, then runs the shell fragment + /// `body` (`$1…` are btrfs's arguments). + fn btrfs(&self, body: &str) -> impl Fn() -> Command + Sync + use<> { + let log = self.log.clone(); + let script = format!("echo \"$*\" >> \"$FAKE_LOG\"\n{body}"); + move || { + let mut c = Command::new("sh"); + c.arg("-c").arg(&script).arg("btrfs").env("FAKE_LOG", &log); + c + } + } + } + + /// Creates, deletes and snapshots as btrfs does; `send` writes `STREAM`. + const LIKE_BTRFS: &str = r#"case "$1 $2" in + "subvolume create") mkdir "$3";; + "subvolume delete") rm -rf "$3";; + "subvolume snapshot") cp -a "$4" "$5";; + send*) printf STREAM;; +esac"#; + const FAIL: &str = r#"echo "boom: no space left" >&2; exit 1"#; + const NOTHING: &str = ":"; + + /// Probes that fail on a path they cannot stat, as `statfs` does. + fn on_btrfs(path: &Path) -> anyhow::Result { + path.try_exists()?; + Ok(true) + } + fn off_btrfs(path: &Path) -> anyhow::Result { + path.try_exists()?; + Ok(false) + } + fn probe_fails(_: &Path) -> anyhow::Result { + anyhow::bail!("statfs refused") + } + fn is_subvol(_: &Path) -> bool { + true + } + fn not_subvol(_: &Path) -> bool { + false + } + + #[tokio::test] + async fn ensure_leaves_an_existing_agent_root_alone() { + let fx = Fx::new(); + std::fs::create_dir(fx.agent("iris")).unwrap(); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + assert_eq!( + ensure_agent_subvolume(&env, "iris").await.unwrap(), + (String::new(), String::new()) + ); + assert!(fx.calls().is_empty(), "{:?}", fx.calls()); + } + + #[tokio::test] + async fn ensure_makes_no_subvolume_off_btrfs_but_creates_the_root() { + let fx = Fx::new(); + std::fs::remove_dir(&fx.state).unwrap(); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, off_btrfs, not_subvol); + ensure_agent_subvolume(&env, "iris").await.unwrap(); + assert!(fx.calls().is_empty()); + assert!(fx.state.is_dir(), "the root is created for the probe"); + assert!(!fx.agent("iris").exists()); + } + + #[tokio::test] + async fn ensure_creates_the_subvolume_owned_like_the_root() { + use std::os::unix::fs::MetadataExt as _; + let fx = Fx::new(); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + ensure_agent_subvolume(&env, "iris").await.unwrap(); + assert_eq!( + fx.calls(), + [format!("subvolume create {}", fx.agent("iris").display())] + ); + let (root, agent) = ( + std::fs::metadata(&fx.state).unwrap(), + std::fs::metadata(fx.agent("iris")).unwrap(), + ); + assert_eq!((agent.uid(), agent.gid()), (root.uid(), root.gid())); + } + + /// The test user can chown only to its own groups, so the group of the + /// root is the one id that can differ from what `mkdir` gives. + #[tokio::test] + async fn ensure_hands_the_subvolume_to_the_roots_group() { + use std::os::unix::fs::MetadataExt as _; + let mut groups = [0 as libc::gid_t; 64]; + // SAFETY: `groups` has room for the 64 ids it declares. + let n = unsafe { libc::getgroups(64, groups.as_mut_ptr()) }; + let primary = std::fs::metadata("/proc/self").unwrap().gid(); + let Some(other) = groups[..usize::try_from(n).unwrap_or(0)] + .iter() + .copied() + .find(|g| *g != primary) + else { + return; + }; + let fx = Fx::new(); + std::os::unix::fs::chown(&fx.state, None, Some(other)).unwrap(); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + ensure_agent_subvolume(&env, "iris").await.unwrap(); + assert_eq!(std::fs::metadata(fx.agent("iris")).unwrap().gid(), other); + } + + #[tokio::test] + async fn ensure_rolls_the_subvolume_back_when_the_chown_fails() { + let fx = Fx::new(); + // The fake creates nothing, so the chown has no target. + let btrfs = fx.btrfs(NOTHING); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + let err = ensure_agent_subvolume(&env, "iris").await.unwrap_err(); + assert!(err.to_string().contains("rolled back"), "{err:#}"); + let agent = fx.agent("iris"); + assert_eq!( + fx.calls(), + [ + format!("subvolume create {}", agent.display()), + format!("subvolume delete {}", agent.display()), + ] + ); + } + + #[tokio::test] + async fn ensure_reports_a_failed_create_without_rolling_back() { + let fx = Fx::new(); + let btrfs = fx.btrfs(FAIL); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + let err = ensure_agent_subvolume(&env, "iris").await.unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("btrfs subvolume create") && msg.contains("boom"), + "{msg}" + ); + assert_eq!(fx.calls().len(), 1, "no rollback of what never existed"); + } + + #[tokio::test] + async fn ensure_surfaces_a_missing_btrfs_binary_and_a_failed_probe() { + let fx = Fx::new(); + let missing = || Command::new("/nonexistent-hive-priv-test/btrfs"); + let env = fx.env(&missing, on_btrfs, not_subvol); + let err = ensure_agent_subvolume(&env, "iris").await.unwrap_err(); + assert!( + err.to_string().contains("spawn btrfs subvolume create"), + "{err:#}" + ); + + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, probe_fails, not_subvol); + let err = ensure_agent_subvolume(&env, "iris").await.unwrap_err(); + assert!(err.to_string().contains("statfs refused"), "{err:#}"); + assert!(fx.calls().is_empty()); + } + + #[tokio::test] + async fn delete_removes_only_a_real_subvolume() { + let fx = Fx::new(); + std::fs::create_dir(fx.agent("iris")).unwrap(); + let btrfs = fx.btrfs(LIKE_BTRFS); + + // A plain dir (or a missing one) is hive-c0re's to remove. + let env = fx.env(&btrfs, on_btrfs, not_subvol); + delete_agent_subvolume(&env, "iris").await.unwrap(); + delete_agent_subvolume(&env, "nobody").await.unwrap(); + assert!(fx.calls().is_empty()); + assert!(fx.agent("iris").is_dir()); + + let env = fx.env(&btrfs, on_btrfs, is_subvol); + delete_agent_subvolume(&env, "iris").await.unwrap(); + assert_eq!( + fx.calls(), + [format!("subvolume delete {}", fx.agent("iris").display())] + ); + assert!(!fx.agent("iris").exists()); + } + + #[tokio::test] + async fn delete_reports_a_btrfs_failure() { + let fx = Fx::new(); + let btrfs = fx.btrfs(FAIL); + let env = fx.env(&btrfs, on_btrfs, is_subvol); + let err = delete_agent_subvolume(&env, "iris").await.unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + } + + #[tokio::test] + async fn quota_enable_is_a_noop_off_btrfs() { + let fx = Fx::new(); + std::fs::remove_dir(&fx.state).unwrap(); + let btrfs = fx.btrfs(NOTHING); + let env = fx.env(&btrfs, off_btrfs, not_subvol); + let (out, note) = ensure_btrfs_quota_in(&env).await.unwrap(); + assert!(out.is_empty() && note.contains("not btrfs"), "{note}"); + assert!(fx.calls().is_empty()); + assert!(fx.state.is_dir()); + } + + #[tokio::test] + async fn quota_enable_targets_the_state_root() { + let fx = Fx::new(); + let btrfs = fx.btrfs(NOTHING); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + ensure_btrfs_quota_in(&env).await.unwrap(); + assert_eq!(fx.calls(), [format!("quota enable {}", fx.state.display())]); + + let failing = fx.btrfs(FAIL); + let env = fx.env(&failing, on_btrfs, not_subvol); + let err = ensure_btrfs_quota_in(&env).await.unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + let env = fx.env(&btrfs, probe_fails, not_subvol); + assert!(ensure_btrfs_quota_in(&env).await.is_err()); + } + + #[tokio::test] + async fn usage_is_scoped_to_the_agent_and_returned_verbatim() { + let fx = Fx::new(); + let btrfs = fx.btrfs(r"printf '0/257 4096 4096\n'"); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + let (out, err) = read_subvolume_usage(&env, "iris").await.unwrap(); + assert_eq!((out.as_str(), err.as_str()), ("0/257 4096 4096\n", "")); + assert_eq!( + fx.calls(), + [format!( + "qgroup show -f --raw {}", + fx.agent("iris").display() + )] + ); + + let failing = fx.btrfs(FAIL); + let env = fx.env(&failing, on_btrfs, not_subvol); + let err = read_subvolume_usage(&env, "iris").await.unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + } + + #[tokio::test] + async fn quota_limit_is_the_byte_count_or_none() { + let fx = Fx::new(); + let btrfs = fx.btrfs(NOTHING); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + set_subvolume_quota(&env, "iris", Some(1_048_576)) + .await + .unwrap(); + set_subvolume_quota(&env, "iris", None).await.unwrap(); + let a = fx.agent("iris"); + assert_eq!( + fx.calls(), + [ + format!("qgroup limit 1048576 {}", a.display()), + format!("qgroup limit none {}", a.display()), + ] + ); + + let failing = fx.btrfs(FAIL); + let env = fx.env(&failing, on_btrfs, not_subvol); + let err = set_subvolume_quota(&env, "iris", Some(7)) + .await + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("limit 7") && msg.contains("boom"), "{msg}"); + } + + #[tokio::test] + async fn snapshot_needs_a_subvolume_and_an_unused_label() { + let fx = Fx::new(); + std::fs::create_dir(fx.agent("iris")).unwrap(); + let btrfs = fx.btrfs(LIKE_BTRFS); + + let env = fx.env(&btrfs, on_btrfs, not_subvol); + let err = snapshot_agent_subvolume(&env, "iris", "hive-a") + .await + .unwrap_err(); + assert!(err.to_string().contains("not a btrfs subvolume"), "{err}"); + + let env = fx.env(&btrfs, on_btrfs, is_subvol); + let (path, _) = snapshot_agent_subvolume(&env, "iris", "hive-a") + .await + .unwrap(); + let snap = fx.snap("iris", "hive-a"); + assert_eq!(path, snap.display().to_string()); + assert_eq!( + fx.calls(), + [format!( + "subvolume snapshot -r {} {}", + fx.agent("iris").display(), + snap.display() + )] + ); + + let err = snapshot_agent_subvolume(&env, "iris", "hive-a") + .await + .unwrap_err(); + assert!(err.to_string().contains("already exists"), "{err}"); + assert_eq!(fx.calls().len(), 1, "an existing snapshot is never re-run"); + } + + #[tokio::test] + async fn snapshot_reports_a_btrfs_failure() { + let fx = Fx::new(); + let btrfs = fx.btrfs(FAIL); + let env = fx.env(&btrfs, on_btrfs, is_subvol); + let err = snapshot_agent_subvolume(&env, "iris", "hive-a") + .await + .unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("snapshot -r") && msg.contains("boom"), "{msg}"); + } + + #[tokio::test] + async fn deleting_a_snapshot_touches_only_that_snapshot() { + let fx = Fx::new(); + std::fs::create_dir(fx.agent("iris")).unwrap(); + std::fs::create_dir(fx.snap("iris", "hive-a")).unwrap(); + std::fs::create_dir(fx.snap("iris", "hive-b")).unwrap(); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + + delete_agent_snapshot(&env, "iris", "hive-gone") + .await + .unwrap(); + assert!(fx.calls().is_empty(), "a missing snapshot is a no-op"); + + delete_agent_snapshot(&env, "iris", "hive-a").await.unwrap(); + assert_eq!( + fx.calls(), + [format!( + "subvolume delete {}", + fx.snap("iris", "hive-a").display() + )] + ); + assert!(!fx.snap("iris", "hive-a").exists()); + assert!(fx.snap("iris", "hive-b").exists()); + assert!(fx.agent("iris").exists()); + + let failing = fx.btrfs(FAIL); + let env = fx.env(&failing, on_btrfs, not_subvol); + let err = delete_agent_snapshot(&env, "iris", "hive-b") + .await + .unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + } + + #[tokio::test] + async fn stale_paths_are_cleared_only_when_present() { + let fx = Fx::new(); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + let stale = fx.state.join(".iris.migrating"); + cleanup_stale_path(&env, &stale).await; + assert!(fx.calls().is_empty()); + + std::fs::create_dir_all(stale.join("debris")).unwrap(); + cleanup_stale_path(&env, &stale).await; + assert_eq!( + fx.calls(), + [format!("subvolume delete {}", stale.display())] + ); + assert!(!stale.exists()); + + // The plain remove covers what `btrfs subvolume delete` refuses. + std::fs::create_dir_all(stale.join("debris")).unwrap(); + let refusing = fx.btrfs(FAIL); + let env = fx.env(&refusing, on_btrfs, not_subvol); + cleanup_stale_path(&env, &stale).await; + assert!(!stale.exists()); + } + + // --- upgrade ------------------------------------------------------------- + + fn plain_agent(fx: &Fx) { + use std::os::unix::fs::PermissionsExt as _; + let a = fx.agent("iris"); + std::fs::create_dir_all(a.join("state")).unwrap(); + std::fs::write(a.join("state/notes"), b"keep me").unwrap(); + std::fs::set_permissions(&a, std::fs::Permissions::from_mode(0o750)).unwrap(); + } + + #[tokio::test] + async fn upgrade_swaps_in_a_copy_with_the_same_data_and_mode() { + use std::os::unix::fs::MetadataExt as _; + let fx = Fx::new(); + plain_agent(&fx); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + let (out, _) = upgrade_agent_subvolume(&env, "iris").await.unwrap(); + assert!(out.contains("upgraded"), "{out}"); + + let a = fx.agent("iris"); + assert_eq!(std::fs::read(a.join("state/notes")).unwrap(), b"keep me"); + let meta = std::fs::metadata(&a).unwrap(); + assert_eq!(meta.mode() & 0o777, 0o750); + assert!(!fx.state.join(".iris.migrating").exists()); + assert!(!fx.state.join(".iris.old").exists()); + assert_eq!( + fx.calls(), + [format!( + "subvolume create {}", + fx.state.join(".iris.migrating").display() + )] + ); + } + + #[tokio::test] + async fn upgrade_clears_debris_from_an_interrupted_run_first() { + let fx = Fx::new(); + plain_agent(&fx); + for leftover in [".iris.migrating", ".iris.old"] { + std::fs::create_dir_all(fx.state.join(leftover).join("junk")).unwrap(); + } + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + upgrade_agent_subvolume(&env, "iris").await.unwrap(); + assert!(!fx.agent("iris").join("junk").exists()); + assert!(!fx.state.join(".iris.old").exists()); + assert_eq!(fx.calls().len(), 3, "{:?}", fx.calls()); + } + + #[tokio::test] + async fn upgrade_is_a_noop_on_a_subvolume_and_refuses_off_btrfs() { + let fx = Fx::new(); + plain_agent(&fx); + let btrfs = fx.btrfs(LIKE_BTRFS); + + let env = fx.env(&btrfs, on_btrfs, is_subvol); + let (out, _) = upgrade_agent_subvolume(&env, "iris").await.unwrap(); + assert!(out.is_empty()); + + let env = fx.env(&btrfs, off_btrfs, not_subvol); + let err = upgrade_agent_subvolume(&env, "iris").await.unwrap_err(); + assert!(err.to_string().contains("not on btrfs"), "{err}"); + + assert!(fx.calls().is_empty()); + assert!(fx.agent("iris").join("state/notes").exists()); + } + + #[tokio::test] + async fn upgrade_leaves_the_original_when_the_subvolume_cannot_be_made() { + let fx = Fx::new(); + plain_agent(&fx); + let btrfs = fx.btrfs(FAIL); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + let err = upgrade_agent_subvolume(&env, "iris").await.unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + assert_eq!( + std::fs::read(fx.agent("iris").join("state/notes")).unwrap(), + b"keep me" + ); + assert!(!fx.state.join(".iris.old").exists()); + } + + #[tokio::test] + async fn upgrade_points_at_the_data_after_a_crash_mid_swap() { + let fx = Fx::new(); + std::fs::create_dir_all(fx.state.join(".iris.old")).unwrap(); + std::fs::write(fx.state.join(".iris.old/data"), b"x").unwrap(); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + + let err = upgrade_agent_subvolume(&env, "iris").await.unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("mv ") && msg.contains(".iris.old"), "{msg}"); + assert!( + fx.state.join(".iris.old/data").exists(), + "recovery data kept" + ); + assert!(fx.calls().is_empty()); + + let err = upgrade_agent_subvolume(&env, "nobody").await.unwrap_err(); + assert!(err.to_string().contains("nothing to do"), "{err}"); + } + + // --- export --------------------------------------------------------------- + + fn snapshots(fx: &Fx, labels: &[&str]) { + for l in labels { + std::fs::create_dir_all(fx.snap("iris", l)).unwrap(); + } + } + + #[tokio::test] + async fn export_to_file_streams_into_the_staging_root() { + let fx = Fx::new(); + snapshots(&fx, &["hive-a", "hive-p"]); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + + let (dest, _) = send_agent_snapshot_to_file(&env, "iris", "hive-a", Some("hive-p"), "out") + .await + .unwrap(); + assert_eq!(Path::new(&dest), fx.staging.join("out")); + assert_eq!(std::fs::read(&dest).unwrap(), b"STREAM"); + + send_agent_snapshot_to_file(&env, "iris", "hive-a", None, "full") + .await + .unwrap(); + let (a, p) = (fx.snap("iris", "hive-a"), fx.snap("iris", "hive-p")); + assert_eq!( + fx.calls(), + [ + format!("send -p {} {}", p.display(), a.display()), + format!("send {}", a.display()), + ] + ); + } + + #[tokio::test] + async fn export_to_file_never_overwrites_and_never_leaves_a_partial() { + let fx = Fx::new(); + snapshots(&fx, &["hive-a"]); + std::fs::create_dir_all(&fx.staging).unwrap(); + std::fs::write(fx.staging.join("taken"), b"earlier export").unwrap(); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + + let err = send_agent_snapshot_to_file(&env, "iris", "hive-a", None, "taken") + .await + .unwrap_err(); + assert!(err.to_string().contains("already exists"), "{err}"); + assert_eq!( + std::fs::read(fx.staging.join("taken")).unwrap(), + b"earlier export" + ); + assert!( + fx.calls().is_empty(), + "btrfs never ran against a taken dest" + ); + + let failing = fx.btrfs(FAIL); + let env = fx.env(&failing, on_btrfs, not_subvol); + let err = send_agent_snapshot_to_file(&env, "iris", "hive-a", None, "half") + .await + .unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + assert!(!fx.staging.join("half").exists(), "partial export removed"); + + let err = send_agent_snapshot_to_file(&env, "iris", "hive-missing", None, "none") + .await + .unwrap_err(); + assert!(err.to_string().contains("does not exist"), "{err}"); + assert!(!fx.staging.join("none").exists()); + } + + fn fd_for(path: &Path) -> std::os::fd::OwnedFd { + std::os::fd::OwnedFd::from(std::fs::File::create(path).unwrap()) + } + + #[tokio::test] + async fn export_to_fd_streams_into_the_descriptor() { + let fx = Fx::new(); + snapshots(&fx, &["hive-a", "hive-p"]); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + let sink = fx.state.join("sink"); + + let (out, err) = + send_agent_snapshot_to_fd(&env, "iris", "hive-a", Some("hive-p"), fd_for(&sink)) + .await + .unwrap(); + assert!(out.is_empty() && err.is_empty()); + assert_eq!(std::fs::read(&sink).unwrap(), b"STREAM"); + send_agent_snapshot_to_fd(&env, "iris", "hive-a", None, fd_for(&sink)) + .await + .unwrap(); + let (a, p) = (fx.snap("iris", "hive-a"), fx.snap("iris", "hive-p")); + assert_eq!( + fx.calls(), + [ + format!("send -p {} {}", p.display(), a.display()), + format!("send {}", a.display()), + ] + ); + } + + #[tokio::test] + async fn export_to_fd_needs_both_snapshots_and_reports_a_failed_send() { + let fx = Fx::new(); + snapshots(&fx, &["hive-a"]); + let sink = fx.state.join("sink"); + let btrfs = fx.btrfs(LIKE_BTRFS); + let env = fx.env(&btrfs, on_btrfs, not_subvol); + + let err = send_agent_snapshot_to_fd(&env, "iris", "hive-no", None, fd_for(&sink)) + .await + .unwrap_err(); + assert!(err.to_string().contains("does not exist"), "{err}"); + let err = send_agent_snapshot_to_fd(&env, "iris", "hive-a", Some("hive-no"), fd_for(&sink)) + .await + .unwrap_err(); + assert!(err.to_string().contains("parent snapshot"), "{err}"); + assert!(fx.calls().is_empty()); + + let failing = fx.btrfs(FAIL); + let env = fx.env(&failing, on_btrfs, not_subvol); + let err = send_agent_snapshot_to_fd(&env, "iris", "hive-a", None, fd_for(&sink)) + .await + .unwrap_err(); + assert!(err.to_string().contains("boom"), "{err}"); + } }