jdx/mise · error

brew-cask: temporary artifact directory was replaced

Error message

brew-cask: temporary artifact directory was replaced

What it means

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.

Solutions

  1. Re-run the mise command once no other mise/brew process is touching the same caskroom
  2. Serialize installs of the same cask (per-token lock) in automation instead of running them in parallel
  3. After aborted concurrent jobs, remove leftover staging directories under the caskroom/state tree and retry
  4. If it recurs with zero concurrency, inspect the parent dir for a name-swapping process and report it as a security issue

Example fix

# before: two racing jobs
$ mise brew install --cask foo & mise brew install --cask foo &

# after: serialized
$ mise brew install --cask foo && mise brew install --cask foo
Defensive patterns

Strategy: retry

Validate before calling

// Serialize operations per cask token before installing.
let lock = std::fs::File::create(format!("/tmp/mise-cask-{}.lock", token))?;
use std::os::unix::fs::PermissionsExt;
lock.set_permissions(std::fs::Permissions::from_mode(0o644))?;
flock_exclusive(&lock)?; // hold until the operation finishes

Try / catch

match install_cask(&cask) {
    Err(e) if e.to_string().contains("temporary artifact directory was replaced") => {
        // transient race: wait, verify no concurrent run, retry once
        std::thread::sleep(std::time::Duration::from_secs(2));
        install_cask(&cask)
    }
    other => other,
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of jdx/mise@6f52dcdf99 (2026-08-22). Data as JSON: /api/errors/d19801dff1899b4c. Report an issue: GitHub.

Appendix: source

Thrown at src/system/packages/brew/cask.rs:2173

        nix::unistd::unlinkat(parent, name, nix::unistd::UnlinkatFlags::NoRemoveDir)?;
    }
    Ok(())
}

#[cfg(unix)]
fn remove_private_staging_dir(
    parent: &TrustedOperationParent,
    staging: &TrustedOperationParent,
    staging_name: &std::ffi::OsStr,
) -> Result<()> {
    let bound = nix::sys::stat::fstat(&staging.fd)?;
    let linked = nix::sys::stat::fstatat(
        &parent.fd,
        staging_name,
        nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
    )?;
    if bound.st_dev != linked.st_dev || bound.st_ino != linked.st_ino {
        bail!("brew-cask: temporary artifact directory was replaced");
    }
    nix::unistd::unlinkat(
        &parent.fd,
        staging_name,
        nix::unistd::UnlinkatFlags::RemoveDir,
    )?;
    Ok(())
}

fn validate_generic_copy_target(target: &Path) -> Result<()> {
    let prefix = prefix::prefix();
    if !target.starts_with(&prefix)
        || target.strip_prefix(&prefix)?.components().next().is_none()
        || !path_starts_with_resolved_root(target, &prefix)
    {
        bail!(
            "brew-cask: refusing generic artifact copy outside Homebrew prefix: {}",
            target.display()

View on GitHub (pinned to 6f52dcdf99)