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(&current) {
                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

  1. Pass a clean relative path like "sub/dir/file.txt" with no '..', '.', or leading slash.
  2. Reject or normalize user input before calling: strip leading separators and refuse '..' components.
  3. Use Path::components filtering to keep only Normal components, or return your own validation error early.
  4. 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

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


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/a0450e932233019f. Report an issue: GitHub.