lib/sandbox/src/bwrap.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const builtin = @import("builtin");
  3 const sys = @import("sys");
  4 const audit_data = @import("audit.zig");
  5 const change = @import("change.zig");
  6 const command = @import("command.zig");
  7 const cwd_data = @import("cwd.zig");
  8 const layer_data = @import("layer.zig");
  9 const plan_data = @import("plan.zig");
 10 const resolve = @import("resolve.zig");
 11 const result_data = @import("result.zig");
 12 const scan = @import("scan.zig");
 13 
 14 const Allocator = std.mem.Allocator;
 15 const fs_io = sys.fs.debugIo();
 16 const workspace_path = "/workspace";
 17 
 18 pub fn available(allocator: Allocator, scratch: sys.fs.Dir) bool {
 19     if (comptime builtin.os.tag != .linux) return false;
 20     return probe(allocator, scratch) catch false;
 21 }
 22 
 23 fn probe(allocator: Allocator, scratch: sys.fs.Dir) !bool {
 24     var source = try layer_data.create(allocator, .{
 25         .scratch = scratch,
 26         .prefix = "bwrap-probe-source",
 27         .max_file_bytes = 1024,
 28     });
 29     defer source.deinit();
 30 
 31     try source.root.createDirPath(fs_io, "sub");
 32     try source.root.writeFile(fs_io, .{ .sub_path = "sub/input.txt", .data = "input" });
 33     try source.root.writeFile(fs_io, .{ .sub_path = "a.txt", .data = "old" });
 34     try source.root.writeFile(fs_io, .{ .sub_path = "gone.txt", .data = "remove" });
 35 
 36     const host_path = try probeHostPathAlloc(allocator);
 37     defer allocator.free(host_path);
 38     std.Io.Dir.deleteFileAbsolute(fs_io, host_path) catch {};
 39     defer std.Io.Dir.deleteFileAbsolute(fs_io, host_path) catch {};
 40 
 41     const command_text = try std.fmt.allocPrint(
 42         allocator,
 43         "test \"$(pwd)\" = /workspace/sub && cat input.txt > output.txt && printf changed > ../a.txt && printf created > ../new.txt && rm ../gone.txt && printf isolated > {s}",
 44         .{host_path},
 45     );
 46     defer allocator.free(command_text);
 47     const argv = [_][]const u8{ "bash", "-lc", command_text };
 48     var result = execute(allocator, .{
 49         .scratch = scratch,
 50         .source = source.root,
 51         .argv = argv[0..],
 52         .cwd = "sub",
 53         .runner = .bubblewrap_overlay,
 54         .prefix = "bwrap-probe",
 55         .timeout_ms = 2000,
 56         .stdout_limit = 4096,
 57         .stderr_limit = 4096,
 58         .max_file_bytes = 1024,
 59     }) catch return false;
 60     defer result.deinit();
 61     if (result.status.exitCode() != 0) return false;
 62     if (result.audit.runner != .bubblewrap_overlay) return false;
 63     if (result.changes.find("a.txt")) |entry| {
 64         if (entry.operation != .put) return false;
 65     } else return false;
 66     if (result.changes.find("new.txt")) |entry| {
 67         if (entry.operation != .put) return false;
 68     } else return false;
 69     if (result.changes.find("gone.txt")) |entry| {
 70         if (entry.operation != .delete) return false;
 71     } else return false;
 72     if (result.changes.find("sub/output.txt")) |entry| {
 73         if (entry.operation != .put) return false;
 74     } else return false;
 75     if (!try resultFileEquals(allocator, &result, "a.txt", "changed")) return false;
 76     if (!try resultFileEquals(allocator, &result, "sub/output.txt", "input")) return false;
 77     if (!try sourceFileEquals(allocator, source.root, "a.txt", "old")) return false;
 78     if (!try sourceFileEquals(allocator, source.root, "gone.txt", "remove")) return false;
 79     if (source.root.statFile(fs_io, "new.txt", .{})) |_| return false else |err| switch (err) {
 80         error.FileNotFound => {},
 81         else => return err,
 82     }
 83     if (std.Io.Dir.cwd().statFile(fs_io, host_path, .{})) |_| return false else |err| switch (err) {
 84         error.FileNotFound => {},
 85         else => return err,
 86     }
 87     return true;
 88 }
 89 
 90 fn probeHostPathAlloc(allocator: Allocator) Allocator.Error![]u8 {
 91     const pid = sys.process.currentProcessId() catch 0;
 92     const now = sys.time.nanoTimestamp();
 93     return try std.fmt.allocPrint(allocator, "/tmp/tiny-sandbox-bwrap-probe-{x}-{x}", .{ pid, now });
 94 }
 95 
 96 fn resultFileEquals(allocator: Allocator, result: *const result_data.Result, path: []const u8, expected: []const u8) !bool {
 97     const actual = try result.readFileAlloc(allocator, path, expected.len + 1);
 98     defer allocator.free(actual);
 99     return std.mem.eql(u8, actual, expected);
100 }
101 
102 fn sourceFileEquals(allocator: Allocator, source: sys.fs.Dir, path: []const u8, expected: []const u8) !bool {
103     const actual = try source.readFileAlloc(fs_io, path, allocator, .limited(expected.len + 1));
104     defer allocator.free(actual);
105     return std.mem.eql(u8, actual, expected);
106 }
107 
108 pub fn audit(plan: plan_data.Plan) audit_data.Audit {
109     return .{
110         .runner = .bubblewrap_overlay,
111         .filesystem = .overlay,
112         .process = .bubblewrap_namespace,
113         .network = networkAudit(plan),
114         .environment = if (plan.environ_map == null) .inherited else .replaced,
115     };
116 }
117 
118 fn networkAudit(plan: plan_data.Plan) audit_data.Network {
119     if (comptime builtin.os.tag != .linux) return .inherited;
120     if (plan.policy.prefersNetworkIsolation()) return .bubblewrap_namespace;
121     return .inherited;
122 }
123 
124 pub fn execute(allocator: Allocator, plan: plan_data.Plan) !result_data.Result {
125     if (plan.argv.len == 0) return error.EmptyArgv;
126     try cwd_data.validate(plan.cwd);
127 
128     var layer = try layer_data.create(allocator, .{
129         .scratch = plan.scratch,
130         .prefix = plan.prefix,
131         .max_file_bytes = plan.max_file_bytes,
132     });
133     errdefer {
134         repairWorkDir(allocator, plan.scratch, layer.path);
135         layer.deinit();
136     }
137 
138     var container = try plan.scratch.openDir(fs_io, layer.path, .{ .iterate = true });
139     defer container.close(fs_io);
140     try container.createDir(fs_io, "lower", .default_dir);
141     try container.createDir(fs_io, "work", .default_dir);
142 
143     var lower_dir: sys.fs.Dir = if (plan.source) |source| source else try container.openDir(fs_io, "lower", .{ .iterate = true });
144     defer if (plan.source == null) lower_dir.close(fs_io);
145     if (!std.mem.eql(u8, plan.cwd, ".")) {
146         var opened_cwd = try lower_dir.openDir(fs_io, plan.cwd, .{});
147         defer opened_cwd.close(fs_io);
148         try cwd_data.ensureInside(lower_dir, opened_cwd);
149     }
150 
151     var before = try scan.capturePaths(lower_dir, allocator);
152     defer before.deinit();
153 
154     const lower_path = try realPathAlloc(allocator, lower_dir);
155     defer allocator.free(lower_path);
156     const upper_path = try realPathAlloc(allocator, layer.root);
157     defer allocator.free(upper_path);
158     const work_path = try realPathFileAlloc(allocator, container, "work");
159     defer allocator.free(work_path);
160 
161     var bwrap_argv = try buildArgv(allocator, plan, lower_path, upper_path, work_path);
162     defer bwrap_argv.deinit();
163 
164     var executed = try command.run(allocator, .{
165         .argv = bwrap_argv.items.items,
166         .cwd = .inherit,
167         .stdout_limit = plan.stdout_limit,
168         .stderr_limit = plan.stderr_limit,
169         .timeout_ms = plan.timeout_ms,
170         .interrupt = plan.interrupt,
171         .descriptor_policy = .isolated,
172     });
173     errdefer executed.deinit(allocator);
174     repairWorkDir(allocator, plan.scratch, layer.path);
175 
176     var changes = try diffUpper(allocator, &before, layer.root);
177     errdefer changes.deinit();
178 
179     const stdout = executed.stdout;
180     const stderr = executed.stderr;
181     executed.stdout = &.{};
182     executed.stderr = &.{};
183 
184     return .{
185         .allocator = allocator,
186         .layer = layer,
187         .status = .{ .term = executed.term },
188         .stdout = stdout,
189         .stderr = stderr,
190         .changes = changes,
191         .audit = audit(plan),
192     };
193 }
194 
195 const Argv = struct {
196     allocator: Allocator,
197     items: std.ArrayList([]const u8) = .empty,
198     owned: std.ArrayList([]u8) = .empty,
199 
200     fn init(allocator: Allocator) Argv {
201         return .{ .allocator = allocator };
202     }
203 
204     fn deinit(self: *Argv) void {
205         for (self.owned.items) |item| self.allocator.free(item);
206         self.owned.deinit(self.allocator);
207         self.items.deinit(self.allocator);
208         self.* = undefined;
209     }
210 
211     fn append(self: *Argv, value: []const u8) Allocator.Error!void {
212         try self.items.append(self.allocator, value);
213     }
214 
215     fn appendOwned(self: *Argv, value: []u8) Allocator.Error!void {
216         errdefer self.allocator.free(value);
217         try self.owned.append(self.allocator, value);
218         try self.items.append(self.allocator, value);
219     }
220 };
221 
222 fn buildArgv(allocator: Allocator, plan: plan_data.Plan, lower_path: []const u8, upper_path: []const u8, work_path: []const u8) !Argv {
223     var argv = Argv.init(allocator);
224     errdefer argv.deinit();
225 
226     try argv.append("bwrap");
227     try argv.append("--unshare-all");
228     if (networkAudit(plan) == .inherited) try argv.append("--share-net");
229     try argv.append("--die-with-parent");
230     try argv.append("--new-session");
231     try argv.append("--proc");
232     try argv.append("/proc");
233     try argv.append("--dev");
234     try argv.append("/dev");
235     try argv.append("--tmpfs");
236     try argv.append("/tmp");
237     try appendReadOnlyBind(&argv, "/usr");
238     try appendReadOnlyTryBind(&argv, "/bin");
239     try appendReadOnlyTryBind(&argv, "/lib");
240     try appendReadOnlyTryBind(&argv, "/lib64");
241     try appendReadOnlyTryBind(&argv, "/etc");
242     try argv.append("--dir");
243     try argv.append(workspace_path);
244     try argv.append("--overlay-src");
245     try argv.append(lower_path);
246     try argv.append("--overlay");
247     try argv.append(upper_path);
248     try argv.append(work_path);
249     try argv.append(workspace_path);
250     try argv.append("--chdir");
251     if (std.mem.eql(u8, plan.cwd, ".")) {
252         try argv.append(workspace_path);
253     } else {
254         try argv.appendOwned(try std.fmt.allocPrint(allocator, "{s}/{s}", .{ workspace_path, plan.cwd }));
255     }
256     if (plan.environ_map) |env| {
257         try argv.append("--clearenv");
258         var iterator = env.iterator();
259         while (iterator.next()) |entry| {
260             try argv.append("--setenv");
261             try argv.append(entry.key_ptr.*);
262             try argv.append(entry.value_ptr.*);
263         }
264     }
265     try argv.append("--");
266     for (plan.argv) |arg| try argv.append(arg);
267     return argv;
268 }
269 
270 fn appendReadOnlyBind(argv: *Argv, path: []const u8) Allocator.Error!void {
271     try argv.append("--ro-bind");
272     try argv.append(path);
273     try argv.append(path);
274 }
275 
276 fn appendReadOnlyTryBind(argv: *Argv, path: []const u8) Allocator.Error!void {
277     try argv.append("--ro-bind-try");
278     try argv.append(path);
279     try argv.append(path);
280 }
281 
282 fn realPathAlloc(allocator: Allocator, dir: sys.fs.Dir) ![:0]u8 {
283     return try resolve.dirPathAllocZ(allocator, dir);
284 }
285 
286 fn realPathFileAlloc(allocator: Allocator, dir: sys.fs.Dir, path: []const u8) ![:0]u8 {
287     return try dir.realPathFileAlloc(fs_io, path, allocator);
288 }
289 
290 fn diffUpper(allocator: Allocator, lower: *const scan.Snapshot, upper: sys.fs.Dir) !change.Set {
291     var upper_snapshot = try scan.capturePaths(upper, allocator);
292     defer upper_snapshot.deinit();
293     var changes = change.Set.init(allocator);
294     errdefer changes.deinit();
295 
296     for (upper_snapshot.entries.items) |entry| {
297         if (entry.kind == .other and isWhiteout(upper, entry.path)) {
298             try appendDeletes(&changes, lower, entry.path);
299             continue;
300         }
301         if (entry.kind == .directory and lower.find(entry.path) != null) continue;
302         try changes.append(.put, entry);
303     }
304     return changes;
305 }
306 
307 fn isWhiteout(dir: sys.fs.Dir, path: []const u8) bool {
308     const stat = dir.statFile(fs_io, path, .{}) catch return false;
309     return stat.kind == .character_device or stat.kind == .whiteout;
310 }
311 
312 fn appendDeletes(changes: *change.Set, lower: *const scan.Snapshot, path: []const u8) !void {
313     for (lower.entries.items) |entry| {
314         if (!isDeletedPath(path, entry.path)) continue;
315         if (changes.find(entry.path) != null) continue;
316         try changes.append(.delete, entry);
317     }
318 }
319 
320 fn isDeletedPath(path: []const u8, candidate: []const u8) bool {
321     if (std.mem.eql(u8, path, candidate)) return true;
322     if (!std.mem.startsWith(u8, candidate, path)) return false;
323     if (candidate.len <= path.len) return false;
324     return candidate[path.len] == std.fs.path.sep;
325 }
326 
327 fn repairWorkDir(allocator: Allocator, scratch: sys.fs.Dir, layer_path: []const u8) void {
328     const path = std.fmt.allocPrint(allocator, "{s}/work/work", .{layer_path}) catch return;
329     defer allocator.free(path);
330     scratch.setFilePermissions(fs_io, path, .default_dir, .{}) catch {};
331 }
332 
333 fn executeOrSkip(allocator: Allocator, plan: plan_data.Plan) !result_data.Result {
334     return execute(allocator, plan) catch |err| switch (err) {
335         error.FileNotFound => return error.SkipZigTest,
336         else => return err,
337     };
338 }
339 
340 const NetworkWitness = struct {
341     tcp: sys.net.Server,
342     dns: sys.net.Socket,
343 
344     fn init() !NetworkWitness {
345         const loopback = try sys.net.Address.parseIp4("127.0.0.1", 0);
346         var tcp = try loopback.listen(.{
347             .backlog = 4,
348             .close_on_exec = true,
349         });
350         errdefer tcp.deinit();
351 
352         const dns = try sys.net.udpDatagramSocket(.{ .close_on_exec = true });
353         errdefer sys.net.close(dns);
354         try sys.net.bindIp4(dns, sys.net.ip4Address(.{ 127, 0, 0, 1 }, 0));
355         return .{ .tcp = tcp, .dns = dns };
356     }
357 
358     fn deinit(self: *NetworkWitness) void {
359         self.tcp.deinit();
360         sys.net.close(self.dns);
361         self.* = undefined;
362     }
363 
364     fn tcpPort(self: *const NetworkWitness) u16 {
365         return self.tcp.listen_address.getPort();
366     }
367 
368     fn dnsPort(self: *const NetworkWitness) !u16 {
369         return try sys.net.socketPort(self.dns);
370     }
371 
372     fn expectTraffic(self: *NetworkWitness) !void {
373         try std.testing.expect(try sys.net.pollReadable(self.tcp.socket, 1000));
374         var accepted = try self.tcp.accept();
375         defer accepted.stream.close();
376         var connection: [1]u8 = undefined;
377         try std.testing.expectEqual(@as(usize, 1), try accepted.stream.read(&connection));
378         try std.testing.expectEqual(@as(u8, 'C'), connection[0]);
379 
380         try std.testing.expect(try sys.net.pollReadable(self.dns, 1000));
381         var query: [512]u8 = undefined;
382         const received = try sys.net.recvFromIpAddress(self.dns, &query, 0);
383         try std.testing.expect(received.bytes >= 12);
384         try std.testing.expectEqualSlices(u8, &.{ 0x12, 0x34 }, query[0..2]);
385     }
386 
387     fn expectNoTraffic(self: *const NetworkWitness, timeout_ms: i32) !void {
388         try std.testing.expect(!try sys.net.pollReadable(self.tcp.socket, timeout_ms));
389         try std.testing.expect(!try sys.net.pollReadable(self.dns, timeout_ms));
390     }
391 };
392 
393 fn networkProbeCommandAlloc(
394     allocator: Allocator,
395     witness: *const NetworkWitness,
396 ) ![]u8 {
397     return try std.fmt.allocPrint(allocator,
398         \\tcp=0
399         \\exec 3<>/dev/tcp/127.0.0.1/{d} || tcp=$?
400         \\if test "$tcp" -eq 0; then printf C >&3; fi
401         \\dns=0
402         \\dns_query='\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00'
403         \\printf "$dns_query" > /dev/udp/127.0.0.1/{d} || dns=$?
404         \\printf ran > ran.txt
405         \\test "$tcp" -eq 0 && test "$dns" -eq 0
406     , .{ witness.tcpPort(), try witness.dnsPort() });
407 }
408 
409 fn descendantCommandAlloc(allocator: Allocator, port: u16) ![]u8 {
410     return try std.fmt.allocPrint(allocator,
411         \\exec 3<>/dev/tcp/127.0.0.1/{d}
412         \\printf R >&3
413         \\exec 3>&-
414         \\exec 1>&- 2>&-
415         \\/usr/bin/setsid bash -c '
416         \\trap "" TERM HUP
417         \\exec 4<>/dev/tcp/127.0.0.1/{d}
418         \\printf D >&4
419         \\IFS= read -r -n 1 gate <&4
420         \\' &
421         \\wait
422     , .{ port, port });
423 }
424 
425 fn expectTcpByte(server: *sys.net.Server, expected: u8) !void {
426     try std.testing.expect(try sys.net.pollReadable(server.socket, 1500));
427     var accepted = try server.accept();
428     defer accepted.stream.close();
429     var bytes: [1]u8 = undefined;
430     try std.testing.expectEqual(@as(usize, 1), try accepted.stream.read(&bytes));
431     try std.testing.expectEqual(expected, bytes[0]);
432 }
433 
434 fn expectArgv(expected: []const []const u8, actual: []const []const u8) !void {
435     std.debug.assert(expected.len <= 64);
436     std.debug.assert(actual.len <= 64);
437     try std.testing.expectEqual(expected.len, actual.len);
438     for (expected, actual) |expected_arg, actual_arg| {
439         try std.testing.expectEqualStrings(expected_arg, actual_arg);
440     }
441 }
442 
443 const inherited_argv = [_][]const u8{
444     "bwrap",         "--unshare-all", "--share-net",   "--die-with-parent",
445     "--new-session", "--proc",        "/proc",         "--dev",
446     "/dev",          "--tmpfs",       "/tmp",          "--ro-bind",
447     "/usr",          "/usr",          "--ro-bind-try", "/bin",
448     "/bin",          "--ro-bind-try", "/lib",          "/lib",
449     "--ro-bind-try", "/lib64",        "/lib64",        "--ro-bind-try",
450     "/etc",          "/etc",          "--dir",         "/workspace",
451     "--overlay-src", "lower",         "--overlay",     "upper",
452     "work",          "/workspace",    "--chdir",       "/workspace",
453     "--",            "bash",          "-c",            ":",
454 };
455 
456 const isolated_argv = [_][]const u8{
457     "bwrap",         "--unshare-all", "--die-with-parent", "--new-session",
458     "--proc",        "/proc",         "--dev",             "/dev",
459     "--tmpfs",       "/tmp",          "--ro-bind",         "/usr",
460     "/usr",          "--ro-bind-try", "/bin",              "/bin",
461     "--ro-bind-try", "/lib",          "/lib",              "--ro-bind-try",
462     "/lib64",        "/lib64",        "--ro-bind-try",     "/etc",
463     "/etc",          "--dir",         "/workspace",        "--overlay-src",
464     "lower",         "--overlay",     "upper",             "work",
465     "/workspace",    "--chdir",       "/workspace",        "--",
466     "bash",          "-c",            ":",
467 };
468 
469 test "bubblewrap availability probe exercises overlay cwd and namespace behavior" {
470     if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
471 
472     var scratch = std.testing.tmpDir(.{});
473     defer scratch.cleanup();
474 
475     if (!available(std.testing.allocator, scratch.dir)) return error.SkipZigTest;
476 }
477 
478 test "bubblewrap network policy has exact argv and audit" {
479     if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
480 
481     var scratch = std.testing.tmpDir(.{});
482     defer scratch.cleanup();
483     const command_argv = [_][]const u8{ "bash", "-c", ":" };
484     const base_plan = plan_data.Plan{
485         .scratch = scratch.dir,
486         .argv = &command_argv,
487         .runner = .bubblewrap_overlay,
488     };
489 
490     var inherited = try buildArgv(std.testing.allocator, base_plan, "lower", "upper", "work");
491     defer inherited.deinit();
492     try expectArgv(&inherited_argv, inherited.items.items);
493     try std.testing.expectEqual(audit_data.Network.inherited, audit(base_plan).network);
494 
495     var required_plan = base_plan;
496     required_plan.policy.network = .require_isolated;
497     var required = try buildArgv(std.testing.allocator, required_plan, "lower", "upper", "work");
498     defer required.deinit();
499     try expectArgv(&isolated_argv, required.items.items);
500     try std.testing.expectEqual(
501         audit_data.Network.bubblewrap_namespace,
502         audit(required_plan).network,
503     );
504 
505     var preferred_plan = base_plan;
506     preferred_plan.policy.network = .prefer_isolated;
507     var preferred = try buildArgv(std.testing.allocator, preferred_plan, "lower", "upper", "work");
508     defer preferred.deinit();
509     try expectArgv(&isolated_argv, preferred.items.items);
510 }
511 
512 test "bubblewrap required network isolation blocks host connections and DNS" {
513     if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
514 
515     var scratch = std.testing.tmpDir(.{});
516     defer scratch.cleanup();
517     if (!available(std.testing.allocator, scratch.dir)) return error.SkipZigTest;
518 
519     var witness = NetworkWitness.init() catch |err| switch (err) {
520         error.UnsupportedPlatform => return error.SkipZigTest,
521         else => return err,
522     };
523     defer witness.deinit();
524     const command_text = try networkProbeCommandAlloc(std.testing.allocator, &witness);
525     defer std.testing.allocator.free(command_text);
526     const command_argv = [_][]const u8{ "bash", "-c", command_text };
527 
528     {
529         var inherited = try execute(std.testing.allocator, .{
530             .scratch = scratch.dir,
531             .argv = &command_argv,
532             .runner = .bubblewrap_overlay,
533             .prefix = "bwrap-network-inherited",
534             .max_file_bytes = 1024,
535         });
536         defer inherited.deinit();
537         try std.testing.expectEqual(@as(i64, 0), inherited.status.exitCode().?);
538         try std.testing.expectEqual(audit_data.Network.inherited, inherited.audit.network);
539         try std.testing.expect(try resultFileEquals(
540             std.testing.allocator,
541             &inherited,
542             "ran.txt",
543             "ran",
544         ));
545         try witness.expectTraffic();
546     }
547 
548     var isolated = try execute(std.testing.allocator, .{
549         .scratch = scratch.dir,
550         .argv = &command_argv,
551         .runner = .bubblewrap_overlay,
552         .policy = .{ .network = .require_isolated },
553         .prefix = "bwrap-network-isolated",
554         .max_file_bytes = 1024,
555     });
556     defer isolated.deinit();
557     try std.testing.expect(isolated.status.exitCode().? != 0);
558     try std.testing.expectEqual(
559         audit_data.Network.bubblewrap_namespace,
560         isolated.audit.network,
561     );
562     try std.testing.expect(try resultFileEquals(
563         std.testing.allocator,
564         &isolated,
565         "ran.txt",
566         "ran",
567     ));
568     try witness.expectNoTraffic(100);
569 }
570 
571 test "bubblewrap timeout removes a resistant setsid descendant and disposable state" {
572     if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
573     if (!sys.fs.absolutePathExists("/usr/bin/setsid")) return error.SkipZigTest;
574 
575     var scratch = std.testing.tmpDir(.{});
576     defer scratch.cleanup();
577     if (!available(std.testing.allocator, scratch.dir)) return error.SkipZigTest;
578 
579     const loopback = try sys.net.Address.parseIp4("127.0.0.1", 0);
580     var listener = try loopback.listen(.{
581         .backlog = 2,
582         .close_on_exec = true,
583     });
584     defer listener.deinit();
585     const command_text = try descendantCommandAlloc(
586         std.testing.allocator,
587         listener.listen_address.getPort(),
588     );
589     defer std.testing.allocator.free(command_text);
590     const command_argv = [_][]const u8{ "bash", "-c", command_text };
591 
592     try std.testing.expectError(error.Timeout, execute(std.testing.allocator, .{
593         .scratch = scratch.dir,
594         .argv = &command_argv,
595         .runner = .bubblewrap_overlay,
596         .prefix = "bwrap-descendant-cleanup",
597         .timeout_ms = 1000,
598         .max_file_bytes = 1024,
599     }));
600     try expectTcpByte(&listener, 'R');
601     try std.testing.expect(try sys.net.pollReadable(listener.socket, 1500));
602     var descendant = try listener.accept();
603     defer descendant.stream.close();
604     var byte: [1]u8 = undefined;
605     try std.testing.expectEqual(@as(usize, 1), try descendant.stream.read(&byte));
606     try std.testing.expectEqual(@as(u8, 'D'), byte[0]);
607     try std.testing.expect(try sys.net.pollReadable(descendant.stream.handle, 1500));
608     try std.testing.expectEqual(@as(usize, 0), try descendant.stream.read(&byte));
609 
610     var after = try scan.capturePaths(scratch.dir, std.testing.allocator);
611     defer after.deinit();
612     try std.testing.expectEqual(@as(usize, 0), after.entries.items.len);
613 }
614 
615 test "bubblewrap overlay records puts deletes and preserves source" {
616     var source = std.testing.tmpDir(.{});
617     defer source.cleanup();
618     var scratch = std.testing.tmpDir(.{});
619     defer scratch.cleanup();
620 
621     try source.dir.writeFile(fs_io, .{ .sub_path = "a.txt", .data = "old" });
622     try source.dir.writeFile(fs_io, .{ .sub_path = "gone.txt", .data = "remove" });
623 
624     const argv = [_][]const u8{ "bash", "-c", "printf changed > a.txt; rm gone.txt; printf new > new.txt" };
625     var result = try executeOrSkip(std.testing.allocator, .{
626         .scratch = scratch.dir,
627         .source = source.dir,
628         .argv = argv[0..],
629         .runner = .bubblewrap_overlay,
630         .prefix = "bwrap-test",
631         .max_file_bytes = 1024,
632     });
633     defer result.deinit();
634 
635     try std.testing.expectEqual(audit_data.Runner.bubblewrap_overlay, result.audit.runner);
636     try std.testing.expectEqual(change.Operation.put, result.changes.find("a.txt").?.operation);
637     try std.testing.expectEqual(change.Operation.put, result.changes.find("new.txt").?.operation);
638     try std.testing.expectEqual(change.Operation.delete, result.changes.find("gone.txt").?.operation);
639 
640     const changed = try result.readFileAlloc(std.testing.allocator, "a.txt", 1024);
641     defer std.testing.allocator.free(changed);
642     try std.testing.expectEqualStrings("changed", changed);
643 
644     const original = try source.dir.readFileAlloc(fs_io, "a.txt", std.testing.allocator, .limited(1024));
645     defer std.testing.allocator.free(original);
646     try std.testing.expectEqualStrings("old", original);
647 }
648 
649 test "bubblewrap overlay tolerates lower files larger than the byte cap" {
650     var source = std.testing.tmpDir(.{});
651     defer source.cleanup();
652     var scratch = std.testing.tmpDir(.{});
653     defer scratch.cleanup();
654 
655     try source.dir.writeFile(fs_io, .{ .sub_path = "huge.bin", .data = "0123456789abcdef0123456789abcdef" });
656     try source.dir.writeFile(fs_io, .{ .sub_path = "a.txt", .data = "old" });
657 
658     const argv = [_][]const u8{ "bash", "-c", "printf changed > a.txt" };
659     var result = try executeOrSkip(std.testing.allocator, .{
660         .scratch = scratch.dir,
661         .source = source.dir,
662         .argv = argv[0..],
663         .runner = .bubblewrap_overlay,
664         .prefix = "bwrap-oversize-lower",
665         .max_file_bytes = 16,
666     });
667     defer result.deinit();
668 
669     try std.testing.expectEqual(@as(i64, 0), result.status.exitCode().?);
670     try std.testing.expectEqual(change.Operation.put, result.changes.find("a.txt").?.operation);
671     try std.testing.expect(result.changes.find("huge.bin") == null);
672 
673     const changed = try result.readFileAlloc(std.testing.allocator, "a.txt", 1024);
674     defer std.testing.allocator.free(changed);
675     try std.testing.expectEqualStrings("changed", changed);
676 }
677 
678 test "bubblewrap overlay records generated files larger than the byte cap" {
679     var source = std.testing.tmpDir(.{});
680     defer source.cleanup();
681     var scratch = std.testing.tmpDir(.{});
682     defer scratch.cleanup();
683 
684     try source.dir.writeFile(fs_io, .{ .sub_path = "seed.txt", .data = "seed" });
685 
686     const argv = [_][]const u8{ "bash", "-c", "printf '0123456789abcdef0123456789abcdef' > big.out" };
687     var result = try executeOrSkip(std.testing.allocator, .{
688         .scratch = scratch.dir,
689         .source = source.dir,
690         .argv = argv[0..],
691         .runner = .bubblewrap_overlay,
692         .prefix = "bwrap-oversize-generated",
693         .max_file_bytes = 8,
694     });
695     defer result.deinit();
696 
697     try std.testing.expectEqual(@as(i64, 0), result.status.exitCode().?);
698     try std.testing.expectEqual(change.Operation.put, result.changes.find("big.out").?.operation);
699 
700     const generated = try result.readFileAlloc(std.testing.allocator, "big.out", 1024);
701     defer std.testing.allocator.free(generated);
702     try std.testing.expectEqualStrings("0123456789abcdef0123456789abcdef", generated);
703 }
704 
705 test "bubblewrap overlay keeps absolute tmp writes out of the host" {
706     var scratch = std.testing.tmpDir(.{});
707     defer scratch.cleanup();
708 
709     const host_path = "/tmp/tiny-sandbox-bwrap-host-write";
710     std.Io.Dir.deleteFileAbsolute(fs_io, host_path) catch {};
711     defer std.Io.Dir.deleteFileAbsolute(fs_io, host_path) catch {};
712 
713     const argv = [_][]const u8{ "bash", "-c", "printf isolated > /tmp/tiny-sandbox-bwrap-host-write" };
714     var result = try executeOrSkip(std.testing.allocator, .{
715         .scratch = scratch.dir,
716         .argv = argv[0..],
717         .runner = .bubblewrap_overlay,
718         .prefix = "bwrap-host-write",
719         .max_file_bytes = 1024,
720     });
721     defer result.deinit();
722 
723     try std.testing.expectEqual(@as(i64, 0), result.status.exitCode().?);
724     try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().statFile(fs_io, host_path, .{}));
725 }
726 
727 test "bubblewrap overlay expands directory whiteout deletes" {
728     var source = std.testing.tmpDir(.{});
729     defer source.cleanup();
730     var scratch = std.testing.tmpDir(.{});
731     defer scratch.cleanup();
732 
733     try source.dir.createDirPath(fs_io, "dir");
734     try source.dir.writeFile(fs_io, .{ .sub_path = "dir/nested.txt", .data = "nested" });
735 
736     const argv = [_][]const u8{ "bash", "-c", "rm -r dir" };
737     var result = try executeOrSkip(std.testing.allocator, .{
738         .scratch = scratch.dir,
739         .source = source.dir,
740         .argv = argv[0..],
741         .runner = .bubblewrap_overlay,
742         .prefix = "bwrap-dir-delete",
743         .max_file_bytes = 1024,
744     });
745     defer result.deinit();
746 
747     try std.testing.expectEqual(change.Operation.delete, result.changes.find("dir").?.operation);
748     try std.testing.expectEqual(change.Operation.delete, result.changes.find("dir/nested.txt").?.operation);
749 }