{"record":{"id":"84cd949d47f5354e","repo":"jdx/mise","slug":"remote-cache-symlink-target-is-unsafe","errorCode":null,"errorMessage":"remote cache symlink target is unsafe","messagePattern":"remote cache symlink target is unsafe","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/task/task_cache_store.rs","lineNumber":552,"sourceCode":"        || !matches!(path.components().next(), Some(Component::Normal(_)))\n    {\n        bail!(\"invalid remote cache path component\");\n    }\n    Ok(())\n}\n\nfn validate_cache_symlink_target(path: &Path, target: &Path) -> Result<()> {\n    if target.is_absolute() {\n        bail!(\"remote cache symlink target must be relative\");\n    }\n    let resolved = path.parent().unwrap_or(Path::new(\"\")).join(target);\n    let mut depth = 0_i64;\n    for component in resolved.components() {\n        match component {\n            Component::Normal(_) => depth += 1,\n            Component::ParentDir => depth -= 1,\n            Component::CurDir => {}\n            _ => bail!(\"remote cache symlink target is unsafe\"),\n        }\n        if depth < 0 {\n            bail!(\"remote cache symlink target escapes its output root\");\n        }\n    }\n    Ok(())\n}\n\nfn archive_to_cas(path: &Path, staging_dir: &Path) -> Result<(CacheDigest, Vec<BlobUpload>)> {\n    file::create_dir_all(staging_dir)?;\n    let decoder = zstd::Decoder::new(File::open(path)?)?;\n    let mut archive = Archive::new(decoder);\n    let mut nodes = BTreeMap::<PathBuf, ArchiveNode>::new();\n    nodes.insert(PathBuf::new(), ArchiveNode::Directory { mode: 0o755 });\n\n    for entry in archive.entries()? {\n        let mut entry = entry?;\n        let entry_path = entry.path()?.into_owned();","sourceCodeStart":534,"sourceCodeEnd":570,"githubUrl":"https://github.com/jdx/mise/blob/533346cc374382b41ec5ff70536252b2e96e725c/src/task/task_cache_store.rs#L534-L570","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: target stored with embedded root-like component\nlink target: \"//../shared/lib\"  // contains RootDir\n// after\nlink target: \"../../shared/lib\"","handlingStrategy":"validation","validationCode":"use std::path::Component;\nfn link_resolves_cleanly(parent: &std::path::Path, target: &std::path::Path) -> bool {\n    let mut depth: i64 = 0;\n    for c in parent.join(target).components() {\n        match c {\n            Component::Normal(_) => depth += 1,\n            Component::ParentDir => depth -= 1,\n            Component::CurDir => {}\n            _ => return false,\n        }\n        if depth < 0 { return false; }\n    }\n    true\n}","typeGuard":"fn is_plain_relative(target: &std::path::Path) -> bool {\n    use std::path::Component::*;\n    target.components().all(|c| matches!(c, Normal(_) | ParentDir | CurDir))\n}","tryCatchPattern":"if !is_plain_relative(&target) {\n    // rewrite or reject before it reaches validate_cache_symlink_target\n}","preventionTips":["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."],"tags":["cache","symlink","security","validation"],"backgroundTag":"path-traversal-blocked","analyzedSha":"533346cc374382b41ec5ff70536252b2e96e725c","analyzedAt":"2026-09-17T13:35:38.149Z","contentChangedAt":"2026-09-17T13:35:38.149Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}