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

  1. Lexically normalize the path before passing it: remove `.` and resolve `..` against its base, or call `path_clean`/manual canonicalization of components.
  2. If the target is behind symlinks you want resolved, canonicalize the intended real path yourself and pass the resulting absolute, normal path.
  3. Fix the config value or code that produced the unnormalized path — write the final path directly.
  4. 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

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


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)