Hmbown/CodeWhale · error · io::Error (InvalidInput)

external credential path must name a non-reparse regular…

Error message

external credential path must name a non-reparse regular file

What it means

On Windows, the file is opened with `FILE_FLAG_OPEN_REPARSE_POINT` so symlinks/junctions are not followed, and the resulting metadata must be a plain regular file with the `FILE_ATTRIBUTE_REPARSE_POINT` bit clear. Anything that is itself a reparse point (symlink, mount point, OneDrive placeholder, AppExecLink) is rejected with `InvalidInput`, because a reparse point could redirect the consented read to a different secret.

Solutions

  1. Grant the real, physical file location (the junction/symlink target) instead of the link itself.
  2. Move the credential file out of OneDrive/placeholder-backed folders into a local path, or mark the file as always-available offline so it dehydrates to a regular file.
  3. Replace the symlink with a hard copy of the file (`Copy-Item`) rather than `New-Item -ItemType SymbolicLink`.

Example fix

# before
PS> New-Item -ItemType SymbolicLink -Path $env:USERPROFILE\.codewhale\claude.json -Target D:\secrets\claude.json
# after
PS> Copy-Item D:\secrets\claude.json $env:USERPROFILE\.codewhale\credentials\claude.json
Defensive patterns

Strategy: validation

Validate before calling

fn ensure_no_reparse(p: &std::path::Path) -> bool {
    use std::os::windows::fs::MetadataExt;
    match std::fs::metadata(p) {
        Ok(md) => md.file_attributes() & 0x400 == 0, // FILE_ATTRIBUTE_REPARSE_POINT
        Err(_) => false,
    }
}

Try / catch

match read_to_string(&grant) {
    Err(e) if e.to_string().contains("non-reparse") => eprintln!("credential path is a symlink/junction/placeholder; grant the physical file instead"),
    other => other.map(|_| ()).map_err(Into::into),
}

Prevention

When it happens

Trigger: Granting a Windows symlink or junction as the credential path; the credential file lives under a OneDrive-synced folder (cloud placeholder attributes) or is an AppExecLink; the user aliased the real file with `mklink`.

Common situations: Users storing dotfiles/config under OneDrive/Dropbox folders where placeholders are reparse points; developers symlinking `~/.codewhale` into another location; CI runners with redirected user profiles.

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@433685b202 (2026-09-15). Data as JSON: /api/errors/4af2cce45507b83e. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/external_credentials.rs:280

            io::ErrorKind::InvalidInput,
            "external credential path must be absolute and lexically normalized",
        ));
    }

    // Reject every reparse-point component before the final open. The final
    // handle is opened as the reparse point itself, checked again, and its
    // kernel-resolved path is compared below. A second component pass catches
    // replacement during the open window.
    reject_windows_reparse_components(path)?;
    let file = std::fs::OpenOptions::new()
        .read(true)
        .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
        .open(path)?;
    let metadata = file.metadata()?;
    if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
        || !metadata.file_type().is_file()
    {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "external credential path must name a non-reparse regular file",
        ));
    }
    reject_windows_reparse_components(path)?;

    let handle = file.as_raw_handle();
    // Compare the spelling Windows actually opened rather than asking it to
    // expand the path into its normalized long form. A valid caller path can
    // contain an 8.3 component such as `RUNNER~1`; normalizing only the handle
    // side would make that exact path look redirected. FILE_NAME_OPENED keeps
    // the comparison handle-relative while the pre/post component checks above
    // continue to reject reparse points and swaps.
    let flags = FILE_NAME_OPENED | VOLUME_NAME_DOS;
    // SAFETY: the handle remains owned by `file`; null output asks Windows for
    // the required UTF-16 buffer length.
    let needed = unsafe { GetFinalPathNameByHandleW(handle, std::ptr::null_mut(), 0, flags) };
    if needed == 0 {

View on GitHub (pinned to 433685b202)