No behaviour change: a pure move plus pub(crate) visibility. PrivRequest, hive-priv-sock and every validation rule are untouched; the 58 tests move next to the code they test, unchanged. Why (#5476): the 28 root ops shared one 4.3k-line file, so no op family could be reviewed on its own and every new root op landed there by default. main.rs keeps the socket loop and the dispatch table. container (lifecycle), container_config (nspawn flags, systemd drop-ins, daemon-reload), storage (btrfs subvolumes, quotas, snapshots, du), files (pause marker, socket dir, legacy tmpfiles) and infra (gateway nginx, forge admin, infra containers) each carry their own validation; shared holds the agent-name checks, the credential-name charset and StagedFile, plus the tests' scratch helpers (one counter crate-wide, so test dirs stay unique). hive-priv/README.md is in the diff because it named src/main.rs as the home of container_flake_action and now describes the module layout.
491 lines
21 KiB
Rust
491 lines
21 KiB
Rust
//! Root-side file ops in agent-owned or agent-reachable directories:
|
|
//! `SetAgentPaused` (the pause marker in the agent's `harness/`),
|
|
//! `EnsureAgentSocketDir` (`/run/hive-agent/<name>`) and the legacy
|
|
//! `SyncAgentTmpfiles`, which only removes the tmpfiles file older builds
|
|
//! wrote.
|
|
|
|
use std::os::fd::AsRawFd as _;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use hive_priv_sock::{AGENT_STATE_ROOT, PAUSED_MARKER_FILE};
|
|
|
|
use crate::shared::{StagedFile, ensure_plain_filename, open_dir, validate_agent_name};
|
|
|
|
/// Root of the per-agent unix-socket dirs on the host.
|
|
const SOCKET_DIR_ROOT: &str = "/run/hive-agent";
|
|
|
|
/// `SetAgentPaused`.
|
|
pub(crate) fn exec_set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, String)> {
|
|
validate_agent_name(agent_name)?;
|
|
set_agent_paused(agent_name, paused)
|
|
}
|
|
|
|
/// Create or remove an agent's pause marker under its harness dir. The
|
|
/// marker is written empty and chowned to the harness dir's owner (the
|
|
/// agent), matching how the harness itself would have created it.
|
|
///
|
|
/// Both directions are idempotent: re-pausing truncates the existing empty
|
|
/// marker rather than failing, and a `NotFound` on removal is the
|
|
/// already-resumed case, not an error.
|
|
fn set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, String)> {
|
|
let harness_dir = PathBuf::from(AGENT_STATE_ROOT)
|
|
.join(agent_name)
|
|
.join("harness");
|
|
if paused {
|
|
return write_agent_dir_file(agent_name, &harness_dir, PAUSED_MARKER_FILE, "");
|
|
}
|
|
remove_marker_in(&harness_dir, PAUSED_MARKER_FILE)?;
|
|
tracing::info!(agent = %agent_name, "cleared pause marker");
|
|
Ok((String::new(), String::new()))
|
|
}
|
|
|
|
/// Unlink `dir/filename`, treating "already gone" as success.
|
|
///
|
|
/// `remove_file` unlinks the leaf itself and never follows a symlink, so an
|
|
/// agent-planted link at the marker path cannot redirect this root unlink
|
|
/// at another file — the same threat [`StagedFile`] closes on the create side.
|
|
fn remove_marker_in(dir: &Path, filename: &str) -> Result<()> {
|
|
let path = dir.join(filename);
|
|
match std::fs::remove_file(&path) {
|
|
Ok(()) => Ok(()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(e).with_context(|| format!("remove {}", path.display())),
|
|
}
|
|
}
|
|
|
|
/// Write `content` to `dir/filename` as root, chowning the result to `dir`'s
|
|
/// owner so the agent process can read it back. Its caller is the pause
|
|
/// marker, in `harness/`, a directory owned by the agent, which is precisely
|
|
/// why it needs hive-priv at all.
|
|
///
|
|
/// The file is published through a [`StagedFile`], so a reader woken by its
|
|
/// appearance cannot catch it empty or half-written: several of these paths
|
|
/// have a `systemd.path` unit watching them, and one of those triggers on the
|
|
/// file existing rather than changing.
|
|
fn write_agent_dir_file(
|
|
agent_name: &str,
|
|
dir: &Path,
|
|
filename: &str,
|
|
content: &str,
|
|
) -> Result<(String, String)> {
|
|
use std::os::fd::AsRawFd as _;
|
|
use std::os::unix::fs::MetadataExt as _;
|
|
|
|
// Checked before `create_dir_all`, so a bad leaf does no filesystem work.
|
|
ensure_plain_filename("write_agent_dir_file", filename)?;
|
|
|
|
let state_dir = dir.to_path_buf();
|
|
// NOTE: `create_dir_all` is normally a no-op — lifecycle creates and chowns
|
|
// the state dir during spawn. On the rare edge where the dir doesn't exist
|
|
// yet (container being provisioned for the first time), the newly created dir
|
|
// is root:root. The `stat state_dir` chown below will then see uid=0 and
|
|
// leave the file root-owned (0600). The agent won't be able to read it until
|
|
// its lifecycle completes.
|
|
std::fs::create_dir_all(&state_dir)
|
|
.with_context(|| format!("create state dir {}", state_dir.display()))?;
|
|
|
|
// Security-critical: the (state/-owning) agent may plant a symlink or
|
|
// hardlink in this dir, and `StagedFile` neither follows nor reuses one,
|
|
// so this root-privileged create/write/chmod/chown can't be redirected at
|
|
// an arbitrary file.
|
|
let path = state_dir.join(filename);
|
|
let dir_fd = open_dir(&state_dir)?;
|
|
let staged = StagedFile::write(&dir_fd, &state_dir, filename, content.as_bytes(), 0o600)?;
|
|
|
|
// Chown to the state dir's owner so the agent process can read the file.
|
|
// fchown on the temp's fd — TOCTOU-immune (the inode the write hit, never a
|
|
// swapped path). If stat fails (e.g. dir just created, owner is root), the
|
|
// file stays root-owned and 0600 — still unreadable by others, just not
|
|
// agent-readable. Log a warning so operators can diagnose.
|
|
match dir_fd.metadata() {
|
|
Ok(meta) => {
|
|
// SAFETY: the temp's fd is open and owned by `staged` for the whole
|
|
// call; `fchown` only mutates that inode's uid/gid.
|
|
let rc = unsafe { libc::fchown(staged.file().as_raw_fd(), meta.uid(), meta.gid()) };
|
|
if rc != 0 {
|
|
let e = std::io::Error::last_os_error();
|
|
tracing::warn!(
|
|
agent = %agent_name,
|
|
path = %path.display(),
|
|
error = %e,
|
|
"write_agent_dir_file: fchown failed"
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
agent = %agent_name,
|
|
error = %e,
|
|
"write_agent_dir_file: stat state_dir failed, leaving file root-owned"
|
|
);
|
|
}
|
|
}
|
|
// After the chown, never before: the file must never be visible under its
|
|
// final name while still root-owned. A failed publish drops `staged`,
|
|
// which removes the temp and the credential in it.
|
|
staged.publish()?;
|
|
|
|
tracing::info!(
|
|
agent = %agent_name,
|
|
dir = %state_dir.display(),
|
|
file = %filename,
|
|
"wrote agent file"
|
|
);
|
|
Ok((String::new(), String::new()))
|
|
}
|
|
|
|
/// Written by older hive-priv builds. Nothing writes it now, but left on disk
|
|
/// systemd-tmpfiles would still apply it at every boot.
|
|
const LEGACY_TMPFILES_PATH: &str = "/etc/tmpfiles.d/hyperhive-agents.conf";
|
|
|
|
/// Unlink [`LEGACY_TMPFILES_PATH`]; already absent is success. Run at every
|
|
/// hive-priv start and by the legacy `SyncAgentTmpfiles` request.
|
|
pub(crate) fn remove_legacy_tmpfiles() -> Result<()> {
|
|
match std::fs::remove_file(LEGACY_TMPFILES_PATH) {
|
|
Ok(()) => {
|
|
tracing::info!("removed legacy {LEGACY_TMPFILES_PATH}");
|
|
Ok(())
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
Err(e) => Err(e).with_context(|| format!("remove {LEGACY_TMPFILES_PATH}")),
|
|
}
|
|
}
|
|
|
|
/// `EnsureAgentSocketDir` — create `/run/hive-agent/<name>` if missing.
|
|
pub(crate) fn ensure_agent_socket_dir(name: &str) -> Result<(String, String)> {
|
|
ensure_socket_dir_in(Path::new(SOCKET_DIR_ROOT), name)?;
|
|
Ok((String::new(), String::new()))
|
|
}
|
|
|
|
/// Create `<root>/<name>` as a `0751` directory owned by the caller (root in
|
|
/// production), or accept an existing directory untouched.
|
|
/// `name` must be an agent ident; it is validated here, before any path is
|
|
/// built from it.
|
|
///
|
|
/// The result is an nspawn bind source, so a symlink here would bind a host
|
|
/// path of the planter's choosing into the container. Every step is relative
|
|
/// to an `O_DIRECTORY|O_NOFOLLOW` fd for `root`, and an existing entry is
|
|
/// checked with `AT_SYMLINK_NOFOLLOW` and refused unless it is a directory.
|
|
///
|
|
/// An existing directory keeps its owner and mode: the container's activation
|
|
/// hands it to the agent user, and re-asserting root here would undo that on
|
|
/// every start.
|
|
fn ensure_socket_dir_in(root: &Path, name: &str) -> Result<()> {
|
|
use std::os::unix::fs::OpenOptionsExt as _;
|
|
|
|
validate_agent_name(name)?;
|
|
let path = root.join(name);
|
|
let c_name = std::ffi::CString::new(name)
|
|
.with_context(|| format!("agent name {name:?} contains a NUL byte"))?;
|
|
let root_dir = std::fs::OpenOptions::new()
|
|
.read(true)
|
|
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC)
|
|
.open(root)
|
|
.with_context(|| format!("open (no-follow) {}", root.display()))?;
|
|
// SAFETY: `root_dir` is an open directory fd and `c_name` NUL-terminated.
|
|
if unsafe { libc::mkdirat(root_dir.as_raw_fd(), c_name.as_ptr(), 0o751) } == 0 {
|
|
// mkdirat's mode is masked by the umask; the mode is part of the
|
|
// contract, so it is set explicitly.
|
|
// SAFETY: as above.
|
|
let rc = unsafe {
|
|
libc::fchmodat(
|
|
root_dir.as_raw_fd(),
|
|
c_name.as_ptr(),
|
|
0o751,
|
|
libc::AT_SYMLINK_NOFOLLOW,
|
|
)
|
|
};
|
|
if rc != 0 {
|
|
return Err(std::io::Error::last_os_error())
|
|
.with_context(|| format!("chmod 0751 {}", path.display()));
|
|
}
|
|
tracing::info!(path = %path.display(), "created agent socket dir");
|
|
return Ok(());
|
|
}
|
|
let err = std::io::Error::last_os_error();
|
|
if err.kind() != std::io::ErrorKind::AlreadyExists {
|
|
return Err(err).with_context(|| format!("create {}", path.display()));
|
|
}
|
|
// SAFETY: `stat` is plain old data; the zeroed value is only read after
|
|
// `fstatat` has filled it in.
|
|
let mut st: libc::stat = unsafe { std::mem::zeroed() };
|
|
// SAFETY: as for `mkdirat`; `st` is a valid, writable `stat`.
|
|
let rc = unsafe {
|
|
libc::fstatat(
|
|
root_dir.as_raw_fd(),
|
|
c_name.as_ptr(),
|
|
&raw mut st,
|
|
libc::AT_SYMLINK_NOFOLLOW,
|
|
)
|
|
};
|
|
if rc != 0 {
|
|
return Err(std::io::Error::last_os_error())
|
|
.with_context(|| format!("stat (no-follow) {}", path.display()));
|
|
}
|
|
if st.st_mode & libc::S_IFMT != libc::S_IFDIR {
|
|
bail!(
|
|
"{} exists but is not a directory; refusing it as a bind source",
|
|
path.display()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{PAUSED_MARKER_FILE, ensure_socket_dir_in, remove_marker_in, write_agent_dir_file};
|
|
use crate::shared::test_support::{entries, scratch};
|
|
use crate::shared::{StagedFile, open_dir};
|
|
use std::path::PathBuf;
|
|
|
|
#[test]
|
|
fn rejects_non_plain_filenames() {
|
|
let dir = scratch();
|
|
let dir_fd = open_dir(&dir).unwrap();
|
|
for bad in ["", ".", "..", "a/b", "/etc/passwd", "../escape", "sub/tok"] {
|
|
assert!(
|
|
StagedFile::write(&dir_fd, &dir, bad, b"x", 0o600).is_err(),
|
|
"must reject filename {bad:?}"
|
|
);
|
|
// The wrapper's own check runs before its `create_dir_all`.
|
|
// Pointing it at a directory that does not exist yet is what makes
|
|
// this arm bite: the property worth pinning is that a bad leaf
|
|
// fails BEFORE any root-privileged filesystem work.
|
|
let absent = dir.join("never-created");
|
|
assert!(
|
|
write_agent_dir_file("a", &absent, bad, "x").is_err(),
|
|
"wrapper must reject filename {bad:?}"
|
|
);
|
|
assert!(
|
|
!absent.exists(),
|
|
"rejected write created a directory for {bad:?}"
|
|
);
|
|
}
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A published file arrives complete and alone. The residue arm is the
|
|
/// load-bearing one: a stranded temp holds the same secret, is owned by
|
|
/// root, and nothing else in the system would ever remove it.
|
|
#[test]
|
|
fn publishing_leaves_only_the_finished_file() {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
let dir = scratch();
|
|
let names = |d: &PathBuf| {
|
|
let mut v: Vec<String> = std::fs::read_dir(d)
|
|
.unwrap()
|
|
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
|
.collect();
|
|
v.sort();
|
|
v
|
|
};
|
|
assert!(names(&dir).is_empty(), "control: scratch starts empty");
|
|
|
|
let path = dir.join("matrix-token-alice");
|
|
for content in ["SECRET", "ROTATED"] {
|
|
write_agent_dir_file("a", &dir, "matrix-token-alice", content).unwrap();
|
|
assert_eq!(names(&dir), ["matrix-token-alice"], "temp left behind");
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), content);
|
|
}
|
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o600, "credential must be 0600 once published");
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A failed publish must not strand the temp.
|
|
#[test]
|
|
fn a_failed_publish_removes_the_temp() {
|
|
let dir = scratch();
|
|
// A directory at the destination fails the `rename` (EISDIR) without
|
|
// needing to drop privileges; the write and chown ahead of it still run.
|
|
std::fs::create_dir(dir.join("forge-token")).unwrap();
|
|
|
|
let err = write_agent_dir_file("a", &dir, "forge-token", "SECRET")
|
|
.expect_err("publishing onto a directory must fail");
|
|
assert!(
|
|
format!("{err:#}").contains("publishing"),
|
|
"must fail AT the publish -- failing earlier leaves no temp to \
|
|
clean up and makes the next assertion vacuous. got: {err:#}"
|
|
);
|
|
assert_eq!(
|
|
entries(&dir),
|
|
["forge-token"],
|
|
"temp still holds the secret after a failed publish"
|
|
);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The agent plants a symlink where the token goes. The rename replaces
|
|
/// the link; the root-privileged write never reaches its target.
|
|
#[test]
|
|
fn a_symlink_at_the_name_is_replaced_not_followed() {
|
|
let dir = scratch();
|
|
let target = dir.join("target");
|
|
std::fs::write(&target, "original").unwrap();
|
|
std::os::unix::fs::symlink(&target, dir.join("forge-token")).unwrap();
|
|
|
|
write_agent_dir_file("a", &dir, "forge-token", "SECRET").unwrap();
|
|
assert_eq!(
|
|
std::fs::read_to_string(&target).unwrap(),
|
|
"original",
|
|
"symlink target must be untouched"
|
|
);
|
|
let meta = std::fs::symlink_metadata(dir.join("forge-token")).unwrap();
|
|
assert!(meta.file_type().is_file(), "the link must be replaced");
|
|
assert_eq!(
|
|
std::fs::read_to_string(dir.join("forge-token")).unwrap(),
|
|
"SECRET"
|
|
);
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A missing socket dir is created `0751`, owned by the caller (root in
|
|
/// production), which is the mode the container's activation expects to
|
|
/// hand over and the one dialers traverse.
|
|
#[test]
|
|
fn socket_dir_created_0751_when_absent() {
|
|
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
|
|
let dir = scratch();
|
|
ensure_socket_dir_in(&dir, "probe").unwrap();
|
|
let meta = std::fs::symlink_metadata(dir.join("probe")).unwrap();
|
|
assert!(meta.is_dir());
|
|
assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
|
|
// SAFETY: `geteuid` has no preconditions.
|
|
assert_eq!(meta.uid(), unsafe { libc::geteuid() });
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// An existing directory keeps its mode and contents: by the second start
|
|
/// it belongs to the agent user, and a live socket may sit inside it.
|
|
#[test]
|
|
fn socket_dir_existing_directory_left_alone() {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
let dir = scratch();
|
|
let sub = dir.join("probe");
|
|
std::fs::create_dir(&sub).unwrap();
|
|
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
std::fs::write(sub.join("web.sock"), "").unwrap();
|
|
|
|
ensure_socket_dir_in(&dir, "probe").unwrap();
|
|
let mode = std::fs::metadata(&sub).unwrap().permissions().mode() & 0o7777;
|
|
assert_eq!(mode, 0o700, "an existing dir's mode must not be reset");
|
|
assert!(sub.join("web.sock").exists());
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A symlink at the leaf would make nspawn bind wherever it points; it is
|
|
/// refused, whether it points at a directory or nowhere.
|
|
#[test]
|
|
fn socket_dir_refuses_symlink_leaf() {
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
let dir = scratch();
|
|
let elsewhere = dir.join("elsewhere");
|
|
std::fs::create_dir(&elsewhere).unwrap();
|
|
std::fs::set_permissions(&elsewhere, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
std::os::unix::fs::symlink(&elsewhere, dir.join("probe")).unwrap();
|
|
std::os::unix::fs::symlink(dir.join("missing"), dir.join("dangling")).unwrap();
|
|
|
|
assert!(ensure_socket_dir_in(&dir, "probe").is_err());
|
|
assert!(ensure_socket_dir_in(&dir, "dangling").is_err());
|
|
let mode = std::fs::metadata(&elsewhere).unwrap().permissions().mode() & 0o7777;
|
|
assert_eq!(mode, 0o700, "the symlink target must be untouched");
|
|
assert!(!dir.join("missing").exists());
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A regular file where the dir should be is refused, not bound.
|
|
#[test]
|
|
fn socket_dir_refuses_non_directory_leaf() {
|
|
let dir = scratch();
|
|
std::fs::write(dir.join("probe"), "x").unwrap();
|
|
let err = ensure_socket_dir_in(&dir, "probe").unwrap_err();
|
|
assert!(format!("{err:#}").contains("not a directory"), "{err:#}");
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A symlinked root would place the new dir in a tree of the planter's
|
|
/// choosing; the `O_NOFOLLOW` open refuses it before anything is created.
|
|
#[test]
|
|
fn socket_dir_refuses_symlinked_root() {
|
|
let dir = scratch();
|
|
let elsewhere = dir.join("elsewhere");
|
|
std::fs::create_dir(&elsewhere).unwrap();
|
|
let root = dir.join("root");
|
|
std::os::unix::fs::symlink(&elsewhere, &root).unwrap();
|
|
|
|
assert!(ensure_socket_dir_in(&root, "probe").is_err());
|
|
assert!(!elsewhere.join("probe").exists());
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// Names that are not a plain agent ident are refused by validation,
|
|
/// before anything is created. Runs against a scratch parent, so the
|
|
/// refusal cannot come from a missing `/run/hive-agent`; `.`/`..` exist
|
|
/// as directories and `Atlas` would be created, so each bad name gets
|
|
/// through only if validation is gone. Control: a plain name succeeds
|
|
/// in the same parent.
|
|
#[test]
|
|
fn socket_dir_refuses_bogus_names() {
|
|
let dir = scratch();
|
|
for bad in ["", ".", "..", "../etc", "a/b", "Atlas", "a b", "a\0b"] {
|
|
let err = ensure_socket_dir_in(&dir, bad).unwrap_err();
|
|
assert!(
|
|
format!("{err:#}").contains("invalid name"),
|
|
"agent name {bad:?} must be refused by validation, got: {err:#}"
|
|
);
|
|
}
|
|
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
|
|
ensure_socket_dir_in(&dir, "atlas").unwrap();
|
|
assert!(dir.join("atlas").is_dir());
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// The pause marker round-trips through the same root-only path the
|
|
/// credential writes use, and BOTH directions are idempotent — the
|
|
/// dashboard toggle and `hivectl pause|resume` fire blind, without
|
|
/// reading the current state first.
|
|
#[test]
|
|
fn pause_marker_create_and_remove_are_idempotent() {
|
|
let dir = scratch();
|
|
let path = dir.join(PAUSED_MARKER_FILE);
|
|
|
|
for _ in 0..2 {
|
|
write_agent_dir_file("a", &dir, PAUSED_MARKER_FILE, "").unwrap();
|
|
assert!(path.exists(), "marker must exist after pause");
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
|
|
}
|
|
for _ in 0..2 {
|
|
remove_marker_in(&dir, PAUSED_MARKER_FILE).unwrap();
|
|
assert!(!path.exists(), "marker must be gone after resume");
|
|
}
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
/// A resume must never follow an agent-planted symlink at the marker
|
|
/// path: this unlink runs as root, so following it would let an agent
|
|
/// delete an arbitrary file on the host.
|
|
#[test]
|
|
fn resume_unlinks_the_symlink_not_its_target() {
|
|
let dir = scratch();
|
|
let target = dir.join("target");
|
|
std::fs::write(&target, "original").unwrap();
|
|
let link = dir.join(PAUSED_MARKER_FILE);
|
|
std::os::unix::fs::symlink(&target, &link).unwrap();
|
|
|
|
remove_marker_in(&dir, PAUSED_MARKER_FILE).unwrap();
|
|
// `exists()` follows the link, so it can't tell "link removed" from
|
|
// "target removed, dangling link left" — stat the link itself.
|
|
assert!(
|
|
std::fs::symlink_metadata(&link).is_err(),
|
|
"the link itself must be unlinked"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&target).unwrap(),
|
|
"original",
|
|
"symlink target must survive the root unlink"
|
|
);
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
}
|