Hmbown/CodeWhale · error · io::Error
external credential path must be lexically normalized
Error message
external credential path must be lexically normalized
What it means
During its component walk, `open_secure_regular_file` accepts only normal path components and the root. Components like `.` (CurDir), `..` (ParentDir), or Windows prefixes are rejected with this `InvalidInput` error, guaranteeing the path is lexically normalized before it is opened — no symlink-style or traversal-style relative navigation can occur inside the secure open.
Solutions
- Lexically normalize the path before passing it: remove `.` and resolve `..` against its base, or call `path_clean`/manual canonicalization of components.
- If the target is behind symlinks you want resolved, canonicalize the intended real path yourself and pass the resulting absolute, normal path.
- Fix the config value or code that produced the unnormalized path — write the final path directly.
- Note `fs::canonicalize` alone may be inappropriate pre-open for security paths; prefer rejecting and reconstructing the path cleanly.
Example fix
// before
let creds = read_to_string("/home/me/../me/.config/creds")?;
// after
let creds = read_to_string("/home/me/.config/creds")?; // lexically normalized Defensive patterns
Strategy: validation
Validate before calling
fn is_lexically_normalized(p: &Path) -> bool {
use std::path::Component;
!p.components().any(|c| matches!(c,
Component::CurDir | Component::ParentDir | Component::Prefix(_)))
}
if !is_lexically_normalized(&path) {
return Err(anyhow::anyhow!("credential path not normalized: {path:?}"));
} Type guard
fn is_safe_credential_path(p: &Path) -> bool {
use std::path::Component;
p.is_absolute() && p.components().all(|c| matches!(c, Component::RootDir | Component::Normal(_)))
} Try / catch
match read_to_string(&cred_path) {
Err(e) if e.to_string().contains("lexically normalized") => {
eprintln!("Resolve '.'/'..' in {cred_path:?} before passing it (no traversal allowed).");
}
other => other?,
} Prevention
- Normalize paths (resolve `.` and `..` lexically) before storing them in config.
- Never pass user-supplied relative traversal segments into credential loaders.
- Prefer canonical, written-out absolute paths in configuration files.
- Treat unnormalized paths as a red flag in untrusted input paths.
When it happens
Trigger: Calling `read_to_string`/`read_codewhale_owned_to_string` with a path containing `.`, `..`, or a non-Unix prefix component, e.g. `/home/me/../me/.config/creds` or `./credentials`.
Common situations: Paths assembled by string concatenation or templating that leave `..` segments in; user-supplied config values not canonicalized; migrated configs with symlink-heavy layouts baked in as `..` chains.
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
- Codewhale-owned credential file must be singly linked
- Codewhale-owned credential file must be singly linked…
- external credential path contains a NUL byte
- external credential path escapes its absolute root
- external credential path must be absolute
AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22).
Data as JSON: /api/errors/a410181a855d35cd.
Report an issue: GitHub.
Appendix: source
Thrown at crates/tui/src/external_credentials.rs:173
// SAFETY: `root` is a valid C string and flags require no variadic mode.
let root_fd = unsafe {
libc::open(
root.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
)
};
if root_fd < 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: `root_fd` is newly owned after the successful `open`.
let mut current = unsafe { File::from_raw_fd(root_fd) };
let mut normals = path
.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(Ok(part)),
Component::RootDir => None,
Component::Prefix(_) | Component::CurDir | Component::ParentDir => {
Some(Err(io::Error::new(
io::ErrorKind::InvalidInput,
"external credential path must be lexically normalized",
)))
}
})
.peekable();
let mut opened_leaf = false;
while let Some(component) = normals.next() {
let component = component?;
let component = CString::new(component.as_bytes()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"external credential path contains a NUL byte",
)
})?;
let leaf = normals.peek().is_none();
#[cfg(test)]View on GitHub (pinned to 73e0f67d83)