ErrLookup › Background articles › Rust io::Error: what ErrorKind::NotFound, PermissionDenied, and InvalidData actually mean — from real OS failures to library fail-closed refusals
Rust io::Error: what ErrorKind::NotFound, PermissionDenied, and InvalidData actually mean — from real OS failures to library fail-closed refusals
io::Error is the error type Rust programs surface when input or output fails — a missing TLS certificate file, an unwritable temp directory, a truncated build archive, a rename blocked by a cloud-sync placeholder. This family covers both faces of the type across crates such as rustfs, rustc, cargo, Rocket, yazi, exa, and rustdesk: genuine OS errors (errno and Windows error codes mapped to ErrorKind) that libraries re-wrap with context, and library-minted errors built with io::Error::new to express fail-closed invariants such as reparse-point refusals, identity-change races, erasure-shard inconsistency, and framing violations. Developers meet it during file reads and writes, renames, canonicalization, TLS setup, archive building, and DRM capture; the same ErrorKind can be a raw OS verdict in one library and a deliberate refusal in another, so the message and library docs decide which.
Distilled from 252 documented records across 12 repositories.
Background
std::io::Error is Rust's built-in type for failed input and output, and it is produced at two very different layers. The classic form wraps an OS error code — errno on Unix, GetLastError on Windows — and kind() maps that code onto a coarse ErrorKind such as NotFound, PermissionDenied, InvalidInput, InvalidData, Unsupported, or Other; callers branch on the kind for control flow while Display prints the system message. The second form is arbitrary: any library can mint an io::Error with io::Error::new(kind, message), which has made the type the de facto error currency for anything that touches files, streams, or devices.
One half of the documented family is ordinary OS failure, relabeled for diagnosis. Rocket re-wraps failures opening TLS certificate files and keeps the underlying kind; yazi re-wraps cache-stamp write errors with io::Error::new(e.kind(), ...) so PermissionDenied or StorageFull survives the wrap; rustc's archive builder prefixes context strings like 'failed to open object file' or 'failed to map object file' onto the raw error; and cargo's Windows-only try_canonicalize synthesizes an explicit NotFound when canonicalization fails and the path genuinely does not exist. From the caller's side these behave like OS errors with better labeling: the real fix is the path, the permission, the disk, or the share.
The other half never came from the OS at all. Libraries deliberately pick a kind and message to express invariants: rustfs rejects reparse points (junctions, symlinks, mount points, cloud placeholders) as PermissionDenied, aborts file-identity-change races mid-open as InvalidData, enforces a rename jail against '..' components as InvalidInput, and fails closed on volumes without stable 64-bit file IDs as Unsupported. exa returns ErrorKind::Other from a CString path conversion whose message names a NUL byte that cannot occur in real Unix filenames — the actual trigger is almost always non-UTF-8 bytes in the name. cargo's LimitErrorReader converts silent truncation into an explicit error, and rustdesk bounds DRM scanout stride before row-copy arithmetic can overflow. Because the library chooses the kind, ErrorKind alone cannot tell an OS verdict from a library refusal; the message and the library's documentation have to.
The family also varies in shape. Some records chain two failures into one message, such as rustfs's '{write_err}; failed to schedule staged file cleanup: {cleanup_err}', which means the original write error plus a leaked staging file. Many checks are platform-gated — a large share of rustfs's guards exist only on Windows, and rustc's file locking simply reports 'file locks not supported on this platform' on targets without a backend. Retryability is library-specific: rustfs treats identity and framing errors as fail-closed signals that must not be patched around, while its final-path sizing race and staged-file interference are explicitly safe to retry with a fresh handle or a new staging name.
Common causes
- Malformed, escaping, or non-UTF-8 paths. Object names containing '..' segments or root components that climb out of the base directory, rename sources or destinations whose last component is a root or '..', and filenames with bytes that are not valid UTF-8. exa's message names a NUL byte, but on Unix a NUL cannot appear in a real filename, so the trigger is almost always non-UTF-8 encoding failing the CString conversion.
- External software and concurrent processes interfering with live files. Antivirus quarantine-and-recreate, backup and search-indexer agents opening files without FILE_SHARE_DELETE, sync clients, cleanup scripts, or a second server instance sharing the data directory. These produce sharing violations on rename, identity-changed guards between two opens of the same name, and leaked staged files when cleanup also fails.
- Windows reparse points and cloud-sync placeholders. Junctions, directory symlinks, mount points, and OneDrive/Dropbox/SharePoint hydration placeholders make entries carry FILE_ATTRIBUTE_REPARSE_POINT where ordinary files or directories are expected. Storage layers refuse to read through or overwrite them, surfacing PermissionDenied on rename, publication, and path-guard checks.
- Missing files, wrong working directory, or denied permissions. Relative TLS certificate paths that do not resolve from the binary's working directory at launch, canonicalization of build artifacts that have not been generated yet, temp directories that were deleted or are unwritable, and cert/key files readable only by root in containers.
- Corrupted or inconsistent on-disk artifacts. Archives whose recorded member offsets and sizes exceed the actual file length after truncation, replacement, or concurrent builds on one target directory; erasure-coded objects mixing shards from different write generations so parity verification fails; and encrypted streams decrypted from a start offset that is not aligned to the package boundary, so a payload is parsed as a header.
- Environmental limits and unsupported platforms or filesystems. Disk full or quota exceeded during staged writes; FAT32/exFAT or network-redirected volumes that return invalid file IDs, defeating anti-race guards; DRM framebuffers reporting stride 0 or stride times height beyond the copy bound after a mode change; platforms with no file-locking backend, which disables incremental-compilation locking; and read caps that turn oversize input into an explicit error.
- Build and toolchain incompatibilities. C static libraries compiled with -flto contain raw LLVM bitcode or .gnu.lto_/.llvm.lto sections that rustc cannot link into a cdylib, and distros that enable LTO globally make this common; some builds also fail when TMPDIR sits on a different filesystem from the output, turning the final rename cross-device.
What usually fixes it
- Diagnose from the ErrorKind and the wrapped cause, not the headline message. Re-wrapping usually preserves the kind (yazi re-wraps with io::Error::new(e.kind(), ...), Rocket keeps the underlying kind), context prefixes such as 'failed to open object file' name the failing step, and messages can even name the wrong case — exa's NUL message almost always means non-UTF-8 bytes.
- Separate OS failures from fail-closed refusals before retrying. Invariant violations (identity changed mid-open, reparse points rejected, framing or shard-consistency errors) signal an environment problem or an internal bug: fix the environment and preserve logs instead of retry-looping. Tiny race windows — a final path growing between sizing and fill, or one-off staging interference — do clear with a single bounded retry using a fresh handle or a new random staging name.
- Harden the environment that hosts data directories: keep them on plain local NTFS outside cloud-sync and DFS scopes, exclude them from antivirus, backup, and search-indexer agents, ensure a single owning process, and sweep leaked '.rustfs-write-*' staging entries only while the server is stopped.
- Validate paths at the trust boundary: reject '..' segments and leading '/' in bucket and object names before they reach the disk layer, construct destinations as base.join(relative) so prefix checks hold by construction, and convert paths with std::os::unix::ffi::OsStrExt::as_bytes instead of to_str() so non-UTF-8 names cannot fail.
- Rebuild or re-fetch corrupted artifacts instead of trusting a bad read: run cargo clean or delete the specific registry-cache entry, serialize concurrent builds against one target directory, and run heal on objects with mixed-generation shards after comparing each disk's metadata.
- Keep build inputs compatible: compile C static libraries with -fno-lto when they will link into a Rust cdylib (verify with objdump -h | grep lto), keep TMPDIR on the same filesystem as the build output, and confirm the target triple has the backends — such as flock — that the build relies on.
Go deeper
- Parsing and encoding errors: unexpected token, malformed input — why parsers reject input and how to find the real culprit.
- SSL/TLS and certificate errors — how TLS handshakes and certificate validation fail.
Documented occurrences
- Error: path somehow contained a NUL? (ogham/exa)
- rename source must be an ordinary file or a Windows data-dedup entry (rustfs/rustfs)
- guarded Windows path contains a reparse point below its publication root (rustfs/rustfs)
- rename source must have a file name (rustfs/rustfs)
- guarded Windows metadata entry is not an ordinary file (rustfs/rustfs)
- the path was not found (rust-lang/cargo)
- non-final DARE package must carry a full 64KiB payload: cipher={}, payload_len={}, sequence_number={}, header={:02x?} (rustfs/rustfs)
- archive member at offset {start} with size {} exceeds archive size {} in `{}` (rust-lang/rust)
- missing shard {index} after RustFS codec reconstruction (rustfs/rustfs)
- destination metadata identity changed while it was opened (rustfs/rustfs)
- LLVM bitcode object in C static library (LTO not supported) (rust-lang/rust)
- error reading TLS file `{source}`: {e} (rwf2/Rocket)
- {rename_err}; failed to schedule staged file cleanup: {cleanup_err} (rustfs/rustfs)
- LTO object in C static library is not supported (rust-lang/rust)
- staged Windows metadata identity changed while publication was prepared (rustfs/rustfs)
- Windows filesystem did not provide a stable 64-bit file identity (rustfs/rustfs)
- S2 padding exceeds 24-bit framing limit (rustfs/rustfs)
- restore target already exists: {to:?} (sxyazi/yazi)
- rename destination contains an invalid path component (rustfs/rustfs)
- {write_err}; failed to schedule staged file cleanup: {cleanup_err} (rustfs/rustfs)
…and 232 more across the corpus — use search.
Honest provenance: generated on 2026-08-16 from AI-assisted analysis of the linked records. See how records are made.