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

  1. Run the test and inspect why ensure_trusted_appdir succeeded on a symlinked tail
  2. 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'
  3. 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)
  4. 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

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


AI-assisted analysis of jdx/mise@afd2eddd3a (2026-09-09). Data as JSON: /api/errors/f4ed7b062be3a834. Report an issue: GitHub.