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
- Run the test and confirm whether ditto_into returns Ok or touches attacker/marker
- Use mkdirat on the parent fd and treat EEXIST as a hard failure wrapped as 'cannot create staging directory'
- Never follow or re-create over an existing destination entry; remove any pre-existing staging name before staging, or fail closed
- 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
- Create staging dirs with mkdirat on a directory fd and treat EEXIST as fatal
- Use randomized staging suffixes plus existence checks to shrink the TOCTOU window
- After staging, verify the destination is still a real directory (fstat, not path stats)
- Never pass followable absolute paths to ditto/copy helpers; use fd-relative names
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
- expected world-writable ancestor to be refused
- expected symlinked appdir tail to be rejected
- created path component {} was replaced before it could be op
- brew-cask: refusing elevated operation because target appear
- staged blob does not match the declared CAS digest
AI-assisted analysis of jdx/mise@afd2eddd3a (2026-09-09).
Data as JSON: /api/errors/c4177c2b47e03e3a.
Report an issue: GitHub.