jdx/mise · error

expected pre-planted symlink destination to be refused

Error message

expected pre-planted symlink destination to be refused

What it means

Test panic in the cask security tests: ditto_into was given a staging destination name that was pre-planted as a symlink pointing at an attacker-controlled directory. The call must fail (mkdirat returns EEXIST, and the implementation must not follow the symlink) with a 'cannot create staging directory' error, and the attacker directory must remain untouched. If ditto_into succeeds, the guard against symlink-swap staging attacks is broken.

Source

Thrown at src/system/packages/brew/cask/tests.rs:6798

    // it, so nothing is written outside the verified directory.
    let tmp = trusted_tempdir()?;
    let base = tmp.path().canonicalize()?;
    let appdir = base.join("Applications");
    let parent = ensure_trusted_appdir(&appdir)?;

    let source = base.join("payload");
    file::create_dir_all(&source)?;
    crate::file::write(source.join("marker"), "payload")?;

    let attacker = base.join("attacker");
    file::create_dir_all(&attacker)?;
    let tmp_name = std::ffi::OsStr::new("Foo.mise-tmp-abc");
    std::os::unix::fs::symlink(&attacker, appdir.join(tmp_name))?;

    // Fails at `mkdirat` (EEXIST) before `ditto` is ever spawned, so this
    // holds on platforms without `ditto` too.
    let err = match ditto_into(&source, &parent.fd, tmp_name) {
        Ok(()) => panic!("expected pre-planted symlink destination to be refused"),
        Err(err) => err.to_string(),
    };
    assert!(err.contains("cannot create staging directory"), "{err}");
    assert!(!attacker.join("marker").exists());
    Ok(())
}

#[cfg(target_os = "macos")]
#[test]
fn repair_app_permissions_does_not_traverse_bundle_symlinks() -> Result<()> {
    // A cask bundle may contain a symlink pointing outside the application
    // directory. The recursive flag/permission repair must not follow it and
    // change the referent.
    let tmp = trusted_tempdir()?;
    let base = tmp.path().canonicalize()?;
    let appdir = base.join("Applications");
    let parent = ensure_trusted_appdir(&appdir)?;

View on GitHub (pinned to afd2eddd3a)

Solutions

  1. Run the test and confirm whether ditto_into returns Ok or touches attacker/marker
  2. Use mkdirat on the parent fd and treat EEXIST as a hard failure wrapped as 'cannot create staging directory'
  3. Never follow or re-create over an existing destination entry; remove any pre-existing staging name before staging, or fail closed
  4. Confirm ditto (if spawned) receives the fd-relative name, not a followable absolute path

Example fix

// before
std::fs::create_dir_all(parent.join(tmp_name))?; // follows pre-planted symlink
// after
let rc = unsafe { libc::mkdirat(parent_fd.as_raw_fd(), ctmp_name.as_ptr(), 0o700) };
if rc != 0 {
    return Err(io::Error::last_os_error()).context("cannot create staging directory");
} // EEXIST on pre-planted symlink fails closed; attacker dir untouched
Defensive patterns

Strategy: try-catch

Validate before calling

// refuse to stage into an existing destination name (pre-planted symlink)
let tmp = appdir.join("Foo.mise-tmp-abc");
if std::fs::symlink_metadata(&tmp).is_ok() {
    panic!("staging name already exists; refusing (possible symlink attack)");
}

Type guard

fn destination_is_free(parent_fd: &File, name: &OsStr) -> bool {
    // fstatat with AT_SYMLINK_NOFOLLOW: true only when the entry does not exist
    fstatat_nofollow(parent_fd, name).map(|r| r.is_err()).unwrap_or(false)
}

Try / catch

match ditto_into(&source, &parent_fd, tmp_name) {
    Ok(()) => { /* staging succeeded */ }
    Err(e) if e.to_string().contains("cannot create staging directory") => {
        eprintln!("staging destination pre-exists (possible symlink attack): {e}");
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: Calling ditto_into(&source, &parent_fd, tmp_name) where parent/tmp_name already exists as a symlink; the implementation creates or follows the destination instead of refusing at mkdirat EEXIST, so it returns Ok(()) and the attacker's marker target may be written.

Common situations: TOCTOU/symlink-swap attacks where a local attacker pre-creates Foo.mise-tmp-abc as a symlink in the trusted appdir; regression when switching from path-based mkdir to fd-based mkdirat or losing the EEXIST check.

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/c4177c2b47e03e3a. Report an issue: GitHub.