jdx/mise · error

remote cache symlink target is unsafe

Error message

remote cache symlink target is unsafe

What it means

When validating a relative symlink target, the resolver walks components of (link's parent + target): Normal increases depth, '..' decreases it, and any other component (RootDir, Prefix, etc.) is rejected as unsafe. This catches targets that, once joined under the link location, reference outside the output root or use disallowed path forms.

Solutions

  1. Rewrite the symlink with a plain relative target using only './', names, and '..' segments, then republish.
  2. Re-run the task to regenerate the output tree with clean symlinks.
  3. Purge the offending cache entry from the remote cache.
  4. Review the tooling that produced the symlink metadata for non-portable path components.

Example fix

// before: target stored with embedded root-like component
link target: "//../shared/lib"  // contains RootDir
// after
link target: "../../shared/lib"
Defensive patterns

Strategy: validation

Validate before calling

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

Type guard

fn is_plain_relative(target: &std::path::Path) -> bool {
    use std::path::Component::*;
    target.components().all(|c| matches!(c, Normal(_) | ParentDir | CurDir))
}

Try / catch

if !is_plain_relative(&target) {
    // rewrite or reject before it reaches validate_cache_symlink_target
}

Prevention

When it happens

Trigger: archive_to_cas() or materialize_remote_tree() validates a symlink whose joined resolution contains a RootDir/Prefix component or another non-Normal/ParentDir/CurDir component (task_cache_store.rs:552), e.g. a target containing a drive prefix or root marker after joining.

Common situations: Symlink targets written with unusual path syntax (drive letters, leading '/' fragments inside an otherwise relative path); corrupted symlink metadata from a bad uploader; hostile cache entries attempting exotic traversal.

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/84cd949d47f5354e. Report an issue: GitHub.

Appendix: source

Thrown at src/task/task_cache_store.rs:552

        || !matches!(path.components().next(), Some(Component::Normal(_)))
    {
        bail!("invalid remote cache path component");
    }
    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();

View on GitHub (pinned to 533346cc37)