{"record":{"id":"d7902f48dd12bb78","repo":"astrid-runtime/astrid","slug":"capsule-archive-contains-an-unsafe-path","errorCode":null,"errorMessage":"capsule archive contains an unsafe path {}","messagePattern":"capsule archive contains an unsafe path (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/astrid-capsule-install/src/source_digest.rs","lineNumber":47,"sourceCode":"            source.display()\n        );\n    }\n\n    let staging = tempfile::tempdir().context(\"create source digest staging directory\")?;\n    let file = fs::File::open(source)\n        .with_context(|| format!(\"open capsule archive {}\", source.display()))?;\n    let decoder = flate2::read::GzDecoder::new(file);\n    let mut archive = tar::Archive::new(decoder);\n    let mut names = BTreeSet::new();\n    for entry in archive.entries().context(\"read capsule archive entries\")? {\n        let mut entry = entry.context(\"read capsule archive entry\")?;\n        let path = entry.path().context(\"read capsule archive path\")?;\n        if path.is_absolute()\n            || path\n                .components()\n                .any(|component| matches!(component, std::path::Component::ParentDir))\n        {\n            bail!(\"capsule archive contains an unsafe path {}\", path.display());\n        }\n        let name = path\n            .to_str()\n            .ok_or_else(|| anyhow::anyhow!(\"capsule archive path is not UTF-8\"))?\n            .replace('\\\\', \"/\");\n        if !names.insert(name.clone()) {\n            bail!(\"capsule archive contains duplicate path {name}\");\n        }\n        let entry_type = entry.header().entry_type();\n        if !entry_type.is_dir() && !entry_type.is_file() {\n            bail!(\"capsule archive contains a link or special file {name}\");\n        }\n        let destination = staging.path().join(&path);\n        if entry_type.is_dir() {\n            fs::create_dir_all(&destination)\n                .with_context(|| format!(\"create capsule archive directory {name}\"))?;\n            continue;\n        }","sourceCodeStart":29,"sourceCodeEnd":65,"githubUrl":"https://github.com/astrid-runtime/astrid/blob/affd8760f44190dbdfbec23403f4c4b642c33112/crates/astrid-capsule-install/src/source_digest.rs#L29-L65","documentation":"While unpacking a user-supplied capsule archive into a staging directory to compute its canonical digest, every entry path is checked for safety: absolute paths or any ParentDir (`..`) component are rejected. This is a path-traversal guard — a malicious or malformed archive could otherwise escape the staging directory and overwrite files outside it during digesting.","triggerScenarios":"Calling archive_digest_for_source on a .tar.gz whose entries include absolute paths (e.g. `/etc/passwd`) or `..` components (e.g. `../../evil`), typically produced by packaging tools that used absolute source paths or bad base-directory handling.","commonSituations":"Hand-built tar archives with `tar -C` mistakes; archives created on Windows with drive-letter absolute paths; a malicious or tampered capsule archive; archives regenerated after directory restructuring without stripping a common prefix.","solutions":["Inspect the archive with `tar -tzvf capsule.tgz` and find the offending entry.","Rebuild the archive with relative paths from the capsule root: `tar -czf capsule.tgz -C <capsule-root> .`.","Strip leading prefixes/`..` segments when repacking (e.g. with `tar --transform` or a build script).","Only digest archives from trusted sources; reject ones you did not build yourself."],"exampleFix":"// before: archive built from wrong cwd\n// tar -czf capsule.tgz ../my-capsule/Capsule.toml  (entries contain ..)\n// after: build from inside the capsule root\n// cd my-capsule && tar -czf ../capsule.tgz .","handlingStrategy":"validation","validationCode":"// inspect the archive before digesting\nlet out = std::process::Command::new(\"tar\")\n    .args([\"-tzf\", archive_path])\n    .output()?;\nlet unsafe_entry = String::from_utf8_lossy(&out.stdout)\n    .lines()\n    .any(|l| l.starts_with('/') || l.split('/').any(|seg| seg == \"..\"));\nif unsafe_entry { eprintln!(\"archive has absolute or .. paths; rebuild it\"); }","typeGuard":"fn archive_paths_are_safe(names: &[String]) -> bool {\n    names.iter().all(|n| !n.starts_with('/') && !n.split('/').any(|s| s == \"..\"))\n}","tryCatchPattern":"match archive_digest_for_source(archive) {\n    Err(e) if e.to_string().contains(\"unsafe path\") => {\n        anyhow::bail!(\"rebuild the archive with relative paths: tar -czf capsule.tgz -C <root> .\");\n    }\n    other => other,\n}","preventionTips":["Always build archives with `tar -C <capsule-root> .` so entries are relative","Audit archives with `tar -tzf` before digesting or publishing third-party archives","Never concatenate or merge archives that may carry absolute prefixes","Strip common path prefixes when repacking restructured trees"],"tags":["security","path-traversal","archive","tar"],"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"}