501 lines
21 KiB
Rust
501 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.
|
|
pub(crate) 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,
|
|
private_users: Option<bool>,
|
|
) -> Result<(String, String)> {
|
|
ensure_socket_dir_in(Path::new(SOCKET_DIR_ROOT), name)?;
|
|
// A namespaced agent's activation cannot chown the dir through the id
|
|
// mapping, so the data owner, which is the agent user inside, gets it here.
|
|
if let Some(owner) = crate::userns::owner_for_agent(name, private_users)? {
|
|
let path = Path::new(SOCKET_DIR_ROOT).join(name);
|
|
std::os::unix::fs::lchown(&path, Some(owner), Some(owner))
|
|
.with_context(|| format!("chown {}", path.display()))?;
|
|
}
|
|
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();
|
|
}
|
|
}
|