Files, Directories, Paths, and Metadata
Use native filesystem APIs precisely, with explicit path, metadata, error, portability, and lifetime boundaries.
Three ways to work with the filesystem
Sharp Runtime exposes the same storage through three different shapes. Static helpers are concise for one operation, metadata objects retain a path for a sequence of related operations, and streams expose ordered bytes. Choosing among them is about lifetime and control rather than feature ranking.
| Shape | Types | Best use | Tradeoff |
|---|---|---|---|
| Static helpers | File, Directory, Path | One-shot reads, writes, moves, existence checks, and path manipulation | Whole-file/eager operations and a deliberately bounded overload set |
| Metadata objects | FileSystemInfo, FileInfo, DirectoryInfo, DriveInfo | Retain a normalized path and combine metadata with operations | Not a live handle; the filesystem may change between calls |
| Open I/O | FileStream, RandomAccess | Incremental bytes, access modes, seeking, resizing, or explicit offsets | Resource ownership and close behavior become part of correctness |
All three use native facilities. Sharp Runtime does not provide a virtual filesystem abstraction that erases case sensitivity, permissions, sharing, symbolic links, timestamp resolution, or atomic-rename differences. Treat a portable source branch as evidence that the code compiles there, not as proof that every host filesystem answers identically.
File: whole-file and namespace operations
System::IO::File is a static class for the common operations currently implemented:
| Operation group | Members | Current behavior |
|---|---|---|
| Existence | Exists | True only for a regular file; empty paths and lookup errors return false |
| Namespace mutation | Delete, Copy, Move | Copy can opt into overwrite; move is non-overwriting; delete rejects a directory |
| Text | ReadAllText, WriteAllText, AppendAllText | Reads or writes native string bytes through standard streams |
| Lines | ReadAllLines, WriteAllLines | Eager vector of strings; writes an LF after each supplied line |
| Binary | ReadAllBytes, WriteAllBytes | Eager byte vectors in binary mode |
File::Delete intentionally does not inherit std::filesystem::remove’s ability to remove an empty directory. It first rejects a directory with IOException, matching the separation between file deletion and directory deletion. Deleting a missing file succeeds. FileInfo::Delete routes through the same implementation so the static and object-shaped doors cannot disagree.
Non-overwriting Move rejects an existing destination before calling the native rename operation. This matters on POSIX, where an unchecked rename can replace an existing destination. Copy and Move require the source to be a regular file and translate their main native failures into the IOException family.
The text methods read and write the bytes carried by std::string. They do not detect a BOM, validate UTF-8, or accept an Encoding argument. ReadAllLines uses the native std::getline LF delimiter, while WriteAllLines writes LF explicitly. Applications that need a defined Unicode or line-ending policy should make that policy explicit with the Text component.
Directory: eager, single-level enumeration
System::IO::Directory implements existence, recursive directory creation, optional recursive deletion, movement, eager file/subdirectory enumeration, and process-current-directory access.
| Member | Implemented scope | Not implied |
|---|---|---|
Exists | True only when the path resolves to a directory | It does not distinguish missing, inaccessible, and invalid with exceptions |
CreateDirectory | Creates intermediate directories | No symbolic-link creation API |
Delete(path, recursive) | Single directory or full tree | Recursive deletion is destructive and follows host filesystem behavior |
Move | Non-overwriting native rename | No cross-device copy-and-delete fallback |
GetFiles(path) | Immediate regular files, returned eagerly | No streaming enumeration or recursion |
GetFiles(path, pattern) | */? pattern over immediate file names | No SearchOption or EnumerationOptions |
GetDirectories | Immediate subdirectories, returned eagerly | No pattern overload in this class |
| Current directory | GetCurrentDirectory, SetCurrentDirectory | The current directory is process-global state, not thread-local context |
The pattern translator treats *.* as *, preserving the legacy .NET/DOS expectation that extensionless names are included. Matching is case-insensitive in the current implementation, including on case-sensitive POSIX filesystems. That is a visible policy choice: it should not be used to infer the filesystem’s actual case rules.
Enumeration uses std::filesystem::directory_iterator. Results are not sorted, snapshots are not atomic, and entries can disappear between enumeration and use. Because the surface is eager, memory consumption grows with the number of matching entries. Permission or concurrent-mutation failures are not normalized uniformly at every iterator step; callers should keep their error handling at the operation boundary.
Path: lexical operations, not filesystem authority
System::IO::Path manipulates path strings. Most members do not access the filesystem, and a syntactically normalized result is not proof that a target exists or remains confined to a directory.
| Area | Members | Semantics |
|---|---|---|
| Composition | Combine with two or three parts | A rooted later part replaces preceding parts |
| Name parts | GetFileName, GetFileNameWithoutExtension, GetExtension, GetDirectoryName | Lexical string/path decomposition |
| Normalization | GetFullPath | Resolves against the process current directory and collapses duplicate, dot, and dot-dot segments |
| Temporary paths | GetTempPath, GetTempFileName | Uses the host temporary directory; the file-name method creates the file |
| Extensions | ChangeExtension, HasExtension | Works on the final path segment |
| Root test | IsPathRooted | Delegates absolute-path recognition to std::filesystem::path |
GetFullPath rejects an empty string and embedded NUL, obtains the process working directory for relative input, and removes relative segments lexically. It does not resolve symbolic links. Code enforcing a security boundary needs both lexical checks and an appropriate native filesystem strategy; path normalization alone cannot defeat a link swap or another process changing the tree.
The root-length helper is intentionally simplified. Drive-letter and UNC-prefix behavior is not modeled as a complete .NET Windows path grammar. Separator constants and PathSeparator are platform-specific, but a successful build on Windows does not make every managed path edge case equivalent.
FileSystemInfo, FileInfo, and DirectoryInfo
FileSystemInfo retains the original spelling and a resolved absolute path. ToString returns the original spelling; FullName returns the absolute path. An empty constructor path throws ArgumentException, and resolution uses an error-code path so a std::filesystem::filesystem_error does not escape that constructor.
Timestamp model
The base supplies local and UTC creation, last-access, and last-write getters plus local/UTC last-write setters. It does not supply creation-time setters. On POSIX, “creation time” uses st_ctime when portable birth-time data is unavailable; that is inode metadata-change time, not necessarily when the file was created. Timestamp precision, daylight conversion, and access-time updates remain filesystem and mount-policy dependent.
FileInfo
FileInfo exposes name, directory name, existence, length, a simplified owner-write permission test, delete, copy, and move. Length throws when the path is a directory but returns zero when a missing file or another size lookup error prevents a result. The object is not an open handle and does not cache existence or size as a coherent transaction.
CopyTo and MoveTo validate their destination and require the source to exist. A successful move updates the object’s full and original paths. CopyToInfo is a native convenience that returns a new destination object. Full .NET properties such as attributes, Unix mode, link target, and symbolic-link creation/resolution are not implemented on this base/object family.
DirectoryInfo
DirectoryInfo exposes name, parent, existence, create, delete, move, immediate FileInfo enumeration, immediate DirectoryInfo enumeration, and GetFileNames. The last member is a Sharp Runtime convenience; .NET’s DirectoryInfo.GetFiles already returns metadata objects rather than strings.
Missing are CreateSubdirectory, streaming Enumerate* methods, GetFileSystemInfos, Root, and pattern/recursive overloads on the instance. Recursive delete is explicit. The non-recursive override rejects a missing directory and reports non-empty/native failures rather than silently turning them into a no-op.
FileStream modes, access, and large offsets
FileStream supplies the open-handle surface for common file work. FileMode::Open and Truncate require an existing regular file; CreateNew rejects an existing target; create/truncate/append modes require write access; append cannot include read access. The stream tracks read/write capability and consults the native open state for every public operation that needs the file.
The general Stream contract represents length, position, count, and offsets with 32-bit intcs. Code that needs a large-file offset beyond that representable range should use RandomAccess, whose lengths and file offsets are longcs, or a project-specific native abstraction. See Streams, readers, and writers for the complete lifecycle and capability matrix.
Although types such as FileShare, FileOptions, and FileStreamOptions exist in the module, the active FileStream constructor set does not reproduce the complete managed sharing, asynchronous-option, handle, and buffer-size surface. Do not infer file-sharing enforcement merely from the presence of the enum headers.
RandomAccess: positional native descriptor I/O
RandomAccess performs reads and writes at an explicit file offset without changing a shared logical stream position. On POSIX it uses pread, pwrite, lseek, ftruncate, and fsync. On Windows it converts a CRT integer descriptor to a Win32 handle and uses overlapped ReadFile/WriteFile, file-pointer/length operations, and FlushFileBuffers. Every operation throws PlatformNotSupportedException on Emscripten.
| Member | Contract | Caller responsibility |
|---|---|---|
GetLength | Returns a 64-bit length; invalid or non-seekable descriptors throw | Pass a live descriptor suitable for a file-length query |
SetLength | Rejects negative length and reports native truncation failure | Descriptor must permit resize |
Read | Returns a possibly shorter count, zero at end | Keep the destination buffer and descriptor valid for the call |
Write | Retries short writes until all requested bytes are written; detects zero progress | Keep the source buffer stable |
FlushToDisk | Requests an OS-level durable flush | Understand that device/filesystem durability remains host-defined |
The class borrows the integer descriptor. It never opens, closes, duplicates, locks, or extends its lifetime. Closing the descriptor, letting another thread reuse its number, or changing the underlying file concurrently is outside the class’s ownership boundary.
DriveInfo is a lightweight view
DriveInfo uses std::filesystem::space for available, free, and total bytes. It reports zero when the root is not ready or the query fails. On Windows, GetDrives enumerates the logical-drive bitmask. Elsewhere it returns the root directory /. Drive type currently always reports Fixed, drive format reports Unknown, and volume label returns the configured name. These values are useful as a small storage-capacity surface, not a full mount/volume inventory.
Watching is a separate concurrency model
FileSystemWatcher is not a polling wrapper around Directory. On Linux it owns inotify descriptors and a background dispatch thread. Handler lifetime, reconfiguration, event coalescing, kernel queue limits, and backend availability therefore require separate treatment. Read FileSystemWatcher in depth before using it in teardown-sensitive code.
Filesystem portability checklist
- Do not assume enumeration order. Sort explicitly when order is part of your output or test.
- Do not assume case sensitivity from the platform label alone; mounts and the current pattern implementation can differ.
- Do not treat
Exists=falseas proof that a path is absent; it can also mean the lookup failed. - Keep path normalization separate from symlink-aware confinement.
- Expect timestamp precision and creation-time meaning to vary.
- Use the operation’s exception, not a preflight existence check, as the final authority; the tree can change between calls.
- Keep destructive recursion explicit and narrowly scoped.
- Do not assume an open file can be renamed, deleted, or shared identically on Windows and POSIX.