{"record":{"id":"ab0e722bef4f9123","repo":"astrid-runtime/astrid","slug":"capsule-source-symlink-resolves-outside-source","errorCode":null,"errorMessage":"capsule source symlink {} resolves outside source root","messagePattern":"capsule source symlink (.+?) resolves outside source root","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/astrid-capsule-install/src/storage.rs","lineNumber":789,"sourceCode":"    root: &Path,\n    current: &Path,\n    entries: &mut Vec<(PathBuf, Metadata)>,\n) -> anyhow::Result<()> {\n    let mut children = read_dir_sorted(current)?;\n    for (name, metadata) in children.drain(..) {\n        let relative = name\n            .strip_prefix(root)\n            .map_err(|_| anyhow::anyhow!(\"capsule path escaped source root\"))?\n            .to_path_buf();\n        let file_type = metadata.file_type();\n        if file_type.is_symlink() {\n            let resolved = fs::canonicalize(&name).with_context(|| {\n                format!(\"canonicalize capsule source symlink {}\", relative.display())\n            })?;\n            let canonical_root = fs::canonicalize(root)\n                .with_context(|| format!(\"canonicalize capsule source root {}\", root.display()))?;\n            if !resolved.starts_with(&canonical_root) {\n                bail!(\n                    \"capsule source symlink {} resolves outside source root\",\n                    relative.display()\n                );\n            }\n            let resolved_metadata = fs::metadata(&resolved).with_context(|| {\n                format!(\"stat capsule source symlink target {}\", relative.display())\n            })?;\n            if !resolved_metadata.is_file() {\n                bail!(\n                    \"capsule source symlink {} does not resolve to a regular file\",\n                    relative.display()\n                );\n            }\n            // File links are materialized as regular archive entries. This\n            // preserves npm's node_modules/.bin links without ever storing a\n            // redirect in the durable package.\n            entries.push((relative, resolved_metadata));\n            continue;","sourceCodeStart":771,"sourceCodeEnd":807,"githubUrl":"https://github.com/astrid-runtime/astrid/blob/affd8760f44190dbdfbec23403f4c4b642c33112/crates/astrid-capsule-install/src/storage.rs#L771-L807","documentation":"Raised by collect_entries during canonical_capsule_archive when a symlink inside the capsule source canonicalizes to a path outside the source root. The library permits file symlinks (to keep npm-style node_modules/.bin working) only if their targets stay within the capsule, preventing archive content that escapes the source tree.","triggerScenarios":"Archiving a capsule whose directory contains a symlink pointing to a file outside the capsule directory (e.g. ../shared/lib.wit or /usr/share/foo); fs::canonicalize resolves it and the starts_with(canonical_root) check fails.","commonSituations":"Sharing a common asset via ../ links during development; node_modules links hoisted above the package root in monorepos; linking to a globally installed tool or config file.","solutions":["Move the symlink target inside the capsule source directory and update the link to a relative in-tree path.","Replace the symlink with a real copy of the target file inside the capsule.","If the content belongs to another package, publish it separately and depend on it rather than linking."],"exampleFix":"// before: escapes the source root\nvendor/lib.wit -> ../../shared/lib.wit\n\n// after: keep the target inside the capsule\nvendor/lib.wit (regular file copy of shared/lib.wit)","handlingStrategy":"validation","validationCode":"fn symlink_escapes_root(link: &Path, root: &Path) -> std::io::Result<bool> {\n    let target = std::fs::canonicalize(link)?;\n    let root = std::fs::canonicalize(root)?;\n    Ok(!target.starts_with(root))\n}","typeGuard":"fn is_in_tree_link(link: &Path, root: &Path) -> bool {\n    std::fs::canonicalize(link).ok()\n        .zip(std::fs::canonicalize(root).ok())\n        .map_or(false, |(t, r)| t.starts_with(r))\n}","tryCatchPattern":"match publish(...) {\n    Err(e) if e.to_string().contains(\"resolves outside source root\") => { eprintln!(\"copy linked assets into the capsule\"); exit(1); }\n    other => other?,\n}","preventionTips":["Use relative links whose targets stay inside the capsule","Copy shared assets into the tree instead of linking ../","Audit with `find . -type l -exec readlink {} \\;` before publishing"],"tags":["symlink","path-traversal","capsule"],"backgroundTag":"path-traversal-blocked","analyzedSha":"affd8760f44190dbdfbec23403f4c4b642c33112","analyzedAt":"2026-09-09T21:28:12.402Z","contentChangedAt":"2026-09-09T21:28:12.402Z","schemaVersion":2},"datasetVersion":"2026-09-14T11:17:12.474Z"}