{"record":{"id":"442b365430b08eab","repo":"jdx/mise","slug":"output-path-traverses-symlink-ancestor","errorCode":null,"errorMessage":"output path {} traverses symlink ancestor {}","messagePattern":"output path (.+?) traverses symlink ancestor (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/task/task_cache.rs","lineNumber":1331,"sourceCode":"    roots.sort_by_key(|path| path.components().count());\n    let mut result = Vec::<PathBuf>::new();\n    for root in roots {\n        if !result.iter().any(|parent| root.starts_with(parent)) {\n            result.push(root);\n        }\n    }\n    result.sort();\n    result\n}\n\nfn ensure_no_symlink_ancestors(root: &Path, rel: &Path) -> Result<()> {\n    let mut current = root.to_path_buf();\n    let component_count = rel.components().count();\n    for component in rel.components().take(component_count.saturating_sub(1)) {\n        current.push(component);\n        match fs::symlink_metadata(&current) {\n            Ok(metadata) if metadata.file_type().is_symlink() => {\n                bail!(\n                    \"output path {} traverses symlink ancestor {}\",\n                    rel.display(),\n                    current.display()\n                );\n            }\n            Ok(_) => {}\n            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}\n            Err(err) => return Err(err.into()),\n        }\n    }\n    Ok(())\n}\n\nfn install_transactionally(\n    root: &Path,\n    staging: &Path,\n    install_roots: &[PathBuf],\n    remove_roots: &[PathBuf],","sourceCodeStart":1313,"sourceCodeEnd":1349,"githubUrl":"https://github.com/jdx/mise/blob/9dcfcaa0dc8747a2577d3270b69bb9d8313b2807/src/task/task_cache.rs#L1313-L1349","documentation":"Before packing outputs into the cache archive, mise walks each output path's ancestor directories; if any ancestor is a symbolic link, it refuses the path. Symlinked ancestors make archive paths ambiguous and could redirect restores outside the intended tree, so this is a hard security stop, not a warning.","triggerScenarios":"ensure_no_symlink_ancestors walks the parent components of a relative output and fs::symlink_metadata reports one of them as a symlink — e.g. outputs = ['dist/bin/tool'] where dist or dist/bin is a symlink ('current' -> 'v1.2.3' style).","commonSituations":"Build dirs managed as symlinks (nix-style /current, shared caches); CI creating a versioned dir plus a 'latest' link; monorepos linking package dirs.","solutions":["Point outputs at the real (non-symlink) target, e.g. the versioned path instead of the 'current' link","Replace the symlinked directory with a real directory before the task produces outputs","Create output directories with mkdir -p rather than ln -s anywhere in the ancestor chain"],"exampleFix":"# before: 'current' is a symlink to 'v1.2.3'\noutputs = ['current/dist/app']\n\n# after: reference the real directory\noutputs = ['v1.2.3/dist/app']","handlingStrategy":"validation","validationCode":"# detect symlinked ancestors of an output path\npython3 - <<'PY'\nimport os, sys\np = sys.argv[1].rstrip(os.sep)\nwhile p not in ('', os.sep, '.'):\n    if os.path.islink(p):\n        print('symlink ancestor:', p)\n    p = os.path.dirname(p)\nPY dist/bin/tool","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Create output directories with mkdir -p, never ln -s in the ancestor chain","Audit dev-shell/nix environments that replace build dirs with symlinks","Point outputs at versioned real paths, not 'current'/'latest' links"],"tags":["mise","task-cache","symlink","security"],"backgroundTag":"symlink-traversal","analyzedSha":"9dcfcaa0dc8747a2577d3270b69bb9d8313b2807","analyzedAt":"2026-08-17T14:28:50.624Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}