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

Codewhale-owned credential file must be singly linked

Error message

Codewhale-owned credential file must be singly linked

What it means

For Codewhale-owned files on Windows with `require_owner_only = true`, `GetFileInformationByHandle` reports the number of hard links; anything other than 1 returns `PermissionDenied`. Extra hard links mean the same file data is reachable at another path, which defeats per-file ownership/mode assumptions and enables link-swap tampering.

Solutions

  1. Give the file a fresh single-link inode: `Copy-Item` the file to a temp name and `Move-Item -Force` it over the original.
  2. Find the extra links (`fsutil hardlink list <path>` on newer Windows) and delete the aliases.
  3. Recreate the credential file with the owning tool instead of copying/linking it.

Example fix

# before
PS> fsutil hardlink list $env:USERPROFILE\.codewhale\credentials\claude.json   # shows 2+ links
# after
PS> Copy-Item $env:USERPROFILE\.codewhale\credentials\claude.json $env:TEMP\claude.json
PS> Move-Item -Force $env:TEMP\claude.json $env:USERPROFILE\.codewhale\credentials\claude.json
Defensive patterns

Strategy: validation

Validate before calling

fn ensure_single_link(path: &std::path::Path) -> std::io::Result<()> {
    use std::os::windows::io::*;
    let f = std::fs::File::open(path)?;
    let info = f.metadata()?;
    if info.number_of_links() != 1 {
        return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "file must be singly linked"));
    }
    Ok(())
}

Try / catch

match read_codewhale_owned_to_string(&path) {
    Err(e) if e.to_string().contains("singly linked") => { relink_copy(&path)?; read_codewhale_owned_to_string(&path).map_err(Into::into) }
    other => other.map_err(Into::into),
}

Prevention

When it happens

Trigger: The credential file was hard-linked (`mklink /H` or `fsutil hardlink create`) to another location, or created by tooling that hard-links (rsync `--link-dest` equivalents, copy-on-write dedupe, some backup restore tools).

Common situations: Backup-restore jobs recreating credentials via hard links; users aliasing the file into a second profile with `mklink /H`; deduplicating sync tools that link identical files.

Understand the failure class

Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@433685b202 (2026-09-15). Data as JSON: /api/errors/1dea00f9cab01be9. Report an issue: GitHub.

Appendix: source

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

    let expected = normalize_windows_path_for_comparison(path)?;
    if actual != expected {
        return Err(io::Error::new(
            io::ErrorKind::PermissionDenied,
            "external credential path was redirected while opening",
        ));
    }
    if require_owner_only {
        use windows_sys::Win32::Storage::FileSystem::{
            BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
        };
        let mut information = BY_HANDLE_FILE_INFORMATION::default();
        // SAFETY: the opened credential handle and output pointer remain valid
        // for the duration of the call.
        if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
            return Err(io::Error::last_os_error());
        }
        if information.nNumberOfLinks != 1 {
            return Err(io::Error::new(
                io::ErrorKind::PermissionDenied,
                "Codewhale-owned credential file must be singly linked",
            ));
        }
        verify_windows_owner_only_handle(handle)?;
    }
    Ok(file)
}

/// Normalize a Windows path without replacement characters. Unpaired UTF-16
/// is rejected so two distinct paths can never compare equal after a lossy
/// conversion. This is intentionally stricter than filesystem display.
#[cfg(windows)]
fn normalize_windows_path_for_comparison(path: &Path) -> io::Result<String> {
    let text = path.to_str().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::PermissionDenied,
            "credential path contains invalid Unicode and cannot be compared safely",

View on GitHub (pinned to 433685b202)