{"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":"exception","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/9dcfcaa0dc8747a2577d3270b69bb9d8313b2807/src/task/task_cache_store.rs#L537-L573","documentation":"validate_cache_symlink_target walks the resolved target components keeping a running depth: +1 for Normal, -1 for ParentDir. If depth ever goes negative the target climbs above the output root and mise bails with 'remote cache symlink target escapes its output root' - the classic nested-dot-dot traversal.","triggerScenarios":"A symlink deeper in the cached tree whose target has more \"..\" components than the link has ancestors, e.g. a link at \"a/b/link\" with target \"../../../outside\", encountered during archive_to_cas upload or materialize_remote_tree restore.","commonSituations":"Symlinks pointing at the repository root or a sibling directory from a nested output; links generated from absolute paths by tools that relativize incorrectly; crafted malicious cache blobs.","solutions":["Point the symlink at a path inside the output tree, relative to the link's location","If the link must reach outside the output, exclude that output from the cache roots","Purge the cached entry and let the task rebuild its outputs"],"exampleFix":"# before: link at out/a/b/link climbs above the output root\nln -s ../../../vendor/lib out/a/b/link\n# after: target stays under the output root\nln -s ../../vendor/lib out/a/b/link","handlingStrategy":"validation","validationCode":"use std::path::{Component, Path};\n\nfn symlink_target_escapes(link: &Path, target: &Path) -> bool {\n    if target.is_absolute() {\n        return true;\n    }\n    let resolved = link.parent().unwrap_or(Path::new(\"\")).join(target);\n    let mut depth = 0i64;\n    for c in resolved.components() {\n        match c {\n            Component::Normal(_) => depth += 1,\n            Component::ParentDir => {\n                depth -= 1;\n                if depth < 0 {\n                    return true;\n                }\n            }\n            Component::CurDir => {}\n            _ => return true,\n        }\n    }\n    false\n}\n\nassert!(!symlink_target_escapes(&link, &std::fs::read_link(&link)?));","typeGuard":"fn symlink_target_escapes(link: &std::path::Path, target: &std::path::Path) -> bool {\n    if target.is_absolute() {\n        return true;\n    }\n    let resolved = link.parent().unwrap_or(std::path::Path::new(\"\")).join(target);\n    let mut depth = 0i64;\n    for c in resolved.components() {\n        match c {\n            std::path::Component::Normal(_) => depth += 1,\n            std::path::Component::ParentDir => {\n                depth -= 1;\n                if depth < 0 {\n                    return true;\n                }\n            }\n            std::path::Component::CurDir => {}\n            _ => return true,\n        }\n    }\n    false\n}","tryCatchPattern":"match cache_commit(&task).await {\n    Ok(()) => (),\n    Err(err) if err.to_string().contains(\"symlink target escapes its output root\") => {\n        purge_cache_entry(&task).await?;\n        exclude_output_root_and_rebuild(&task).await? // the link must leave the tree\n    }\n    Err(err) => return Err(err),\n}","preventionTips":["Count dot-dot segments in symlink targets against the link's depth before caching outputs","Keep cached output trees self-contained: no links reaching outside the root","Purge the cache entry after fixing symlink structure so stale blobs are not restored"],"tags":["mise","task-cache","symlink","path-traversal","remote-cache"],"backgroundTag":"symlink-path-traversal","analyzedSha":"9dcfcaa0dc8747a2577d3270b69bb9d8313b2807","analyzedAt":"2026-08-17T14:28:50.624Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}