jdx/mise · error

brew-cask: temporary artifact directory was replaced

Error message

brew-cask: temporary artifact directory was replaced

What it means

This is an inode-identity integrity check during atomic artifact linking. The code first `fstatat`s the staging directory when it binds it (`bound`), and just before removing it re-`fstatat`s the same name with `AT_SYMLINK_NOFOLLOW`; if `st_dev`/`st_ino` no longer match, the original staging directory was unlinked and replaced by something else at the same path, so the operation bails instead of operating on the imposter. This prevents an attacker from swapping the staging directory between bind and unlink.

Source

Thrown at src/system/packages/brew/cask/mod.rs:1895

        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 afd2eddd3a)

Solutions

  1. Re-run the operation once, ensuring no other brew/cask process touches the Homebrew prefix concurrently.
  2. Disable or check any background cleanup (temp sweeper, antivirus, periodic scripts) acting on the staging area.
  3. If it reproduces, verify ownership/permissions of the staging parent directory — a replaced directory implies someone else can write there; fix permissions before retrying.
  4. Treat repeated occurrences as a security signal: audit the prefix for untrusted write access.
Defensive patterns

Strategy: retry

Validate before calling

use nix::sys::stat::fstatat;
fn staging_dir_stable(parent: &std::fs::File, name: &std::ffi::CStr, bound: &nix::sys::stat::FileStat) -> bool {
    fstatat(parent, name, nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW)
        .map(|s| s.st_dev == bound.st_dev && s.st_ino == bound.st_ino)
        .unwrap_or(false)
}

Try / catch

match result {
    Err(e) if e.to_string().contains("temporary artifact directory was replaced") => {
        // retry once with no concurrent processes; abort if it recurs
    }
    Err(e) => return Err(e),
    Ok(v) => v,
}

Prevention

When it happens

Trigger: A brew-cask operation using the staging/unlink sequence at mod.rs:1895 where the `staging_name` entry under `parent` was removed and recreated (different inode) between the initial bind and the final `unlinkat` — e.g. by a concurrent process, a cleanup job, or an attacker exploiting a writable parent directory.

Common situations: Another brew process or tmp-watcher deleting/recreating the staging directory mid-install; running two conflicting cask operations in parallel on the same prefix; a security-hardened environment where some other tool sanitizes the temporary area during the operation.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of jdx/mise@afd2eddd3a (2026-09-09). Data as JSON: /api/errors/ff6a93abecc5e257. Report an issue: GitHub.