I/O, Files, and Streams
A code-first guide to streams, files, directories, readers, writers, watchers, compression, hashing, and storage.
The I/O model in one view
Sharp Runtime’s I/O surface is a native C++ implementation of a practical subset of System.IO. The familiar names are useful when porting, but the storage underneath is ordinary C++: streams are polymorphic objects, files are reached through std::fstream and std::filesystem, and ownership is explicit. There is no managed finalization pass to rescue an object whose lifetime was designed incorrectly.
The core IO component owns byte streams, text and binary readers and writers, filesystem helpers, metadata objects, positional descriptor I/O, drive information, unmanaged-memory access, and the Linux file watcher. Compression, ZIP, hashing, storage-path selection, and isolated storage are separate physical components so consumers can select only the surface they need.
| Need | CMake target | What it adds | Important dependency |
|---|---|---|---|
| Streams, files, paths, readers, writers, watcher | SharpRuntime::IO | The core System::IO surface | Public: Core.Base, Uri; private: TimeZone |
| GZip and Deflate streams | SharpRuntime::IO.Compression | Compression streams and options | Private ZLIB; public Buffers, Core.Base, IO |
| ZIP archives | SharpRuntime::IO.Compression.Zip | ZipArchive, entries, modes, levels | Private vendored miniz |
| Non-cryptographic hashes | SharpRuntime::IO.Hashing | Adler, CRC, xxHash and the incremental base API | Public Core.Base, IO |
| Application-scoped storage | SharpRuntime::IO.IsolatedStorage | Store-relative file and directory operations | Private Storage path provider |
Request the component that owns the public header you include. CMake enables its dependency closure. A consumer of IO.Compression, for example, does not need to list IO again.
Surface map
The core component is broad enough that choosing a type by name alone is not always sufficient. This map separates ownership, buffering, representation, and platform concerns.
| Family | Representative types | Use it for | Read before porting |
|---|---|---|---|
| Byte streams | Stream, MemoryStream, FileStream, BufferedStream, UnmanagedMemoryStream | Sequential or seekable byte I/O behind one virtual interface | Capability defaults, close behavior, and ownership differ by concrete stream |
| Binary records | BinaryReader, BinaryWriter | Little-endian primitives, 7-bit lengths, UTF-8 strings | They borrow a raw Stream*; the stream must outlive the wrapper |
| Text | TextReader, TextWriter, StreamReader, StreamWriter, StringReader, StringWriter | Lines and native strings | StreamReader is a byte-to-character reader, not a general encoding decoder |
| Whole-file helpers | File | Read, write, append, copy, move, delete, and existence checks | Text helpers operate on native string bytes and do not offer encoding/BOM options |
| Directories and paths | Directory, Path | Eager enumeration, creation, deletion, movement, normalization, and path parts | The implemented overload set is smaller than .NET’s and Windows root semantics are reduced |
| Metadata objects | FileSystemInfo, FileInfo, DirectoryInfo, DriveInfo | Object-shaped metadata and operations | Symbolic-link APIs, several enumeration overloads, and full drive classification are absent |
| Positional I/O | RandomAccess | Read or write at a file offset without changing a shared position | It borrows a native descriptor and throws on Emscripten |
| Filesystem events | FileSystemWatcher | Single-directory create, delete, change, and rename events on Linux | No recursive watch and no Windows/macOS backend |
| Raw native memory | UnmanagedMemoryStream, UnmanagedMemoryAccessor | Expose caller-owned memory through stream or primitive-access APIs | The caller owns the buffer and must keep it alive |
Streams are capability contracts
Stream supplies a common byte-oriented protocol, not a promise that every operation works. Derived types must implement Read, Close, and Length. The base implementation rejects writing and resizing, derives single-byte reads from Read, derives seeking from Position and Length, and makes Flush a no-op.
Capability properties are virtual defaults rather than abstract members. The base claims readable, not writable, and not seekable. A custom stream that overrides Write but forgets CanWrite can write in practice while readers of its contract reject it. Conversely, an unreadable custom stream that inherits the default CanRead=true is accepted by reader constructors and fails later. Implementers must override every capability whose answer differs from the base, including changes caused by closing the stream.
Read operations may return fewer bytes than requested and return zero at end-of-stream. A caller that needs a complete record must loop or use a reader method whose contract promises an exact count. Write behavior depends on the concrete stream. RandomAccess::Write, for example, retries native short writes until the requested range is complete; the abstract Stream::Write contract does not turn every subclass into an exact-write transport automatically.
See Streams, readers, and writers for the capability matrix, ownership rules, disposal behavior, binary format, and text-encoding boundaries.
Filesystem operations are eager and native
File and Directory are static convenience surfaces. FileInfo and DirectoryInfo retain an absolute path and expose instance-shaped operations. Both styles ultimately use the host filesystem. They do not virtualize naming, permissions, link resolution, sharing, case sensitivity, timestamp resolution, or atomicity into one universal .NET-like filesystem.
Current directory enumeration returns vectors. There are no streaming Enumerate* forms, and the core directory helpers do not expose recursive SearchOption or EnumerationOptions overloads. Large or untrusted directory trees therefore need an application-specific traversal rather than assuming the complete .NET enumeration surface exists.
FileSystemInfo translates path-resolution failures into Sharp Runtime exceptions instead of allowing std::filesystem::filesystem_error to escape its constructor. On POSIX, creation time is approximated with inode metadata-change time where portable birth-time data is unavailable. That distinction matters for backup, synchronization, and audit software.
See Files, directories, paths, and metadata for the exact helper inventory, deletion and movement rules, positional I/O, timestamp caveats, and platform differences.
Ownership and lifetime
The I/O API uses several ownership styles. Confusing them is a more serious bug than choosing the wrong convenience method.
| Object | Backing resource | Ownership rule | Invalidation risk |
|---|---|---|---|
FileStream | Its std::fstream | Owns and closes it; destructor is the RAII boundary | Public operations that require the file throw after Close |
MemoryStream | Internal std::vector<bytecs> | Owns a copy of constructor bytes | A reference from GetBuffer can be invalidated by later growth |
BufferedStream | Raw Stream* | Borrowed by default; optionally closes it when constructed as owner | The wrapped stream must outlive a non-owning wrapper |
| Reader/writer wrappers | Raw Stream* | Borrow it; leaveOpen controls whether close/destruction closes the stream | The pointer dangles if the stream dies first |
UnmanagedMemoryStream | Raw byte buffer | Never frees it | The buffer must outlive the stream and must not move |
RandomAccess | Native integer descriptor | Never opens, closes, duplicates, or extends its lifetime | Closing or reusing the descriptor concurrently is the caller’s bug |
leaveOpen=true means the wrapper does not close the underlying stream. At the selected source revision, the text wrappers also remain usable after their own Close; that is a documented compatibility gap, not a license to rely on the behavior. Design code so wrappers are not reused after close.
Text and binary representations
BinaryReader and BinaryWriter define an explicit little-endian record format. Their string format is UTF-8 bytes preceded by a 7-bit encoded byte length. Character reads decode UTF-8 into charcs UTF-16 code units, retaining a low surrogate when a supplementary scalar crosses a caller-supplied boundary. This is a specific binary protocol, not host-endian serialization of arbitrary C++ objects.
The text wrappers are narrower. StreamReader reads individual bytes as Latin-1/ASCII-like characters and does not detect a BOM or decode multibyte UTF-8. StreamWriter writes the bytes already present in the native std::string. If an application needs validated Unicode transcoding, it should use the Text component’s encoding APIs before or after the I/O operation.
Errors are part of the contract
The implementation increasingly translates native failures into System-shaped exceptions at public doors: invalid ranges use argument exceptions, a closed stream uses ObjectDisposedException, access mismatches use NotSupportedException, and filesystem failures use the relevant IOException family. This does not erase every host difference. Permissions, sharing, missing parents, symlinks, special files, full disks, and unavailable platform services can still produce platform-specific messages and timing.
Existence checks intentionally answer false for empty paths and native lookup errors rather than throwing. Mutating operations are not silent: for example, File::Delete rejects a directory instead of letting std::filesystem::remove remove an empty one, and non-overwriting moves reject an existing destination.
Platform boundaries
- Core file, directory, path, stream, and drive surfaces build on the standard library, but their visible behavior still follows the host filesystem.
RandomAccessuses Win32 overlapped file operations on Windows andpread/pwrite/fsyncon POSIX. It throwsPlatformNotSupportedExceptionon Emscripten.FileSystemWatcherhas a real Linux/inotify backend only. Enabling it elsewhere throws.- Path separators and text-writer line endings are selected at compile time. The
Pathmodel does not reproduce the complete Windows drive/UNC grammar. - Android and Emscripten storage roots belong to the separate
Storagecomponent and require application integration; see Platforms and portability.
Compression, hashing, and isolated storage
The physical component split is also a semantic boundary. IO.Compression provides GZip and raw Deflate stream adapters backed privately by ZLIB. IO.Compression.Zip owns ZIP archive behavior and its private miniz implementation. IO.Hashing contains non-cryptographic checksum and hash algorithms; it is not a substitute for the cryptographic component. IO.IsolatedStorage interprets names relative to an application storage root and now rejects ordinary lexical and resolved-symlink escapes, but it is not an operating-system security sandbox and retains documented check-then-use and direct-constructor boundaries.