jdx/mise · error

remote cache symlink target escapes its output root

Error message

remote cache symlink target escapes its output root

What it means

validate_cache_symlink_target walks the resolved target components keeping a running depth: +1 for Normal, -1 for ParentDir. If depth ever goes negative the target climbs above the output root and mise bails with 'remote cache symlink target escapes its output root' - the classic nested-dot-dot traversal.

Source

Thrown at src/task/task_cache_store.rs:555

    }
    Ok(())
}

fn validate_cache_symlink_target(path: &Path, target: &Path) -> Result<()> {
    if target.is_absolute() {
        bail!("remote cache symlink target must be relative");
    }
    let resolved = path.parent().unwrap_or(Path::new("")).join(target);
    let mut depth = 0_i64;
    for component in resolved.components() {
        match component {
            Component::Normal(_) => depth += 1,
            Component::ParentDir => depth -= 1,
            Component::CurDir => {}
            _ => bail!("remote cache symlink target is unsafe"),
        }
        if depth < 0 {
            bail!("remote cache symlink target escapes its output root");
        }
    }
    Ok(())
}

fn archive_to_cas(path: &Path, staging_dir: &Path) -> Result<(CacheDigest, Vec<BlobUpload>)> {
    file::create_dir_all(staging_dir)?;
    let decoder = zstd::Decoder::new(File::open(path)?)?;
    let mut archive = Archive::new(decoder);
    let mut nodes = BTreeMap::<PathBuf, ArchiveNode>::new();
    nodes.insert(PathBuf::new(), ArchiveNode::Directory { mode: 0o755 });

    for entry in archive.entries()? {
        let mut entry = entry?;
        let entry_path = entry.path()?.into_owned();
        validate_cache_path(&entry_path)?;
        let mode = entry.header().mode() & 0o7777;
        let entry_type = entry.entry_type();

View on GitHub (pinned to 9dcfcaa0dc)

Solutions

  1. Point the symlink at a path inside the output tree, relative to the link's location
  2. If the link must reach outside the output, exclude that output from the cache roots
  3. Purge the cached entry and let the task rebuild its outputs

Example fix

# before: link at out/a/b/link climbs above the output root
ln -s ../../../vendor/lib out/a/b/link
# after: target stays under the output root
ln -s ../../vendor/lib out/a/b/link
Defensive patterns

Strategy: validation

Validate before calling

use std::path::{Component, Path};

fn symlink_target_escapes(link: &Path, target: &Path) -> bool {
    if target.is_absolute() {
        return true;
    }
    let resolved = link.parent().unwrap_or(Path::new("")).join(target);
    let mut depth = 0i64;
    for c in resolved.components() {
        match c {
            Component::Normal(_) => depth += 1,
            Component::ParentDir => {
                depth -= 1;
                if depth < 0 {
                    return true;
                }
            }
            Component::CurDir => {}
            _ => return true,
        }
    }
    false
}

assert!(!symlink_target_escapes(&link, &std::fs::read_link(&link)?));

Type guard

fn symlink_target_escapes(link: &std::path::Path, target: &std::path::Path) -> bool {
    if target.is_absolute() {
        return true;
    }
    let resolved = link.parent().unwrap_or(std::path::Path::new("")).join(target);
    let mut depth = 0i64;
    for c in resolved.components() {
        match c {
            std::path::Component::Normal(_) => depth += 1,
            std::path::Component::ParentDir => {
                depth -= 1;
                if depth < 0 {
                    return true;
                }
            }
            std::path::Component::CurDir => {}
            _ => return true,
        }
    }
    false
}

Try / catch

match cache_commit(&task).await {
    Ok(()) => (),
    Err(err) if err.to_string().contains("symlink target escapes its output root") => {
        purge_cache_entry(&task).await?;
        exclude_output_root_and_rebuild(&task).await? // the link must leave the tree
    }
    Err(err) => return Err(err),
}

Prevention

When it happens

Trigger: A symlink deeper in the cached tree whose target has more ".." components than the link has ancestors, e.g. a link at "a/b/link" with target "../../../outside", encountered during archive_to_cas upload or materialize_remote_tree restore.

Common situations: Symlinks pointing at the repository root or a sibling directory from a nested output; links generated from absolute paths by tools that relativize incorrectly; crafted malicious cache blobs.

Related errors


AI-assisted analysis of jdx/mise@9dcfcaa0dc (2026-08-17). Data as JSON: /api/errors/566a47803ff462ef. Report an issue: GitHub.