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
- Rewrite the symlink with a plain relative target using only './', names, and '..' segments, then republish.
- Re-run the task to regenerate the output tree with clean symlinks.
- Purge the offending cache entry from the remote cache.
- 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
- Express symlink targets only with names, '.', and '..'.
- Resolve links locally with path-clean semantics before packaging.
- Skip and report entries with unusual path syntax rather than materializing them.
- Unit-test link packaging with exotic target strings.
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
- remote cache symlink target must be relative
- remote cache path escapes its output root
- remote cache path must be relative
- remote cache symlink target escapes its output root
- a parent directory is now a symlink; left untouched
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)