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 }