{"record":{"id":"566a47803ff462ef","repo":"jdx/mise","slug":"remote-cache-symlink-target-escapes-its-output-roo","errorCode":null,"errorMessage":"remote cache symlink target escapes its output root","messagePattern":"remote cache symlink target escapes its output root","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/task/task_cache_store.rs","lineNumber":555,"sourceCode":"    }\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();\n        validate_cache_path(&entry_path)?;\n        let mode = entry.header().mode() & 0o7777;\n        let entry_type = entry.entry_type();","sourceCodeStart":537,"sourceCodeEnd":573,"githubUrl":"https://github.com/jdx/mise/blob/533346cc374382b41ec5ff70536252b2e96e725c/src/task/task_cache_store.rs#L537-L573","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","solutions":["Adjust the task to keep symlinks inside the output root (use relative targets that stay within the tree) and republish.","Copy the linked resource into the output tree instead of symlinking outside it.","Purge the offending remote cache entry and force a local rebuild.","Audit the remote cache source/credentials if the entry was not produced by your own tasks."],"exampleFix":"// before (inside out/)\nln -s ../shared/config.json out/config.json   // escapes output root\n// after\ncp ../shared/config.json out/config.json","handlingStrategy":"validation","validationCode":"fn link_stays_in_root(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            std::path::Component::Normal(_) => depth += 1,\n            std::path::Component::ParentDir => depth -= 1,\n            _ => {}\n        }\n        if depth < 0 { return false; }\n    }\n    true\n}","typeGuard":"fn resolves_inside_root(parent: &std::path::Path, target: &std::path::Path) -> bool {\n    parent.join(target).components()\n        .scan(0i64, |d, c| {\n            match c {\n                std::path::Component::Normal(_) => *d += 1,\n                std::path::Component::ParentDir => *d -= 1,\n                _ => {}\n            }\n            Some(*d)\n        }).all(|d| d >= 0)\n}","tryCatchPattern":"match validate_cache_symlink_target(&link, &target) {\n    Err(e) if e.to_string().contains(\"escapes its output root\") => {\n        // purge entry and rebuild locally\n    }\n    other => other?,\n}","preventionTips":["Keep all symlink targets within the task's output directory.","Copy out-of-tree dependencies into the output instead of linking upward.","Only trust cache entries from authenticated, controlled servers.","Scan packaged trees for upward links before publishing."],"tags":["cache","symlink","path-traversal","security"],"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"}