{"record":{"id":"d19801dff1899b4c","repo":"jdx/mise","slug":"brew-cask-temporary-artifact-directory-was-replac","errorCode":null,"errorMessage":"brew-cask: temporary artifact directory was replaced","messagePattern":"brew-cask: temporary artifact directory was replaced","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/system/packages/brew/cask.rs","lineNumber":2173,"sourceCode":"        nix::unistd::unlinkat(parent, name, nix::unistd::UnlinkatFlags::NoRemoveDir)?;\n    }\n    Ok(())\n}\n\n#[cfg(unix)]\nfn remove_private_staging_dir(\n    parent: &TrustedOperationParent,\n    staging: &TrustedOperationParent,\n    staging_name: &std::ffi::OsStr,\n) -> Result<()> {\n    let bound = nix::sys::stat::fstat(&staging.fd)?;\n    let linked = nix::sys::stat::fstatat(\n        &parent.fd,\n        staging_name,\n        nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,\n    )?;\n    if bound.st_dev != linked.st_dev || bound.st_ino != linked.st_ino {\n        bail!(\"brew-cask: temporary artifact directory was replaced\");\n    }\n    nix::unistd::unlinkat(\n        &parent.fd,\n        staging_name,\n        nix::unistd::UnlinkatFlags::RemoveDir,\n    )?;\n    Ok(())\n}\n\nfn validate_generic_copy_target(target: &Path) -> Result<()> {\n    let prefix = prefix::prefix();\n    if !target.starts_with(&prefix)\n        || target.strip_prefix(&prefix)?.components().next().is_none()\n        || !path_starts_with_resolved_root(target, &prefix)\n    {\n        bail!(\n            \"brew-cask: refusing generic artifact copy outside Homebrew prefix: {}\",\n            target.display()","sourceCodeStart":2155,"sourceCodeEnd":2191,"githubUrl":"https://github.com/jdx/mise/blob/6f52dcdf99e282ef7a7db68c81301fa4618d0f79/src/system/packages/brew/cask.rs#L2155-L2191","documentation":"remove_private_staging_dir deletes the private staging directory used for an elevated/generic artifact operation. Before unlinkat it fstat()s the directory fd it holds and compares st_dev/st_ino against the inode currently linked under the staging name in its parent (fstatat with AT_SYMLINK_NOFOLLOW). A mismatch means the name now points at a different directory than the one mise created and verified, so mise refuses to unlink it — a TOCTOU defense against a swapped directory.","triggerScenarios":"Another process unlinks and recreates the staging directory name between its mkdirat/openat and the removal: a concurrent mise or Homebrew run on the same caskroom, a cleanup script, or (deliberately) a same-uid attacker racing the operation.","commonSituations":"Two parallel mise/brew install or uninstall jobs touching the same cask token; CI pipelines racing on a shared caskroom; aggressive tmp/caskroom cleaners running mid-install.","solutions":["Re-run the mise command once no other mise/brew process is touching the same caskroom","Serialize installs of the same cask (per-token lock) in automation instead of running them in parallel","After aborted concurrent jobs, remove leftover staging directories under the caskroom/state tree and retry","If it recurs with zero concurrency, inspect the parent dir for a name-swapping process and report it as a security issue"],"exampleFix":"# before: two racing jobs\n$ mise brew install --cask foo & mise brew install --cask foo &\n\n# after: serialized\n$ mise brew install --cask foo && mise brew install --cask foo","handlingStrategy":"retry","validationCode":"// Serialize operations per cask token before installing.\nlet lock = std::fs::File::create(format!(\"/tmp/mise-cask-{}.lock\", token))?;\nuse std::os::unix::fs::PermissionsExt;\nlock.set_permissions(std::fs::Permissions::from_mode(0o644))?;\nflock_exclusive(&lock)?; // hold until the operation finishes","typeGuard":null,"tryCatchPattern":"match install_cask(&cask) {\n    Err(e) if e.to_string().contains(\"temporary artifact directory was replaced\") => {\n        // transient race: wait, verify no concurrent run, retry once\n        std::thread::sleep(std::time::Duration::from_secs(2));\n        install_cask(&cask)\n    }\n    other => other,\n}","preventionTips":["Never run two mise/brew operations against the same caskroom concurrently","Hold a per-token lock in automation harnesses","Clean leftover staging directories after aborted jobs before retrying"],"tags":["brew-cask","toctou","race-condition","staging","homebrew"],"backgroundTag":"directory-replaced-race","analyzedSha":"6f52dcdf99e282ef7a7db68c81301fa4618d0f79","analyzedAt":"2026-08-22T10:14:23.840Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}