astrid-runtime/astrid · error
workspace descendant must be a non-empty relative path witho
Error message
workspace descendant must be a non-empty relative path without traversal
What it means
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.
Source
Thrown at crates/astrid-core/src/workspace_security.rs:176
}
if metadata.is_dir() {
pending.push(path);
}
}
}
self.resolve_directory(relative)?;
Ok(root)
}
fn resolve_descendant(&self, relative: &Path, kind: DescendantKind) -> io::Result<PathBuf> {
self.verify()?;
let components = relative.components().collect::<Vec<_>>();
if components.is_empty()
|| components
.iter()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"workspace descendant must be a non-empty relative path without traversal",
));
}
let mut current = self.state_dir.clone();
for (index, component) in components.iter().enumerate() {
let Component::Normal(component) = component else {
unreachable!("components validated above")
};
current.push(component);
let metadata = match std::fs::symlink_metadata(¤t) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
let final_component = index == components.len().saturating_sub(1);
let expected_file = final_component && kind == DescendantKind::File;View on GitHub (pinned to affd8760f4)
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)).
Example fix
// before
let entry = ws.resolve_file(user_supplied)?; // "../../etc/passwd"
// after
let rel = Path::new(&user_supplied);
if rel.is_absolute() || rel.components().any(|c| !matches!(c, Component::Normal(_))) {
return Err("invalid relative path");
}
let entry = ws.resolve_file(rel)?; Defensive patterns
Strategy: validation
Validate before calling
fn safe_relative(p: &Path) -> bool {
!p.as_os_str().is_empty()
&& !p.is_absolute()
&& p.components().all(|c| matches!(c, std::path::Component::Normal(_)))
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Understand the failure class
Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.
Related errors
- durable capsule {id} has unsafe WIT metadata path {relative}
- private directory contains traversal: {}
- workspace selection changed after validation
- refusing to seed canonical from a non-content-address contra
- symlink {} resolves outside the capsule source root ({}); re
AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09).
Data as JSON: /api/errors/a0450e932233019f.
Report an issue: GitHub.