diff --git a/src/runtime/effects.zig b/src/runtime/effects.zig index ce420b003..3f24783c5 100644 --- a/src/runtime/effects.zig +++ b/src/runtime/effects.zig @@ -7044,11 +7044,14 @@ pub fn Effects(comptime Msg: type) type { /// `max_effects` slots and the key space with spawns and fetches. pub fn writeFile(self: *Self, options: WriteFileOptions) void { if (options.path.len == 0 or options.path.len > max_effect_file_path_bytes or - options.bytes.len > max_effect_file_bytes) + options.bytes.len > max_effect_file_bytes or pathEndsWithSeparator(options.path)) { return self.rejectFile(options.key, .write, options.on_result); } - var access = self.resolvedFileAccess(options.path, .{ .create_parents = true }) orelse return self.rejectFileExternal(options.key, .write, options.on_result); + var access = self.resolvedFileAccess(options.path, .{ + .create_parents = true, + .preserve_final_component = true, + }) orelse return self.rejectFileExternal(options.key, .write, options.on_result); self.startFile(options.key, .write, &access, options.bytes, options.on_result); } @@ -16413,6 +16416,59 @@ pub fn Effects(comptime Msg: type) type { ctx.done.store(true, .release); } + const OpenedFileParent = struct { + dir: std.Io.Dir, + basename: []const u8, + }; + + fn openOrCreateFileParent(io: std.Io, file_path: []const u8) !OpenedFileParent { + const parent_path = std.fs.path.dirname(file_path) orelse "."; + const parent = std.Io.Dir.cwd().openDir(io, parent_path, .{ .follow_symlinks = true }) catch |err| switch (err) { + error.FileNotFound => create: { + try std.Io.Dir.cwd().createDirPath(io, parent_path); + // A newly created final component must still be a directory, + // not a symlink substituted between creation and opening. + break :create try std.Io.Dir.cwd().openDir(io, parent_path, .{ .follow_symlinks = false }); + }, + else => return err, + }; + return .{ .dir = parent, .basename = std.fs.path.basename(file_path) }; + } + + fn pathEndsWithSeparator(path: []const u8) bool { + const last = path[path.len - 1]; + return last == '/' or (builtin.os.tag == .windows and last == '\\'); + } + + fn writeThroughOpenedParent(dir: std.Io.Dir, io: std.Io, basename: []const u8, bytes: []const u8) !void { + const existing = dir.openFile(io, basename, .{ + .mode = .write_only, + .allow_directory = false, + .follow_symlinks = false, + }) catch |err| switch (err) { + error.FileNotFound, error.SymLinkLoop => null, + else => return err, + }; + if (existing) |file| { + const stat = file.stat(io) catch |err| { + file.close(io); + return err; + }; + if (stat.kind != .file and stat.kind != .sym_link) { + defer file.close(io); + try file.writeStreamingAll(io, bytes); + return; + } + file.close(io); + } + + var atomic = try dir.createFileAtomic(io, basename, .{ .replace = true }); + defer atomic.deinit(io); + try atomic.file.writePositionalAll(io, bytes, 0); + try atomic.file.sync(io); + try atomic.replace(io); + } + fn runFileOp(ctx: *FileWorkerContext, io: std.Io) EffectFileOutcome { if (ctx.missing) return if (ctx.op == .stat) .ok else .not_found; const dir = ctx.parent orelse std.Io.Dir.cwd(); @@ -16420,10 +16476,9 @@ pub fn Effects(comptime Msg: type) type { switch (ctx.op) { .write => { if (ctx.parent == null) { - if (std.fs.path.dirname(file_path)) |parent| { - dir.createDirPath(io, parent) catch |err| return fileOpFailure(err); - } - dir.writeFile(io, .{ .sub_path = file_path, .data = ctx.payload() }) catch |err| return fileOpFailure(err); + var opened_parent = openOrCreateFileParent(io, file_path) catch |err| return fileOpFailure(err); + defer opened_parent.dir.close(io); + writeThroughOpenedParent(opened_parent.dir, io, opened_parent.basename, ctx.payload()) catch |err| return fileOpFailure(err); return .ok; } var atomic = dir.createFileAtomic(io, file_path, .{ .make_path = ctx.parent == null, .replace = true }) catch |err| return fileOpFailure(err); diff --git a/src/runtime/effects_file_tests.zig b/src/runtime/effects_file_tests.zig index 6f7e36aa4..b1acd5b6e 100644 --- a/src/runtime/effects_file_tests.zig +++ b/src/runtime/effects_file_tests.zig @@ -442,6 +442,142 @@ test "real executor writes a file (creating parent dirs) and reads it back" { } } +test "real executor writes through an existing symlinked parent" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDir(io, "target", .default_dir); + try tmp.dir.symLink(io, "target", "alias", .{ .is_directory = true }); + + var h = try Harness.create(); + defer h.destroy(); + var path_buffer: [256]u8 = undefined; + test_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/alias/session.json", .{tmp.sub_path[0..]}); + test_bytes = "through parent link"; + try h.app_state.dispatch(&h.harness.runtime, 1, .save); + try waitForRealResult(&h, 1); + + try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, h.app_state.model.last_outcome.?); + const on_disk = try tmp.dir.readFileAlloc(io, "target/session.json", std.testing.allocator, .limited(64)); + defer std.testing.allocator.free(on_disk); + try std.testing.expectEqualStrings("through parent link", on_disk); +} + +test "real executor creates missing descendants below a symlinked parent" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDir(io, "target", .default_dir); + try tmp.dir.symLink(io, "target", "alias", .{ .is_directory = true }); + + var h = try Harness.create(); + defer h.destroy(); + var path_buffer: [256]u8 = undefined; + test_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/alias/missing/nested/session.json", .{tmp.sub_path[0..]}); + test_bytes = "nested through parent link"; + try h.app_state.dispatch(&h.harness.runtime, 1, .save); + try waitForRealResult(&h, 1); + + try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, h.app_state.model.last_outcome.?); + const on_disk = try tmp.dir.readFileAlloc(io, "target/missing/nested/session.json", std.testing.allocator, .limited(64)); + defer std.testing.allocator.free(on_disk); + try std.testing.expectEqualStrings("nested through parent link", on_disk); +} + +test "real executor writes an absolute path" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var absolute_storage: [std.Io.Dir.max_path_bytes]u8 = undefined; + const absolute_len = try tmp.dir.realPath(io, &absolute_storage); + + var h = try Harness.create(); + defer h.destroy(); + var path_buffer: [std.Io.Dir.max_path_bytes]u8 = undefined; + test_path = try std.fmt.bufPrint(&path_buffer, "{s}/absolute.json", .{absolute_storage[0..absolute_len]}); + test_bytes = "absolute"; + try h.app_state.dispatch(&h.harness.runtime, 1, .save); + try waitForRealResult(&h, 1); + + try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, h.app_state.model.last_outcome.?); + const on_disk = try tmp.dir.readFileAlloc(io, "absolute.json", std.testing.allocator, .limited(64)); + defer std.testing.allocator.free(on_disk); + try std.testing.expectEqualStrings("absolute", on_disk); +} + +test "real executor rejects write paths with trailing separators" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var h = try Harness.create(); + defer h.destroy(); + + var path_buffer: [256]u8 = undefined; + test_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/destination/", .{tmp.sub_path[0..]}); + test_bytes = "not written"; + try h.app_state.dispatch(&h.harness.runtime, 1, .save); + try waitForRealResult(&h, 1); + + try std.testing.expectEqual(effects_mod.EffectFileOutcome.rejected, h.app_state.model.last_outcome.?); +} + +test "real executor replaces a final symlink without overwriting its target" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(io, .{ .sub_path = "target.bin", .data = "keep me" }); + try tmp.dir.symLink(io, "target.bin", "alias.bin", .{}); + + var h = try Harness.create(); + defer h.destroy(); + var path_buffer: [256]u8 = undefined; + test_path = try std.fmt.bufPrint(&path_buffer, ".zig-cache/tmp/{s}/alias.bin", .{tmp.sub_path[0..]}); + test_bytes = "replacement"; + try h.app_state.dispatch(&h.harness.runtime, 1, .save); + try waitForRealResult(&h, 1); + + try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, h.app_state.model.last_outcome.?); + const target = try tmp.dir.readFileAlloc(io, "target.bin", std.testing.allocator, .limited(64)); + defer std.testing.allocator.free(target); + try std.testing.expectEqualStrings("keep me", target); + const replacement_stat = try tmp.dir.statFile(io, "alias.bin", .{ .follow_symlinks = false }); + try std.testing.expectEqual(std.Io.File.Kind.file, replacement_stat.kind); +} + +test "bound real executor replaces a final symlink without overwriting its target" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.createDir(io, "app-data", .default_dir); + try tmp.dir.writeFile(io, .{ .sub_path = "app-data/target.bin", .data = "keep me" }); + try tmp.dir.symLink(io, "target.bin", "app-data/alias.bin", .{}); + + var root_buffer: [256]u8 = undefined; + const root = try std.fmt.bufPrint(&root_buffer, ".zig-cache/tmp/{s}/app-data", .{tmp.sub_path[0..]}); + var path_buffer: [256]u8 = undefined; + const alias = try std.fmt.bufPrint(&path_buffer, "{s}/alias.bin", .{root}); + var h = try Harness.create(); + defer h.destroy(); + h.app_state.effects.bindFileAccess(.{ .roots = &.{root}, .permitted = false, .enforce = true }); + test_path = alias; + test_bytes = "replacement"; + try h.app_state.dispatch(&h.harness.runtime, 1, .save); + try waitForRealResult(&h, 1); + + try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, h.app_state.model.last_outcome.?); + const target = try tmp.dir.readFileAlloc(io, "app-data/target.bin", std.testing.allocator, .limited(64)); + defer std.testing.allocator.free(target); + try std.testing.expectEqualStrings("keep me", target); + const replacement = try tmp.dir.readFileAlloc(io, "app-data/alias.bin", std.testing.allocator, .limited(64)); + defer std.testing.allocator.free(replacement); + try std.testing.expectEqualStrings("replacement", replacement); + const replacement_stat = try tmp.dir.statFile(io, "app-data/alias.bin", .{ .follow_symlinks = false }); + try std.testing.expectEqual(std.Io.File.Kind.file, replacement_stat.kind); +} + test "real executor reports missing files as not_found" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -1041,6 +1177,64 @@ fn makeFifo(path: []const u8) !void { try std.testing.expect(result.term == .exited and result.term.exited == 0); } +const FifoRead = struct { + path: []const u8, + bytes: [64]u8 = undefined, + len: usize = 0, + err: ?anyerror = null, + + fn run(self: *FifoRead) void { + const io = std.testing.io; + var file = std.Io.Dir.cwd().openFile(io, self.path, .{}) catch |err| { + self.err = err; + return; + }; + defer file.close(io); + while (self.len < self.bytes.len) { + var slices: [1][]u8 = .{self.bytes[self.len..]}; + const len = file.readStreaming(io, &slices) catch |err| switch (err) { + error.EndOfStream => return, + else => { + self.err = err; + return; + }, + }; + if (len == 0) return; + self.len += len; + } + } +}; + +test "real executor streams to the exact no-follow-opened FIFO handle" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var fifo_path_buffer: [256]u8 = undefined; + const fifo_path = try std.fmt.bufPrint(&fifo_path_buffer, ".zig-cache/tmp/{s}/delivery.fifo", .{tmp.sub_path[0..]}); + try makeFifo(fifo_path); + + var read: FifoRead = .{ .path = fifo_path }; + const reader = try std.Thread.spawn(.{}, FifoRead.run, .{&read}); + var reader_joined = false; + defer if (!reader_joined) reader.join(); + + var h = try Harness.create(); + defer h.destroy(); + test_path = fifo_path; + test_bytes = "fifo payload"; + try h.app_state.dispatch(&h.harness.runtime, 1, .save); + try waitForRealResult(&h, 1); + reader.join(); + reader_joined = true; + + try std.testing.expect(read.err == null); + try std.testing.expectEqual(effects_mod.EffectFileOutcome.ok, h.app_state.model.last_outcome.?); + try std.testing.expectEqualStrings("fifo payload", read.bytes[0..read.len]); + const stat = try tmp.dir.statFile(io, "delivery.fifo", .{ .follow_symlinks = false }); + try std.testing.expectEqual(std.Io.File.Kind.named_pipe, stat.kind); +} + test "teardown abandons a file worker stuck on a reader-less FIFO and leaks its world loudly" { if (builtin.os.tag == .windows) return error.SkipZigTest; const io = std.testing.io;