jdx/mise · error

remote cache symlink target is unsafe

Error message

remote cache symlink target is unsafe

What it means

After the relative check, validate_cache_symlink_target resolves the target against the link's parent directory and walks its components. Only Normal, ParentDir, and CurDir components are allowed; anything else - in practice a Windows drive Prefix such as "C:libs" or a stray RootDir - bails with 'remote cache symlink target is unsafe'.

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 9dcfcaa0dc)

Solutions

  1. Rewrite the symlink target as a plain relative path without a drive prefix
  2. Avoid caching outputs that contain drive-relative links
  3. Purge the cached entry and let the task rebuild the outputs cleanly

Example fix

# before: target carries a drive prefix (C:libs)
link -> C:libs\tool
# after: plain relative target
link -> libs/tool
Defensive patterns

Strategy: validation

Validate before calling

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

fn symlink_target_is_safe(target: &Path) -> bool {
    !target.is_absolute()
        && target.components().all(|c| matches!(
            c,
            Component::Normal(_) | Component::ParentDir | Component::CurDir
        ))
}

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

Type guard

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

Try / catch

match restore_or_commit(&task).await {
    Ok(v) => v,
    Err(err) if err.to_string().contains("symlink target is unsafe") => {
        purge_cache_entry(&task).await?; // rebuild without the drive-prefixed link
        run_uncached(&task).await?
    }
    Err(err) => return Err(err),
}

Prevention

When it happens

Trigger: A symlink target like "C:libs\x" (a drive-relative Windows path: not absolute, so it passes the first check, but it parses with a Prefix component) stored in a cached archive or in a remote directory proto's symlinks list.

Common situations: Windows build outputs with drive-relative symlink targets; caches produced by tooling that stores Windows targets verbatim; cross-OS sharing of one remote cache between Windows and POSIX machines.

Related errors


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