Skip to content

Commit 88da089

Browse files
authored
fix(cli): disclose orphaned temp path and correct --atomic help text (#537)
* fix(cli): correct stale --atomic help text for swap ordering and permissions The atomic field's doc comment described the pre-#519 removal-before-rename ordering and never mentioned #531's Unix parent-read-permission requirement. Update it to match the current backup-then-swap-then-remove implementation. Closes #532 * fix(cli): disclose surviving temp/backup directory after atomic-force failure run_atomic_force_extraction creates its temp and backup directories with path-based calls, and its best-effort cleanup on failure is also path-based. If an intermediate component of the destination's parent is replaced with a symlink while extraction is in progress, that cleanup can silently target a decoy at the redirected location instead of the real directory, leaving content behind with no indication of where. Every failure site now captures each directory's (dev, ino) identity via PinnedDir::entry_status right after creating it (fd-relative to the already- pinned parent, so unaffected by the redirect), and re-checks that identity after the cleanup attempt. When content survives, its current path is resolved fresh from an fd opened on the entry itself rather than the (possibly still-redirected) logical path, since the logical path does not necessarily lead there anymore. This does not, and cannot, discover content written directly to a redirect-created decoy directory if the redirect reverts before this check runs: exarch-core's own per-entry extraction writes are path-based, and making them fd-relative is out of scope for this fix. That narrower case remains a genuine, undisclosed orphan. Not a security escape and not new data loss, only a disclosure gap in the error/warning text. Closes #530 * docs(skills): sync exarch-cli SKILL.md with corrected --atomic behavior The --atomic flag's skill table entry repeated the same stale removal-before-rename description cli.rs's help text had (#532), and didn't mention the temp/backup disclosure added for #530. Refs #530, #532
1 parent dd860d6 commit 88da089

6 files changed

Lines changed: 789 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
153153

154154
### Changed
155155

156+
- **`exarch-cli`: `extract --help`'s `--atomic` text described a stale swap ordering and omitted
157+
the Unix permission requirement `--force` now has (#532)**: the `atomic` field's doc comment in
158+
`cli.rs` said the existing destination is "removed after successful extraction (just before
159+
rename), not before" — a description that predates the #519 fix and no longer matches
160+
`run_atomic_force_extraction`'s actual swap ordering (old destination renamed aside first, new
161+
content renamed into place second, old destination removed only once that swap succeeds). It also
162+
never mentioned #531's Unix-only change requiring read permission on the destination's parent
163+
directory. The doc comment now describes both accurately. No behavior change, `--help` text only.
156164
- **Duplicate-file, duplicate-symlink, and duplicate-hardlink skip paths now share a
157165
`common::checked_increment_files_skipped` helper (#518)** instead of repeating the same
158166
checked-add-with-overflow-guard increment inline in `formats::common::extract_file_with_permit`,
@@ -312,6 +320,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
312320

313321
### Fixed
314322

323+
- **`exarch-cli`: `extract --atomic --force` did not disclose the location of a temp/backup
324+
directory left behind by a parent-directory redirect mid-extraction (#530)**:
325+
`run_atomic_force_extraction` creates its temp and backup directories with path-based calls
326+
(`tempfile::tempdir_in`), and its best-effort cleanup on failure (`std::fs::remove_dir_all`) is
327+
also path-based — if an intermediate component of the destination's parent is replaced with a
328+
symlink while extraction is in progress, that cleanup call can silently target a decoy at the
329+
redirected location instead of the real directory, leaving genuine content behind with no
330+
indication of where. Every failure site in `run_atomic_force_extraction` now captures each
331+
directory's `(dev, ino)` identity via `PinnedDir::entry_status` (fd-relative to the already-pinned
332+
parent, so unaffected by the redirect) right after creating it, and after the best-effort cleanup
333+
attempt, re-checks that identity: if the directory still exists and matches, its *current* path is
334+
resolved fresh from a freshly opened fd on the entry itself (`PinnedDir::open_entry` +
335+
`commands::atomic_swap::current_path`, using `/proc/self/fd` on Linux and `F_GETPATH` via
336+
`rustix::fs::getpath` on macOS) and disclosed in the error message — critical, since the *logical*
337+
path built from the (possibly still-redirected) parent does not necessarily lead there anymore,
338+
confirmed by live-reproducing a persisting mid-extraction redirect and checking that the disclosed
339+
path resolves to the real, surviving content rather than the decoy. If no fd-to-path facility is
340+
available (any Unix other than Linux/macOS), falls back to naming the `(dev, ino)` identity with a
341+
`find -inum` pointer instead of a path — Unix only, since `entry_status` always reports the
342+
identity `(0, 0)` on other platforms, which would make that pointer meaningless there. Capturing the
343+
backup directory's own identity (used only so a later failure to remove it can be disclosed) is
344+
itself best-effort: by the time it runs, the destination has already been renamed into the backup's
345+
place, so a failure there must not abort an otherwise-successful swap over and above what actually
346+
failed — it degrades to skipping that one disclosure opportunity instead. In the ordinary
347+
(non-redirected) case, cleanup succeeds and
348+
the identity recheck correctly finds nothing, so no directory is disclosed and none is left behind
349+
— this is deliberate: an earlier version of this fix instead persisted the temp directory
350+
unconditionally, which left one behind on *every* failed `--atomic --force`, not just the redirect
351+
race; the identity-recheck approach avoids that regression entirely. Non-Unix targets fall back to
352+
naming the (possibly stale) logical path unconditionally when content survives, since
353+
`PinnedDir::entry_status` cannot distinguish "our" directory from a replacement there (documented
354+
residual, same as #526). This does not, and cannot, discover content written directly to a
355+
redirect-created decoy directory if the redirect is reverted before this check runs — `exarch-core`'s
356+
own per-entry extraction writes are path-based and out of scope for this fix to make fd-relative;
357+
that narrower case remains a genuine, undisclosed orphan. Not a security escape (the fd-pinned swap
358+
logic from #526/GHSA-x8wr-7ww2-c94x still confines renames/removes correctly) and not new data
359+
loss, only a disclosure gap in the error/warning text.
315360
- **`exarch-core`: `verify`/`list` reported `PASS` for a TAR archive containing a non-UTF8 entry
316361
name that may fail to extract on filesystems requiring UTF-8 names (#528)**: TAR entry names are
317362
stored byte-exact (no lossy conversion) in `ArchiveEntry.path`, but

crates/exarch-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ serde_json.workspace = true
2727
tempfile.workspace = true
2828

2929
[target.'cfg(unix)'.dependencies]
30-
rustix = { workspace = true, features = ["fs"] }
30+
rustix = { workspace = true, features = ["fs", "alloc"] }
3131

3232
[dev-dependencies]
3333
assert_cmd.workspace = true

crates/exarch-cli/src/cli.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,15 @@ pub struct ExtractArgs {
114114
pub force: bool,
115115

116116
/// Extract atomically: use a temp dir, rename on success, clean up on
117-
/// failure. When combined with --force, the existing destination is
118-
/// removed after successful extraction (just before rename), not
119-
/// before, to minimize the window where neither old nor new data
120-
/// exists.
117+
/// failure. When combined with --force to replace an existing
118+
/// destination, the existing destination is renamed aside first, the
119+
/// new content is renamed into its place second, and only once that
120+
/// swap has succeeded is the old destination removed - never before, so
121+
/// a failure at any point still leaves either the old or the new
122+
/// content in place under the destination name. On Unix, --force also
123+
/// requires read permission on the destination's parent directory (used
124+
/// to pin it by file descriptor for the swap); the destination itself
125+
/// does not need to be readable.
121126
#[arg(long)]
122127
pub atomic: bool,
123128

crates/exarch-cli/src/commands/atomic_swap.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,63 @@ impl PinnedDir {
129129
rustix::fs::unlinkat(&self.0, name, rustix::fs::AtFlags::REMOVEDIR)?;
130130
Ok(())
131131
}
132+
133+
/// Opens the directory entry `name`, resolved relative to the pinned
134+
/// directory, and returns an owned handle to it.
135+
///
136+
/// Used by callers that need [`current_path`] on the specific entry
137+
/// `name` identifies — resolving *its* current location in the
138+
/// filesystem namespace, not `parent`'s — rather than anything this type
139+
/// exposes about `name` itself. `NOFOLLOW` matches every other lookup in
140+
/// this type: a symlink at `name` is rejected, not followed.
141+
pub(super) fn open_entry(&self, name: &OsStr) -> io::Result<std::fs::File> {
142+
let fd = rustix::fs::openat(
143+
&self.0,
144+
name,
145+
rustix::fs::OFlags::RDONLY
146+
| rustix::fs::OFlags::DIRECTORY
147+
| rustix::fs::OFlags::CLOEXEC
148+
| rustix::fs::OFlags::NOFOLLOW,
149+
rustix::fs::Mode::empty(),
150+
)?;
151+
Ok(std::fs::File::from(fd))
152+
}
153+
}
154+
155+
/// Best-effort: `fd`'s current path in the filesystem namespace, resolved
156+
/// directly from the descriptor rather than any stored string — so it
157+
/// reflects wherever the entry actually is *now*, unaffected by a redirect
158+
/// of any ancestor path component since `fd` was opened. Used to disclose an
159+
/// accurate, walkable location for content [`PinnedDir::entry_status`] has
160+
/// already confirmed survives (issue #530): that confirmation alone doesn't
161+
/// guarantee the *logical* path built from `parent`'s (possibly still
162+
/// redirected) display path actually leads there — this does.
163+
///
164+
/// Returns `None` if this platform has no fd-to-path facility implemented
165+
/// here (anything other than Linux or macOS); callers must have a fallback,
166+
/// such as naming the logical path with a caveat, for that case.
167+
#[cfg(target_os = "linux")]
168+
pub(super) fn current_path(fd: &std::fs::File) -> Option<std::path::PathBuf> {
169+
use std::os::fd::AsRawFd;
170+
std::fs::read_link(format!("/proc/self/fd/{}", fd.as_raw_fd())).ok()
171+
}
172+
173+
/// macOS implementation of [`current_path`] via `fcntl(fd, F_GETPATH)`, the
174+
/// platform's direct fd-to-path facility (Linux's `/proc/self/fd` has no
175+
/// macOS equivalent).
176+
#[cfg(target_os = "macos")]
177+
pub(super) fn current_path(fd: &std::fs::File) -> Option<std::path::PathBuf> {
178+
use std::os::unix::ffi::OsStrExt;
179+
rustix::fs::getpath(fd)
180+
.ok()
181+
.map(|c_path| std::path::PathBuf::from(std::ffi::OsStr::from_bytes(c_path.as_bytes())))
182+
}
183+
184+
/// Fallback for Unix targets with no fd-to-path facility implemented here:
185+
/// no accurate current path is available.
186+
#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
187+
pub(super) fn current_path(_fd: &std::fs::File) -> Option<std::path::PathBuf> {
188+
None
132189
}
133190

134191
/// Non-Unix fallback: plain path-based operations.
@@ -188,6 +245,27 @@ impl PinnedDir {
188245
pub(super) fn remove_dir(&self, name: &OsStr) -> io::Result<()> {
189246
std::fs::remove_dir(self.parent.join(name))
190247
}
248+
249+
/// Opens the directory entry `name`, resolved by joining `self.parent`.
250+
///
251+
/// Path-based, same residual as every other operation on this platform's
252+
/// `PinnedDir`. Kept for signature parity with the Unix impl so callers
253+
/// never need to branch on platform; its result feeds into
254+
/// [`current_path`], which always returns `None` on this platform
255+
/// regardless of whether this call succeeds.
256+
pub(super) fn open_entry(&self, name: &OsStr) -> io::Result<std::fs::File> {
257+
std::fs::File::open(self.parent.join(name))
258+
}
259+
}
260+
261+
/// Non-Unix fallback for [`current_path`]: no fd-to-path facility is
262+
/// implemented here, so this always returns `None` — the same "documented
263+
/// residual, not a false guarantee" pattern as [`PinnedDir`]'s non-Unix
264+
/// `entry_status` always reporting `(0, 0)`. Callers fall back to naming the
265+
/// logical path instead.
266+
#[cfg(not(unix))]
267+
pub(super) fn current_path(_fd: &std::fs::File) -> Option<std::path::PathBuf> {
268+
None
191269
}
192270

193271
/// Reports whether `metadata` carries `FILE_ATTRIBUTE_REPARSE_POINT`.
@@ -216,6 +294,7 @@ fn is_reparse_point(metadata: &std::fs::Metadata) -> bool {
216294
mod tests {
217295
use super::DestEntryKind;
218296
use super::PinnedDir;
297+
use super::current_path;
219298
use std::ffi::OsStr;
220299
use std::os::unix::fs::PermissionsExt;
221300
use std::os::unix::fs::symlink;
@@ -413,4 +492,89 @@ mod tests {
413492
),
414493
}
415494
}
495+
496+
/// Regression test for issue #530, round 3: `current_path` must resolve
497+
/// an entry's *current* location from its own fd, not the path used to
498+
/// open it. Proven directly here — no timing race needed, since the
499+
/// redirect only has to happen *between* `open_entry` and `current_path`,
500+
/// not concurrently with either — by the same
501+
/// move-aside-and-symlink-over technique
502+
/// `rename_follows_the_pinned_inode_not_a_planted_symlink` uses for
503+
/// `rename`. This is the property `survival_clause` (in `extract.rs`)
504+
/// depends on to disclose the real surviving location instead of a
505+
/// decoy or nothing.
506+
#[test]
507+
#[cfg(any(target_os = "linux", target_os = "macos"))]
508+
fn current_path_resolves_through_a_redirected_parent() {
509+
let root = tempfile::tempdir().expect("tempdir");
510+
let real_parent = root.path().join("real_parent");
511+
std::fs::create_dir(&real_parent).expect("create real_parent");
512+
std::fs::create_dir(real_parent.join("entry")).expect("create entry");
513+
514+
let pin = PinnedDir::open(&real_parent).expect("pin real_parent");
515+
let fd = pin
516+
.open_entry(OsStr::new("entry"))
517+
.expect("open entry via pinned fd");
518+
519+
// Redirect the path `real_parent` used to name: move the real
520+
// directory aside and put a symlink to a decoy in its place.
521+
let decoy = root.path().join("decoy");
522+
std::fs::create_dir(&decoy).expect("create decoy");
523+
let moved_real_parent = root.path().join("real_parent_moved");
524+
std::fs::rename(&real_parent, &moved_real_parent).expect("move real parent aside");
525+
symlink(&decoy, &real_parent).expect("plant symlink at the old path");
526+
527+
let resolved = current_path(&fd).expect("current_path must resolve on this platform");
528+
529+
// `current_path` resolves through every symlink in the filesystem
530+
// (e.g. macOS's `/tmp` -> `/private/tmp`), so the expected side must
531+
// be canonicalized the same way before comparing — the property
532+
// under test is "reflects the real, moved location", not "matches
533+
// the exact string `root.path()` happened to be built from".
534+
let expected = moved_real_parent
535+
.join("entry")
536+
.canonicalize()
537+
.expect("canonicalize expected path");
538+
assert_eq!(
539+
resolved, expected,
540+
"current_path must reflect the entry's real, moved location, not the stale logical \
541+
path or the decoy"
542+
);
543+
}
544+
545+
/// `open_entry` must fail closed (`NOFOLLOW`) rather than follow a
546+
/// symlink planted at `name`, the same rejection `entry_status` already
547+
/// applies to its own lookup. An fd opened by following a symlink here
548+
/// would make `current_path` resolve to wherever the symlink's target
549+
/// currently is, not the entry `disclose_if_orphaned` is actually
550+
/// tracking.
551+
#[test]
552+
fn open_entry_rejects_a_planted_symlink() {
553+
let root = tempfile::tempdir().expect("tempdir");
554+
let parent = root.path().join("parent");
555+
std::fs::create_dir(&parent).expect("create parent");
556+
let real_dir = root.path().join("real_dir");
557+
std::fs::create_dir(&real_dir).expect("create real_dir");
558+
let link = parent.join("link");
559+
symlink(&real_dir, &link).expect("plant symlink");
560+
561+
let pin = PinnedDir::open(&parent).expect("pin parent");
562+
let err = pin
563+
.open_entry(OsStr::new("link"))
564+
.expect_err("a symlink entry must be rejected, not followed");
565+
// The specific errno is platform-dependent: combined with
566+
// `O_DIRECTORY`, Linux's `open(2)` still reports `ELOOP` for a
567+
// `NOFOLLOW`'d symlink, while macOS/BSD report `ENOTDIR` instead
568+
// (`io::ErrorKind::FilesystemLoop` itself is also still unstable —
569+
// `io_error_more`, rust-lang/rust#86442 — hence comparing the raw
570+
// errno rather than the `ErrorKind`). Either is acceptable here: what
571+
// matters is that the open fails closed rather than following the
572+
// symlink into a real, followable directory fd.
573+
let raw = err.raw_os_error();
574+
assert!(
575+
raw == Some(rustix::io::Errno::LOOP.raw_os_error())
576+
|| raw == Some(rustix::io::Errno::NOTDIR.raw_os_error()),
577+
"expected NOFOLLOW to reject the symlink with ELOOP or ENOTDIR, got: {err}"
578+
);
579+
}
416580
}

0 commit comments

Comments
 (0)