jdx/mise · error

remote cache symlink target escapes its output root

Error message

remote cache symlink target escapes its output root

What it means

After the depth walk, if the resolved symlink path goes above the output root (depth < 0, i.e. more '..' than normal components from the link's parent), the store rejects it. Such a symlink would escape the materialized artifact directory, so the entry is treated as unsafe.

Solutions

  1. Adjust the task to keep symlinks inside the output root (use relative targets that stay within the tree) and republish.
  2. Copy the linked resource into the output tree instead of symlinking outside it.
  3. Purge the offending remote cache entry and force a local rebuild.
  4. Audit the remote cache source/credentials if the entry was not produced by your own tasks.

Example fix

// before (inside out/)
ln -s ../shared/config.json out/config.json   // escapes output root
// after
cp ../shared/config.json out/config.json
Defensive patterns

Strategy: validation

Validate before calling

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

Type guard

fn resolves_inside_root(parent: &std::path::Path, target: &std::path::Path) -> bool {
    parent.join(target).components()
        .scan(0i64, |d, c| {
            match c {
                std::path::Component::Normal(_) => *d += 1,
                std::path::Component::ParentDir => *d -= 1,
                _ => {}
            }
            Some(*d)
        }).all(|d| d >= 0)
}

Try / catch

match validate_cache_symlink_target(&link, &target) {
    Err(e) if e.to_string().contains("escapes its output root") => {
        // purge entry and rebuild locally
    }
    other => other?,
}

Prevention

When it happens

Trigger: archive_to_cas() or materialize_remote_tree() validates a symlink like target "../../../../etc/passwd" (or any target whose parent-relative resolution crosses the root) (task_cache_store.rs:555).

Common situations: Tasks that create upward-relative symlinks outside their output directory; malicious or compromised remote cache servers; archives of trees where links point to sibling build directories.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


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

Appendix: 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 533346cc37)