jdx/mise · error
expected symlinked appdir tail to be rejected
Error message
expected symlinked appdir tail to be rejected
What it means
Test panic in the cask security tests: ensure_trusted_appdir was given an Applications path whose final component is a symlink to another directory, and it returned Ok. The test requires rejection with a 'cannot open operation directory' error — the tail symlink must be refused via openat-style no-follow semantics — and explicitly must NOT be rejected via the different 'untrusted directory' ancestor guard.
Source
Thrown at src/system/packages/brew/cask/tests.rs:6681
ensure_trusted_appdir(&appdir)?;
assert!(appdir.symlink_metadata()?.file_type().is_dir());
// Idempotent when the directory already exists.
ensure_trusted_appdir(&appdir)?;
Ok(())
}
#[test]
fn ensure_trusted_appdir_rejects_symlinked_tail() -> Result<()> {
// Simulate a symlink planted on the not-yet-existing appdir tail
// between validation and mutation: it must be rejected, not followed.
let tmp = trusted_tempdir()?;
let base = tmp.path().canonicalize()?;
let elsewhere = base.join("elsewhere");
file::create_dir_all(&elsewhere)?;
let appdir = base.join("Applications");
std::os::unix::fs::symlink(&elsewhere, &appdir)?;
let err = match ensure_trusted_appdir(&appdir) {
Ok(_) => panic!("expected symlinked appdir tail to be rejected"),
Err(err) => err.to_string(),
};
// Must fail because the tail is a symlink, not because an ancestor was
// untrusted (which is a different guard).
assert!(err.contains("cannot open operation directory"), "{err}");
assert!(!err.contains("untrusted directory"), "{err}");
Ok(())
}
#[test]
fn ensure_trusted_appdir_stays_bound_after_same_uid_replacement() -> Result<()> {
// The reviewer's scenario: after validation, a same-uid process swaps
// the accepted appdir for a different directory (or symlink). Because
// the descriptor is retained and mutations are addressed through it,
// writes still land in the originally validated directory.
let tmp = trusted_tempdir()?;
let base = tmp.path().canonicalize()?;
let appdir = base.join("Applications");View on GitHub (pinned to afd2eddd3a)
Solutions
- Run the test and inspect why ensure_trusted_appdir succeeded on a symlinked tail
- Open the final path component with openat using O_NOFOLLOW|O_DIRECTORY on the parent fd so symlinked tails fail with 'cannot open operation directory'
- Verify the error is the tail/no-follow failure, not the 'untrusted directory' ancestor failure (the test asserts both the presence and absence of messages)
- Add regression coverage for symlinked intermediate components as well as the tail
Example fix
// before
let dir = File::open(appdir)?; // follows symlink
// after
let parent = File::open(appdir.parent().unwrap())?;
let dir = openat(&parent, appdir.file_name().unwrap(),
OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY))?;
// symlink tail => ELOOP => "cannot open operation directory" error Defensive patterns
Strategy: validation
Validate before calling
// reject a symlinked tail before calling ensure_trusted_appdir
let md = std::fs::symlink_metadata(&appdir)?;
if md.file_type().is_symlink() {
panic!("appdir tail must not be a symlink");
} Type guard
fn tail_is_real_dir(p: &Path) -> bool {
std::fs::symlink_metadata(p).map(|m| m.is_dir()).unwrap_or(false)
// false when the final component is a symlink
} Try / catch
match ensure_trusted_appdir(&appdir) {
Ok(()) => { /* proceed */ }
Err(e) if e.to_string().contains("cannot open operation directory") => {
eprintln!("appdir tail is a symlink or unopenable: {e}");
}
Err(e) => return Err(e),
} Prevention
- Use openat with O_NOFOLLOW|O_DIRECTORY on the final component instead of path-based open
- Distinguish tail-symlink failures from ancestor-trust failures in error messages so tests and users can tell guards apart
- Test both symlinked tails and symlinked ancestors separately
When it happens
Trigger: Calling ensure_trusted_appdir(appdir) where appdir itself is a symlink to a directory the caller controls; the implementation follows the symlink instead of failing with a no-follow open, so the guard is bypassed.
Common situations: An attacker replaces ~/Applications with a symlink to a world-controlled directory so cask staging writes land outside the trusted location; regression in openat(O_NOFOLLOW|O_DIRECTORY) usage on the tail component.
Understand the failure class
Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.
Related errors
- expected world-writable ancestor to be refused
- expected pre-planted symlink destination to be refused
- brew-cask: staged symlink path escaped extraction root: {}
- brew-cask: refusing generic artifact source outside the extr
- brew-cask: staged symlink path escaped extraction root: {}
AI-assisted analysis of jdx/mise@afd2eddd3a (2026-09-09).
Data as JSON: /api/errors/f4ed7b062be3a834.
Report an issue: GitHub.