astrid-runtime/astrid · critical · io::Error

PermissionDenied

PermissionDenied

Error message

private file identity changed while reading

What it means

read_private_file_to_string records the file's identity (from file_identity) before reading, then re-validates the handle contract and compares identity afterward. If the identity changed mid-read, the file was replaced/renamed/swapped concurrently (TOCTOU), so the library discards the result and returns PermissionDenied rather than return contents of a different file.

Source

Thrown at crates/astrid-core/src/platform_fs/windows/private_file.rs:41

        )
    })?;
    let guard = TrustedPathGuard::capture(parent)?;
    guard.verify_contract(BoundaryContract::ExactPrivateDirectory)?;
    let _transaction_lock = acquire_private_file_transaction_lock(parent, &guard)?;
    recover_private_file_transaction_locked(parent, &guard)?;
    guard.verify_contract(BoundaryContract::ExactPrivateDirectory)?;

    let mut file = open_guarded_regular_file(&guard, path, FileContract::ExactPrivate)?;
    let identity = file_identity(&file)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    validate_file_contract(
        file.as_raw_handle().cast(),
        path,
        FileContract::ExactPrivate,
    )?;
    if file_identity(&file)? != identity {
        return Err(io::Error::new(
            io::ErrorKind::PermissionDenied,
            "private file identity changed while reading",
        ));
    }
    guard.verify_contract(BoundaryContract::ExactPrivateDirectory)?;
    Ok(contents)
}

pub(in crate::platform_fs) fn atomic_write_private_file(
    path: &Path,
    bytes: &[u8],
) -> io::Result<()> {
    validate_local_absolute_path(path)?;
    let parent = path.parent().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            "private Windows file has no parent directory",
        )

View on GitHub (pinned to affd8760f4)

Solutions

  1. Retry the read — after a replacement the new file is usually stable; a simple backoff retry typically succeeds.
  2. Serialize access: perform reads and replacements under the same application-level lock or use the library's locked-file APIs on both sides.
  3. Identify the concurrent writer (installer, sync client, AV) and exclude the file's directory from its operations.

Example fix

// before
let secret = read_private_file_to_string(path)?; // races with writer
// after
let secret = loop {
    match read_private_file_to_string(path) {
        Ok(s) => break s,
        Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
            std::thread::sleep(RETRY_DELAY);
            attempts += 1;
            if attempts > MAX_ATTEMPTS { return Err(e); }
        },
        Err(e) => return Err(e),
    }
};
Defensive patterns

Strategy: retry

Try / catch

const MAX: usize = 5;
for attempt in 0..MAX {
    match read_private_file_to_string(path) {
        Ok(s) => return Ok(s),
        Err(e) if e.kind() == io::ErrorKind::PermissionDenied
            && e.to_string().contains("identity changed") => {
            std::thread::sleep(Duration::from_millis(50 * (attempt as u64 + 1)));
        },
        Err(e) => return Err(e),
    }
}
Err(io::Error::new(io::ErrorKind::PermissionDenied, "file keeps changing"))

Prevention

When it happens

Trigger: Calling read_private_file_to_string while another process or thread replaces the target file during the read — e.g. a concurrent restrict_private_file/replace operation, an installer swapping the file, antivirus quarantine, or a sync client (OneDrive/Dropbox) rewriting the file.

Common situations: Two application instances racing on the same private file; deploy scripts replacing secret files while the app reads them; file-sync or backup tools touching the path; periodic key-rotation jobs colliding with readers.

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 astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/20d8e68bae0c8e76. Report an issue: GitHub.