{"record":{"id":"89b94faae29aadba","repo":"pnpm/pnpm","slug":"non-normal-path-component-in-cas-entry-rel","errorCode":null,"errorMessage":"non-normal path component in CAS entry: {rel}","messagePattern":"non-normal path component in CAS entry: (.+?)","errorType":"exception","errorClass":"GitFetcherError","httpStatus":null,"severity":"error","filePath":"pnpm/crates/git-fetcher/src/cas_io.rs","lineNumber":60,"sourceCode":"/// extraction side already get path-traversal guards in\n/// `pnpm-tarball`, but defense-in-depth at this layer means a\n/// future caller (or a bug in that earlier sanitiser) can't turn\n/// a malformed entry into a write outside the working tree.\nfn join_checked(root: &Path, rel: &str) -> Result<PathBuf, GitFetcherError> {\n    let rel_path = Path::new(rel);\n    if rel_path.is_absolute() {\n        return Err(GitFetcherError::Io(io::Error::new(\n            io::ErrorKind::InvalidInput,\n            format!(\"absolute path is not allowed in CAS entry: {rel}\"),\n        )));\n    }\n    let mut out = root.to_path_buf();\n    for c in rel_path.components() {\n        match c {\n            Component::Normal(seg) => out.push(seg),\n            Component::CurDir => {}\n            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {\n                return Err(GitFetcherError::Io(io::Error::new(\n                    io::ErrorKind::InvalidInput,\n                    format!(\"non-normal path component in CAS entry: {rel}\"),\n                )));\n            }\n        }\n    }\n    Ok(out)\n}\n\n/// Copy every CAS file referenced in `cas_paths` into `target_dir`,\n/// preserving relative paths. CAS files are hardlinked-or-copied per\n/// install elsewhere, but for the prepare phase the working tree must\n/// be writable *without* mutating the shared CAS entry, so this path\n/// always allocates fresh inodes via [`fs::copy`].\n///\n/// Produces a *standalone* directory rather than a CAFS slot —\n/// pacquet's `StoreDir` only knows how to import on the way *in*, and\n/// the prepare phase needs raw filesystem semantics for scripts to","sourceCodeStart":42,"sourceCodeEnd":78,"githubUrl":"https://github.com/pnpm/pnpm/blob/6261b7f388016d57ca6b90340342411cd1d0d00f/pnpm/crates/git-fetcher/src/cas_io.rs#L42-L78","documentation":"The same `join_checked` traversal guard also walks the entry's path components: `Component::Normal` segments are appended, `CurDir` (`.`) is skipped, and `ParentDir` (`..`), `RootDir`, or a Windows `Prefix` component aborts with InvalidInput 'non-normal path component in CAS entry'. This blocks `..`-based escapes out of the CAS root even when the path is technically relative.","triggerScenarios":"A `cas_paths` entry containing `..` segments (or a root/prefix component) reaching the git-fetcher's materialize/import step — e.g. `../../etc/passwd` surviving from a crafted git tree or a tarball-sanitizer bug.","commonSituations":"Malicious git dependencies or tarballs with traversal paths; corrupted store index data; upstream sanitiser regressions.","solutions":["Note the printed `{rel}` entry and which dependency produced it","Remove that git dependency's entry from the store and re-fetch from a trusted ref","Report the package and path to pnpm maintainers — normal data never reaches this guard","Treat repeated occurrences as a possible supply-chain attack and audit the dependency source"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"fn cas_entry_has_no_escape(rel: &str) -> bool {\n    use std::path::{Component, Path};\n    Path::new(rel)\n        .components()\n        .all(|c| !matches!(c, Component::ParentDir | Component::RootDir | Component::Prefix(_)))\n}","typeGuard":"fn is_safe_cas_rel(rel: &str) -> bool {\n    use std::path::{Component, Path};\n    let p = Path::new(rel);\n    !p.is_absolute()\n        && p.components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir))\n}","tryCatchPattern":"match import_into_cas(&root, &cas_paths) {\n    Err(GitFetcherError::Io(ref e))\n        if e.kind() == std::io::ErrorKind::InvalidInput\n            && e.to_string().contains(\"non-normal path component\") => {\n        // a `..`/root/prefix component survived upstream sanitising: reject the entry\n    }\n    other => other.map_err(Into::into),\n}","preventionTips":["Reject `..` components where entry paths are produced (tarball extraction, packlist over git trees)","Treat any hit of this guard as a security signal: audit the source dependency","Keep sanitizers and this defense-in-depth layer both active — do not rely on one"],"tags":["pnpm","git","security","path-traversal","store"],"backgroundTag":"path-traversal-blocked","analyzedSha":"6261b7f388016d57ca6b90340342411cd1d0d00f","analyzedAt":"2026-08-17T18:30:54.750Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}