io_v2_files.zig (19727B)
1 //! Private native regular-file backend, not a v2 facility dispatcher. Single 2 //! owner; the embedding supplies scope admission and exclusive registry leases. 3 //! These synchronous bounded calls make no cancellation/deadline guarantee. 4 //! Never use this for standard streams, FIFOs or devices. No v1 policy/dispatch. 5 const std = @import("std"); 6 const c = @cImport({ 7 @cUndef("_FORTIFY_SOURCE"); 8 @cDefine("_FORTIFY_SOURCE", "0"); 9 @cInclude("uv.h"); 10 @cInclude("sys/stat.h"); 11 @cInclude("fcntl.h"); 12 }); 13 pub const limit = std.math.maxInt(i64); 14 pub const chunk_limit = 4096; 15 pub const Mode = enum { reader, writer, appender, update }; 16 pub const Error = error{ InvalidArgument, InvalidBound, UnsupportedFileType, WrongAccess, UnsupportedMode, Closed, HostFailure }; 17 // Error codes are copied before request cleanup. A failed acquisition retains its 18 // primary error AND any close error; cleanup does not replace the primary fault. 19 pub const Status = struct { code: c_int = 0, cleanup_code: c_int = 0 }; 20 pub const File = struct { 21 fd: ?c.uv_file, 22 mode: Mode, 23 cursor: u64 = 0, 24 fn descriptor(self: *const File) Error!c.uv_file { 25 return self.fd orelse error.Closed; 26 } 27 pub fn close(self: *File, status: *Status) Error!void { 28 status.* = .{}; 29 const fd = try self.descriptor(); 30 self.fd = null; // Never retry an uncertain close against a reused FD. 31 var req: c.uv_fs_t = undefined; 32 const rc = c.uv_fs_close(null, &req, fd, null); 33 c.uv_fs_req_cleanup(&req); 34 try check(status, rc); 35 } 36 pub fn read(self: *File, status: *Status, bytes: []u8) Error!usize { 37 status.* = .{}; 38 if (bytes.len == 0 or bytes.len > chunk_limit) return error.InvalidBound; 39 const fd = try self.descriptor(); 40 if (self.mode != .reader and self.mode != .update) return error.WrongAccess; 41 const count = @min(bytes.len, limit - self.cursor); 42 if (count == 0) { 43 _ = try self.size(status); 44 return 0; 45 } 46 var buffer = c.uv_buf_init(bytes.ptr, @intCast(count)); 47 var req: c.uv_fs_t = undefined; 48 const rc = c.uv_fs_read(null, &req, fd, &buffer, 1, @intCast(self.cursor), null); 49 c.uv_fs_req_cleanup(&req); 50 try check(status, rc); 51 self.cursor += @intCast(rc); 52 return @intCast(rc); 53 } 54 pub fn write(self: *File, status: *Status, bytes: []const u8) Error!usize { 55 status.* = .{}; 56 if (bytes.len == 0 or bytes.len > chunk_limit) return error.InvalidBound; 57 const fd = try self.descriptor(); 58 if (self.mode == .reader) return error.WrongAccess; 59 if (self.mode != .appender and bytes.len > limit - self.cursor) return error.InvalidBound; 60 var buffer = c.uv_buf_init(@constCast(bytes.ptr), @intCast(bytes.len)); 61 var req: c.uv_fs_t = undefined; 62 const offset: i64 = if (self.mode == .appender) -1 else @intCast(self.cursor); 63 const rc = c.uv_fs_write(null, &req, fd, &buffer, 1, offset, null); 64 c.uv_fs_req_cleanup(&req); 65 try check(status, rc); 66 if (rc == 0) { 67 status.code = c.UV_EIO; 68 return error.HostFailure; 69 } 70 if (self.mode != .appender) self.cursor += @intCast(rc); 71 return @intCast(rc); 72 } 73 pub fn seek(self: *File, offset: u64) Error!void { 74 if (offset > limit) return error.InvalidBound; 75 _ = try self.descriptor(); 76 if (self.mode == .appender) return error.UnsupportedMode; 77 self.cursor = offset; 78 } 79 pub fn position(self: *const File) Error!u64 { 80 _ = try self.descriptor(); 81 if (self.mode == .appender) return error.UnsupportedMode; 82 return self.cursor; 83 } 84 pub fn size(self: *const File, status: *Status) Error!u64 { 85 status.* = .{}; 86 var req: c.uv_fs_t = undefined; 87 const rc = c.uv_fs_fstat(null, &req, try self.descriptor(), null); 88 defer c.uv_fs_req_cleanup(&req); 89 try check(status, rc); 90 if (req.statbuf.st_size > limit) return error.InvalidBound; 91 return req.statbuf.st_size; 92 } 93 pub fn resize(self: *File, status: *Status, length: u64) Error!void { 94 status.* = .{}; 95 if (length > limit) return error.InvalidBound; 96 const fd = try self.descriptor(); 97 if (self.mode != .writer and self.mode != .update) return error.UnsupportedMode; 98 var req: c.uv_fs_t = undefined; 99 const rc = c.uv_fs_ftruncate(null, &req, fd, @intCast(length), null); 100 c.uv_fs_req_cleanup(&req); 101 try check(status, rc); // Explicit-offset cursor survives resize, even past EOF. 102 } 103 pub fn sync(self: *const File, status: *Status) Error!void { 104 status.* = .{}; 105 var req: c.uv_fs_t = undefined; 106 const rc = c.uv_fs_fsync(null, &req, try self.descriptor(), null); 107 c.uv_fs_req_cleanup(&req); 108 try check(status, rc); 109 } 110 }; 111 fn check(status: *Status, rc: c_int) Error!void { 112 if (rc < 0) { 113 status.code = rc; 114 return error.HostFailure; 115 } 116 } 117 fn regular(mode: u64) bool { 118 return mode & c.S_IFMT == c.S_IFREG; 119 } 120 pub fn open(status: *Status, path: [:0]const u8, mode: Mode) Error!File { 121 return openAfterCheck(status, path, mode, null); 122 } 123 const CheckHook = struct { context: *anyopaque, call: *const fn (*anyopaque) void }; 124 // Private deterministic namespace-race seam; never a source callback. 125 fn openAfterCheck(status: *Status, path: [:0]const u8, mode: Mode, hook: ?CheckHook) Error!File { 126 status.* = .{}; 127 if (path.len == 0 or std.mem.indexOfScalar(u8, path, 0) != null or !std.unicode.utf8ValidateSlice(path)) return error.InvalidArgument; 128 // Preflight avoids opening known devices. It is NOT a security check: the 129 // namespace can change. The descriptor check below is authoritative, and 130 // deliberately precedes truncation. Opening a raced device may have effects. 131 var req: c.uv_fs_t = undefined; 132 const stat_rc = c.uv_fs_stat(null, &req, path.ptr, null); 133 const is_regular = stat_rc >= 0 and regular(req.statbuf.st_mode); 134 c.uv_fs_req_cleanup(&req); 135 if (stat_rc >= 0 and !is_regular) return error.UnsupportedFileType; 136 if (stat_rc < 0 and (stat_rc != c.UV_ENOENT or mode == .reader)) try check(status, stat_rc); 137 if (hook) |h| h.call(h.context); 138 const flags: c_int = c.O_CLOEXEC | c.O_NONBLOCK | c.O_NOCTTY | switch (mode) { 139 .reader => c.O_RDONLY, 140 .writer => c.O_WRONLY | c.O_CREAT, // NO O_TRUNC until fstat accepts the FD. 141 .appender => c.O_WRONLY | c.O_CREAT | c.O_APPEND, 142 .update => c.O_RDWR | c.O_CREAT, 143 }; 144 const fd = c.uv_fs_open(null, &req, path.ptr, flags, 0o666, null); 145 c.uv_fs_req_cleanup(&req); 146 try check(status, fd); 147 var file: File = .{ .fd = fd, .mode = mode }; 148 errdefer { 149 var cleanup: Status = .{}; 150 file.close(&cleanup) catch {}; 151 status.cleanup_code = cleanup.code; 152 } 153 const rc = c.uv_fs_fstat(null, &req, fd, null); 154 const accepted = rc >= 0 and regular(req.statbuf.st_mode); 155 c.uv_fs_req_cleanup(&req); 156 try check(status, rc); 157 if (!accepted) return error.UnsupportedFileType; 158 if (mode == .writer) try file.resize(status, 0); 159 return file; 160 } 161 162 const testing = std.testing; 163 fn testPath(tmp: std.Io.Dir, name: []const u8) ![:0]u8 { 164 const directory = try tmp.realPathFileAlloc(testing.io, ".", testing.allocator); 165 defer testing.allocator.free(directory); 166 return std.fmt.allocPrintSentinel(testing.allocator, "{s}/{s}", .{ directory, name }, 0); 167 } 168 test "regular file modes, bounded transfers and cursor-preserving resize/sync" { 169 var tmp = testing.tmpDir(.{}); 170 defer tmp.cleanup(); 171 const path = try testPath(tmp.dir, "é😀"); 172 defer testing.allocator.free(path); 173 var status: Status = .{}; 174 var file = try open(&status, path, .update); 175 defer file.close(&status) catch {}; 176 try testing.expectEqual(@as(usize, 3), try file.write(&status, "abc")); 177 try file.sync(&status); 178 try testing.expectEqual(@as(u64, 3), try file.position()); 179 try file.seek(1); 180 var bytes: [4097]u8 = undefined; 181 try testing.expectEqual(@as(usize, 2), try file.read(&status, bytes[0..3])); 182 try testing.expectEqualStrings("bc", bytes[0..2]); 183 try testing.expectEqual(@as(usize, 0), try file.read(&status, bytes[0..3])); 184 try file.seek(10); 185 try file.resize(&status, 1); 186 try testing.expectEqual(@as(u64, 10), try file.position()); 187 try testing.expectEqual(@as(u64, 1), try file.size(&status)); 188 try testing.expectError(error.InvalidBound, file.read(&status, &bytes)); 189 try testing.expectError(error.InvalidBound, file.read(&status, bytes[0..0])); 190 try testing.expectError(error.InvalidBound, file.write(&status, &bytes)); 191 try testing.expectError(error.InvalidBound, file.seek(@as(u64, limit) + 1)); 192 try testing.expectError(error.InvalidBound, file.resize(&status, @as(u64, limit) + 1)); 193 try file.close(&status); 194 try testing.expectError(error.Closed, file.close(&status)); 195 try testing.expectError(error.Closed, file.read(&status, bytes[0..1])); 196 var writer = try open(&status, path, .writer); 197 defer writer.close(&status) catch {}; 198 try testing.expectEqual(@as(u64, 0), try writer.size(&status)); 199 try testing.expectError(error.WrongAccess, writer.read(&status, bytes[0..1])); 200 _ = try writer.write(&status, "x"); 201 var appender = try open(&status, path, .appender); 202 defer appender.close(&status) catch {}; 203 _ = try appender.write(&status, "y"); 204 try testing.expectError(error.UnsupportedMode, appender.seek(0)); 205 try testing.expectError(error.UnsupportedMode, appender.position()); 206 try testing.expectError(error.UnsupportedMode, appender.resize(&status, 0)); 207 try testing.expectError(error.WrongAccess, appender.read(&status, bytes[0..1])); 208 var reader = try open(&status, path, .reader); 209 defer reader.close(&status) catch {}; 210 try testing.expectError(error.InvalidBound, reader.write(&status, "")); 211 try testing.expectError(error.UnsupportedMode, reader.resize(&status, 0)); 212 try testing.expectEqual(@as(usize, 2), try reader.read(&status, bytes[0..3])); 213 try testing.expectEqualStrings("xy", bytes[0..2]); 214 try reader.seek(limit); 215 try testing.expectEqual(@as(usize, 0), try reader.read(&status, bytes[0..1])); 216 } 217 test "reject nonregular paths, invalid text and missing readers; symlinks are not confinement" { 218 var tmp = testing.tmpDir(.{}); 219 defer tmp.cleanup(); 220 const fifo = try testPath(tmp.dir, "fifo"); 221 defer testing.allocator.free(fifo); 222 try testing.expectEqual(@as(c_int, 0), c.mkfifo(fifo.ptr, 0o600)); 223 const directory = try testPath(tmp.dir, "."); 224 defer testing.allocator.free(directory); 225 const missing = try testPath(tmp.dir, "missing"); 226 defer testing.allocator.free(missing); 227 var status: Status = .{}; 228 for (std.enums.values(Mode)) |mode| { 229 try testing.expectError(error.UnsupportedFileType, open(&status, fifo, mode)); 230 try testing.expectError(error.UnsupportedFileType, open(&status, directory, mode)); 231 try testing.expectError(error.UnsupportedFileType, open(&status, "/dev/null", mode)); 232 } 233 try testing.expectError(error.HostFailure, open(&status, missing, .reader)); 234 try testing.expectEqual(@as(c_int, c.UV_ENOENT), status.code); 235 try testing.expectError(error.InvalidArgument, open(&status, "", .writer)); 236 try testing.expectError(error.InvalidArgument, open(&status, "x\x00y", .writer)); 237 try testing.expectError(error.InvalidArgument, open(&status, "\xff", .writer)); 238 var file = try open(&status, missing, .writer); 239 defer file.close(&status) catch {}; 240 _ = try file.write(&status, "keep"); 241 const link = try testPath(tmp.dir, "link"); 242 defer testing.allocator.free(link); 243 var req: c.uv_fs_t = undefined; 244 const rc = c.uv_fs_symlink(null, &req, missing.ptr, link.ptr, 0, null); 245 c.uv_fs_req_cleanup(&req); 246 try testing.expectEqual(@as(c_int, 0), rc); 247 var reader = try open(&status, link, .reader); 248 defer reader.close(&status) catch {}; 249 var bytes: [4]u8 = undefined; 250 try testing.expectEqual(@as(usize, 4), try reader.read(&status, &bytes)); 251 try testing.expectEqualStrings("keep", &bytes); 252 try testing.expect(c.fcntl(reader.fd.?, c.F_GETFD) & c.FD_CLOEXEC != 0); 253 } 254 255 // Concrete ownership bridge. Null is reserved for standard placeholders; it is 256 // NOT a standard-stream implementation. Real dispatch must check backend kind 257 // before leasing. No allocator operation follows successful host acquisition. 258 pub const Registry = @import("io_v2_resources.zig").Registry(?*File); 259 pub fn acquire(registry: *Registry, owner: u64, status: *Status, path: [:0]const u8, mode: Mode) !@import("io_v2_resources.zig").Token { 260 return acquireWrapped(registry, owner, status, path, mode, struct { 261 fn wrap(file: *File) ?*File { 262 return file; 263 } 264 }.wrap); 265 } 266 pub fn acquireWrapped(registry: anytype, owner: u64, status: *Status, path: [:0]const u8, mode: Mode, comptime wrap: anytype) !@import("io_v2_resources.zig").Token { 267 status.* = .{}; 268 const admission = try registry.beginAcquire(owner); 269 errdefer registry.abortAcquire(admission) catch unreachable; 270 const backend = try registry.allocator.create(File); 271 errdefer registry.allocator.destroy(backend); 272 backend.* = try open(status, path, mode); 273 errdefer { 274 var cleanup: Status = .{}; 275 backend.close(&cleanup) catch {}; 276 status.cleanup_code = cleanup.code; 277 } 278 return registry.publish(admission, wrap(backend), .{}); 279 } 280 // Call only for an acquired close claim, after all leases have settled. Registry 281 // entry stays present through physical close, even on error. Memory and aliases 282 // are retired exactly once; a close error is reported, never retried implicitly. 283 pub fn release(registry: *Registry, token: @import("io_v2_resources.zig").Token, backend: *File, status: *Status) Error!void { 284 defer registry.finishClose(token) catch unreachable; 285 defer registry.allocator.destroy(backend); 286 try backend.close(status); 287 } 288 289 fn acquisitionFailureProbe(allocator: std.mem.Allocator, path: [:0]const u8) !void { 290 var registry = try Registry.init(allocator, 91, .{ null, null, null }); 291 defer registry.deinit(); 292 var status: Status = .{}; 293 const token = acquire(®istry, 0, &status, path, .reader) catch |err| { 294 try testing.expectEqual(@as(usize, 0), registry.admissions.count()); 295 try testing.expectEqual(@as(usize, 3), registry.resources.count()); 296 return err; 297 }; 298 const claim = (try registry.claimClose(0, token, false)).?; 299 try release(®istry, token, claim.backend.?, &status); 300 try testing.expectEqual(@as(usize, 3), registry.resources.count()); 301 } 302 test "real descriptor admission, lease, handoff, close and allocator failure" { 303 const R = @import("io_v2_resources.zig"); 304 var tmp = testing.tmpDir(.{}); 305 defer tmp.cleanup(); 306 try tmp.dir.writeFile(testing.io, .{ .sub_path = "file", .data = "abc" }); 307 const path = try testPath(tmp.dir, "file"); 308 defer testing.allocator.free(path); 309 try testing.checkAllAllocationFailures(testing.allocator, acquisitionFailureProbe, .{path}); 310 var registry = try Registry.init(testing.allocator, 92, .{ null, null, null }); 311 defer registry.deinit(); 312 var status: Status = .{}; 313 const token = try acquire(®istry, 0, &status, path, .update); 314 const lease = try registry.beginLease(0, token, .exclusive, false); 315 const fd = lease.backend.?.fd.?; 316 try testing.expectError(error.Busy, registry.reserveChild(0, &.{token})); 317 try registry.endLease(lease.token); 318 const transfer = try registry.reserveChild(0, &.{token}); 319 try testing.expectError(error.StaleHandle, registry.lookup(0, token)); 320 try testing.expectError(error.OutOfScope, registry.lookup(0, transfer.tokens[0])); 321 const child_lease = try registry.beginLease(transfer.scope, transfer.tokens[0], .exclusive, false); 322 var bytes: [3]u8 = undefined; 323 _ = try child_lease.backend.?.read(&status, &bytes); 324 try testing.expectEqualStrings("abc", &bytes); 325 registry.beginCloseScope(transfer.scope); 326 try testing.expect((try registry.claimClose(transfer.scope, transfer.tokens[0], true)) == null); 327 try registry.endLease(child_lease.token); 328 const claim = (try registry.claimClose(transfer.scope, transfer.tokens[0], true)).?; 329 try release(®istry, transfer.tokens[0], claim.backend.?, &status); 330 try testing.expectEqual(@as(c_int, -1), c.fcntl(fd, c.F_GETFD)); 331 try registry.finishScope(transfer.scope); 332 // Publication exhaustion can occur AFTER successful open. The descriptor 333 // must be closed and acquisition aborted, not silently left ownerless. 334 registry.next = std.math.maxInt(u64) - 1; 335 try testing.expectError(R.Fault.IdExhausted, acquire(®istry, 0, &status, path, .reader)); 336 try testing.expectEqual(@as(usize, 0), registry.admissions.count()); 337 try testing.expectEqual(@as(usize, 3), registry.resources.count()); 338 } 339 340 pub fn errorCategory(code: c_int) []const u8 { 341 return switch (code) { 342 c.UV_ENOENT => "not-found", 343 c.UV_EACCES, c.UV_EPERM => "os-permission-denied", 344 c.UV_EEXIST => "already-exists", 345 else => "io-error", 346 }; 347 } 348 pub fn errorText(code: c_int) []const u8 { 349 return std.mem.span(c.uv_strerror(code)); 350 } 351 352 test "descriptor kind check rejects a namespace substitution after preflight without truncation" { 353 const Swap = struct { 354 from: [:0]const u8, 355 to: [:0]const u8, 356 fn run(context: *anyopaque) void { 357 const self: *@This() = @ptrCast(@alignCast(context)); 358 var req: c.uv_fs_t = undefined; 359 const rc = c.uv_fs_rename(null, &req, self.from.ptr, self.to.ptr, null); 360 c.uv_fs_req_cleanup(&req); 361 std.debug.assert(rc == 0); 362 } 363 }; 364 for ([_]Mode{ .reader, .writer, .appender, .update }) |mode| { 365 var tmp = testing.tmpDir(.{}); 366 defer tmp.cleanup(); 367 try tmp.dir.writeFile(testing.io, .{ .sub_path = "file", .data = "keep" }); 368 const path = try testPath(tmp.dir, "file"); 369 defer testing.allocator.free(path); 370 const fifo = try testPath(tmp.dir, "fifo"); 371 defer testing.allocator.free(fifo); 372 const saved = try testPath(tmp.dir, "saved"); 373 defer testing.allocator.free(saved); 374 try testing.expectEqual(@as(c_int, 0), c.mkfifo(fifo.ptr, 0o600)); 375 var req: c.uv_fs_t = undefined; 376 const linked = c.uv_fs_link(null, &req, path.ptr, saved.ptr, null); 377 c.uv_fs_req_cleanup(&req); 378 try testing.expectEqual(@as(c_int, 0), linked); 379 // Keep a reader for writer modes, but no writer: opening the reader case 380 // must use NONBLOCK rather than wait for a nonexistent FIFO peer. 381 const anchor = c.uv_fs_open(null, &req, fifo.ptr, c.O_RDONLY | c.O_NONBLOCK | c.O_CLOEXEC, 0, null); 382 c.uv_fs_req_cleanup(&req); 383 try testing.expect(anchor >= 0); 384 defer { 385 _ = c.uv_fs_close(null, &req, anchor, null); 386 c.uv_fs_req_cleanup(&req); 387 } 388 var swap: Swap = .{ .from = fifo, .to = path }; 389 var status: Status = .{}; 390 try testing.expectError(error.UnsupportedFileType, openAfterCheck(&status, path, mode, .{ .context = &swap, .call = Swap.run })); 391 var original = try open(&status, saved, .reader); 392 defer original.close(&status) catch {}; 393 var bytes: [4]u8 = undefined; 394 try testing.expectEqual(@as(usize, 4), try original.read(&status, &bytes)); 395 try testing.expectEqualStrings("keep", &bytes); 396 } 397 }