{"record":{"id":"c4177c2b47e03e3a","repo":"jdx/mise","slug":"expected-pre-planted-symlink-destination-to-be-ref","errorCode":null,"errorMessage":"expected pre-planted symlink destination to be refused","messagePattern":"expected pre-planted symlink destination to be refused","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/system/packages/brew/cask/tests.rs","lineNumber":6798,"sourceCode":"    // it, so nothing is written outside the verified directory.\n    let tmp = trusted_tempdir()?;\n    let base = tmp.path().canonicalize()?;\n    let appdir = base.join(\"Applications\");\n    let parent = ensure_trusted_appdir(&appdir)?;\n\n    let source = base.join(\"payload\");\n    file::create_dir_all(&source)?;\n    crate::file::write(source.join(\"marker\"), \"payload\")?;\n\n    let attacker = base.join(\"attacker\");\n    file::create_dir_all(&attacker)?;\n    let tmp_name = std::ffi::OsStr::new(\"Foo.mise-tmp-abc\");\n    std::os::unix::fs::symlink(&attacker, appdir.join(tmp_name))?;\n\n    // Fails at `mkdirat` (EEXIST) before `ditto` is ever spawned, so this\n    // holds on platforms without `ditto` too.\n    let err = match ditto_into(&source, &parent.fd, tmp_name) {\n        Ok(()) => panic!(\"expected pre-planted symlink destination to be refused\"),\n        Err(err) => err.to_string(),\n    };\n    assert!(err.contains(\"cannot create staging directory\"), \"{err}\");\n    assert!(!attacker.join(\"marker\").exists());\n    Ok(())\n}\n\n#[cfg(target_os = \"macos\")]\n#[test]\nfn repair_app_permissions_does_not_traverse_bundle_symlinks() -> Result<()> {\n    // A cask bundle may contain a symlink pointing outside the application\n    // directory. The recursive flag/permission repair must not follow it and\n    // change the referent.\n    let tmp = trusted_tempdir()?;\n    let base = tmp.path().canonicalize()?;\n    let appdir = base.join(\"Applications\");\n    let parent = ensure_trusted_appdir(&appdir)?;\n","sourceCodeStart":6780,"sourceCodeEnd":6816,"githubUrl":"https://github.com/jdx/mise/blob/afd2eddd3a50c16190efc1c7e94404b48f72af57/src/system/packages/brew/cask/tests.rs#L6780-L6816","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nstd::fs::create_dir_all(parent.join(tmp_name))?; // follows pre-planted symlink\n// after\nlet rc = unsafe { libc::mkdirat(parent_fd.as_raw_fd(), ctmp_name.as_ptr(), 0o700) };\nif rc != 0 {\n    return Err(io::Error::last_os_error()).context(\"cannot create staging directory\");\n} // EEXIST on pre-planted symlink fails closed; attacker dir untouched","handlingStrategy":"try-catch","validationCode":"// refuse to stage into an existing destination name (pre-planted symlink)\nlet tmp = appdir.join(\"Foo.mise-tmp-abc\");\nif std::fs::symlink_metadata(&tmp).is_ok() {\n    panic!(\"staging name already exists; refusing (possible symlink attack)\");\n}","typeGuard":"fn destination_is_free(parent_fd: &File, name: &OsStr) -> bool {\n    // fstatat with AT_SYMLINK_NOFOLLOW: true only when the entry does not exist\n    fstatat_nofollow(parent_fd, name).map(|r| r.is_err()).unwrap_or(false)\n}","tryCatchPattern":"match ditto_into(&source, &parent_fd, tmp_name) {\n    Ok(()) => { /* staging succeeded */ }\n    Err(e) if e.to_string().contains(\"cannot create staging directory\") => {\n        eprintln!(\"staging destination pre-exists (possible symlink attack): {e}\");\n    }\n    Err(e) => return Err(e),\n}","preventionTips":["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"],"tags":["rust","security","symlink","race-condition","test-assertion"],"backgroundTag":"path-traversal-blocked","analyzedSha":"afd2eddd3a50c16190efc1c7e94404b48f72af57","analyzedAt":"2026-09-09T01:38:25.179Z","contentChangedAt":"2026-09-09T01:38:25.179Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}