{"record":{"id":"a0450e932233019f","repo":"astrid-runtime/astrid","slug":"workspace-descendant-must-be-a-non-empty-relative","errorCode":null,"errorMessage":"workspace descendant must be a non-empty relative path without traversal","messagePattern":"workspace descendant must be a non-empty relative path without traversal","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/astrid-core/src/workspace_security.rs","lineNumber":176,"sourceCode":"                }\n                if metadata.is_dir() {\n                    pending.push(path);\n                }\n            }\n        }\n        self.resolve_directory(relative)?;\n        Ok(root)\n    }\n\n    fn resolve_descendant(&self, relative: &Path, kind: DescendantKind) -> io::Result<PathBuf> {\n        self.verify()?;\n        let components = relative.components().collect::<Vec<_>>();\n        if components.is_empty()\n            || components\n                .iter()\n                .any(|component| !matches!(component, Component::Normal(_)))\n        {\n            return Err(io::Error::new(\n                io::ErrorKind::InvalidInput,\n                \"workspace descendant must be a non-empty relative path without traversal\",\n            ));\n        }\n\n        let mut current = self.state_dir.clone();\n        for (index, component) in components.iter().enumerate() {\n            let Component::Normal(component) = component else {\n                unreachable!(\"components validated above\")\n            };\n            current.push(component);\n            let metadata = match std::fs::symlink_metadata(&current) {\n                Ok(metadata) => metadata,\n                Err(error) if error.kind() == io::ErrorKind::NotFound => continue,\n                Err(error) => return Err(error),\n            };\n            let final_component = index == components.len().saturating_sub(1);\n            let expected_file = final_component && kind == DescendantKind::File;","sourceCodeStart":158,"sourceCodeEnd":194,"githubUrl":"https://github.com/astrid-runtime/astrid/blob/affd8760f44190dbdfbec23403f4c4b642c33112/crates/astrid-core/src/workspace_security.rs#L158-L194","documentation":"resolve_descendant joins a relative path onto the validated workspace root and first requires it to be non-empty and composed solely of Normal components. Empty paths, '.', '..', absolute paths, root/prefix components, or any other special component cause this InvalidInput error, preventing traversal outside the workspace.","triggerScenarios":"Calling resolve_directory or resolve_file with an empty string, a path containing '..' or '.', or an absolute path (e.g. '/etc/passwd') as the descendant argument.","commonSituations":"User-supplied filenames concatenated into paths without sanitization; constructing paths with format! and accidentally including leading '/'; API callers passing the raw path from an HTTP request; off-by-one producing empty relative components.","solutions":["Pass a clean relative path like \"sub/dir/file.txt\" with no '..', '.', or leading slash.","Reject or normalize user input before calling: strip leading separators and refuse '..' components.","Use Path::components filtering to keep only Normal components, or return your own validation error early.","If the caller has an absolute path inside the workspace, strip the workspace root prefix first (e.g. path.strip_prefix(root))."],"exampleFix":"// before\nlet entry = ws.resolve_file(user_supplied)?; // \"../../etc/passwd\"\n// after\nlet rel = Path::new(&user_supplied);\nif rel.is_absolute() || rel.components().any(|c| !matches!(c, Component::Normal(_))) {\n    return Err(\"invalid relative path\");\n}\nlet entry = ws.resolve_file(rel)?;","handlingStrategy":"validation","validationCode":"fn safe_relative(p: &Path) -> bool {\n    !p.as_os_str().is_empty()\n        && !p.is_absolute()\n        && p.components().all(|c| matches!(c, std::path::Component::Normal(_)))\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Reject user-supplied paths containing '..' or leading '/' at the API boundary","strip_prefix the workspace root before passing absolute paths","Never build descendant paths with string concatenation"],"tags":["security","path-traversal","validation","workspace"],"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-14T05:17:10.506Z"}