lib/sandbox/src/run.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 bwrap = @import("bwrap.zig");
6 const copied = @import("copied.zig");
7 const change = @import("change.zig");
8 const macos = @import("macos.zig");
9 const plan_data = @import("plan.zig");
10 const result_data = @import("result.zig");
11 const windows = @import("windows.zig");
12
13 const Allocator = std.mem.Allocator;
14 const fs_io = sys.fs.debugIo();
15
16 pub const Status = @import("command.zig").Status;
17 pub const Result = result_data.Result;
18
19 /// Executes a run plan within a disposable layer directory, returning a
20 /// `Result` that owns captured standard streams, the detected change set, an
21 /// `Audit` record, and the layer directory handle.
22 ///
23 /// Before launching the child process, `execute` checks the selected runner
24 /// capabilities against the policy requested in `plan`, returning an error if a
25 /// required setting is unsupported. A child process that exits with a nonzero
26 /// status code produces a successful `Result` carrying that status. The error
27 /// union instead covers refused policies along with host setup, execution, and
28 /// data collection failures.
29 ///
30 /// Callers inspect changes to the layer through the change set. Each entry can
31 /// describe a file, directory, or symbolic link, recording either a path to
32 /// create or replace using the `put` operation or a path to remove using the
33 /// `delete` operation. The library reads the source to prepare the layer and
34 /// reports changes, but it does not automatically apply changes back to the
35 /// source. An unconfined child may independently write external paths on the
36 /// host.
37 pub fn execute(allocator: Allocator, plan: plan_data.Plan) !Result {
38 try plan.policy.validate(expectedAudit(plan));
39 return switch (plan.runner) {
40 .staging => executeStaging(allocator, plan),
41 .bubblewrap_overlay => bwrap.execute(allocator, plan),
42 .macos_staging => macos.execute(allocator, plan),
43 .windows_staging => windows.execute(allocator, plan),
44 };
45 }
46
47 fn expectedAudit(plan: plan_data.Plan) audit_data.Audit {
48 return switch (plan.runner) {
49 .staging => stagingAudit(plan),
50 .bubblewrap_overlay => bwrap.audit(plan),
51 .macos_staging => macos.audit(plan),
52 .windows_staging => windows.audit(plan),
53 };
54 }
55
56 fn stagingAudit(plan: plan_data.Plan) audit_data.Audit {
57 return .{ .environment = if (plan.environ_map == null) .inherited else .replaced };
58 }
59
60 fn executeStaging(allocator: Allocator, plan: plan_data.Plan) !Result {
61 return try copied.execute(allocator, plan, stagingAudit(plan));
62 }
63
64 fn scratchEntryCount(dir: sys.fs.Dir, allocator: Allocator) !usize {
65 var opened = try dir.openDir(fs_io, ".", .{ .iterate = true });
66 defer opened.close(fs_io);
67 var walker = try opened.walk(allocator);
68 defer walker.deinit();
69 var count: usize = 0;
70 while (try walker.next(fs_io)) |_| count += 1;
71 return count;
72 }
73
74 fn expectSocketByte(socket: sys.net.Socket, expected: u8) !void {
75 try std.testing.expect(try sys.net.pollReadable(socket, 1000));
76 var byte: [1]u8 = undefined;
77 try std.testing.expectEqual(
78 @as(usize, 1),
79 try sys.net.recv(socket, &byte, 0),
80 );
81 try std.testing.expectEqual(expected, byte[0]);
82 }
83
84 const ShellCommand = struct {
85 argv: [3][]const u8,
86
87 fn slice(self: *const ShellCommand) []const []const u8 {
88 return self.argv[0..];
89 }
90 };
91
92 fn hostShellCommand(posix: []const u8, windows_command: []const u8) ShellCommand {
93 if (comptime builtin.os.tag == .windows) return .{ .argv = .{ "cmd.exe", "/C", windows_command } };
94 return .{ .argv = .{ "sh", "-c", posix } };
95 }
96
97 test "run executes in disposable layer and records file changes" {
98 var source = std.testing.tmpDir(.{});
99 defer source.cleanup();
100 var scratch = std.testing.tmpDir(.{});
101 defer scratch.cleanup();
102
103 try source.dir.writeFile(fs_io, .{ .sub_path = "a.txt", .data = "old" });
104 try source.dir.writeFile(fs_io, .{ .sub_path = "gone.txt", .data = "remove" });
105
106 const command = hostShellCommand(
107 "printf hello; printf changed > a.txt; printf new > new.txt; rm gone.txt",
108 "echo hello&&echo changed>a.txt&&echo new>new.txt&&del gone.txt",
109 );
110 var result = try execute(std.testing.allocator, .{
111 .scratch = scratch.dir,
112 .source = source.dir,
113 .argv = command.slice(),
114 .prefix = "run-test",
115 .max_file_bytes = 1024,
116 });
117 defer result.deinit();
118
119 try std.testing.expect(std.mem.startsWith(u8, result.stdout, "hello"));
120 try std.testing.expectEqual(@as(i64, 0), result.status.exitCode().?);
121 try std.testing.expectEqual(change.Operation.put, result.changes.find("a.txt").?.operation);
122 try std.testing.expectEqual(change.Operation.put, result.changes.find("new.txt").?.operation);
123 try std.testing.expectEqual(change.Operation.delete, result.changes.find("gone.txt").?.operation);
124
125 const staged = try result.readFileAlloc(std.testing.allocator, "a.txt", 1024);
126 defer std.testing.allocator.free(staged);
127 try std.testing.expectEqualStrings("changed", staged);
128
129 const original = try source.dir.readFileAlloc(fs_io, "a.txt", std.testing.allocator, .limited(1024));
130 defer std.testing.allocator.free(original);
131 try std.testing.expectEqualStrings("old", original);
132 const gone = try source.dir.readFileAlloc(fs_io, "gone.txt", std.testing.allocator, .limited(1024));
133 defer std.testing.allocator.free(gone);
134 try std.testing.expectEqualStrings("remove", gone);
135 try std.testing.expectError(error.FileNotFound, source.dir.statFile(fs_io, "new.txt", .{}));
136 }
137
138 test "run records generated symlink as filesystem delta" {
139 if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
140
141 var scratch = std.testing.tmpDir(.{});
142 defer scratch.cleanup();
143
144 const command = hostShellCommand(
145 "printf target > target.txt; ln -s target.txt link",
146 "",
147 );
148 var result = execute(std.testing.allocator, .{
149 .scratch = scratch.dir,
150 .argv = command.slice(),
151 .prefix = "run-link",
152 .max_file_bytes = 1024,
153 }) catch |err| switch (err) {
154 error.AccessDenied, error.PermissionDenied => return error.SkipZigTest,
155 else => return err,
156 };
157 defer result.deinit();
158
159 const link = result.changes.find("link").?;
160 try std.testing.expectEqual(change.Operation.put, link.operation);
161 try std.testing.expectEqual(change.Kind.sym_link, link.entry.kind);
162 try std.testing.expectEqualStrings("target.txt", link.entry.target);
163 }
164
165 test "run returns nonzero status without discarding filesystem changes" {
166 var scratch = std.testing.tmpDir(.{});
167 defer scratch.cleanup();
168
169 const command = hostShellCommand(
170 "printf err >&2; printf kept > kept.txt; exit 7",
171 "echo err 1>&2&&echo kept>kept.txt&&exit /B 7",
172 );
173 var result = try execute(std.testing.allocator, .{
174 .scratch = scratch.dir,
175 .argv = command.slice(),
176 .prefix = "nonzero-test",
177 .max_file_bytes = 1024,
178 });
179 defer result.deinit();
180
181 try std.testing.expectEqual(@as(i64, 7), result.status.exitCode().?);
182 try std.testing.expect(std.mem.startsWith(u8, result.stderr, "err"));
183 try std.testing.expectEqual(change.Operation.put, result.changes.find("kept.txt").?.operation);
184 }
185
186 test "run honors relative command cwd inside disposable layer" {
187 var source = std.testing.tmpDir(.{});
188 defer source.cleanup();
189 var scratch = std.testing.tmpDir(.{});
190 defer scratch.cleanup();
191
192 try source.dir.createDirPath(fs_io, "sub");
193 try source.dir.writeFile(fs_io, .{ .sub_path = "sub/input.txt", .data = "input" });
194
195 const command = hostShellCommand(
196 "cat input.txt > output.txt",
197 "type input.txt > output.txt",
198 );
199 var result = try execute(std.testing.allocator, .{
200 .scratch = scratch.dir,
201 .source = source.dir,
202 .argv = command.slice(),
203 .cwd = "sub",
204 .prefix = "cwd-test",
205 .max_file_bytes = 1024,
206 });
207 defer result.deinit();
208
209 try std.testing.expectEqual(change.Operation.put, result.changes.find("sub/output.txt").?.operation);
210 const output = try result.readFileAlloc(std.testing.allocator, "sub/output.txt", 1024);
211 defer std.testing.allocator.free(output);
212 try std.testing.expectEqualStrings("input", output);
213 }
214
215 test "run rejects cwd paths outside the disposable layer" {
216 var scratch = std.testing.tmpDir(.{});
217 defer scratch.cleanup();
218
219 const argv = [_][]const u8{ "bash", "-c", ":" };
220 try std.testing.expectError(error.AbsoluteCwd, execute(std.testing.allocator, .{
221 .scratch = scratch.dir,
222 .argv = argv[0..],
223 .cwd = "/tmp",
224 .prefix = "cwd-absolute",
225 }));
226 try std.testing.expectError(error.CwdEscapesLayer, execute(std.testing.allocator, .{
227 .scratch = scratch.dir,
228 .argv = argv[0..],
229 .cwd = "../outside",
230 .prefix = "cwd-parent",
231 }));
232 try std.testing.expectEqual(@as(usize, 0), try scratchEntryCount(scratch.dir, std.testing.allocator));
233 }
234
235 test "run rejects cwd symlinks outside the disposable layer" {
236 var source = std.testing.tmpDir(.{});
237 defer source.cleanup();
238 var scratch = std.testing.tmpDir(.{});
239 defer scratch.cleanup();
240
241 source.dir.symLink(fs_io, "/tmp", "outside", .{ .is_directory = true }) catch |err| switch (err) {
242 error.AccessDenied, error.PermissionDenied => return error.SkipZigTest,
243 else => return err,
244 };
245
246 const argv = [_][]const u8{ "bash", "-c", ":" };
247 try std.testing.expectError(error.CwdEscapesLayer, execute(std.testing.allocator, .{
248 .scratch = scratch.dir,
249 .source = source.dir,
250 .argv = argv[0..],
251 .cwd = "outside",
252 .prefix = "cwd-symlink",
253 }));
254 try std.testing.expectEqual(@as(usize, 0), try scratchEntryCount(scratch.dir, std.testing.allocator));
255 }
256
257 test "run can execute a materialized relative program" {
258 if (comptime !sys.fs.FilePermissions.has_executable_bit) return error.SkipZigTest;
259
260 var source = std.testing.tmpDir(.{});
261 defer source.cleanup();
262 var scratch = std.testing.tmpDir(.{});
263 defer scratch.cleanup();
264
265 try source.dir.writeFile(fs_io, .{ .sub_path = "tool.sh", .data = "#!/bin/sh\nprintf script > script.out\n" });
266 try source.dir.setFilePermissions(fs_io, "tool.sh", .executable_file, .{});
267
268 const argv = [_][]const u8{"./tool.sh"};
269 var result = try execute(std.testing.allocator, .{
270 .scratch = scratch.dir,
271 .source = source.dir,
272 .argv = argv[0..],
273 .prefix = "relative-program",
274 .max_file_bytes = 1024,
275 });
276 defer result.deinit();
277
278 try std.testing.expectEqual(@as(i64, 0), result.status.exitCode().?);
279 try std.testing.expectEqual(change.Operation.put, result.changes.find("script.out").?.operation);
280 }
281
282 test "run can replace the child environment" {
283 var scratch = std.testing.tmpDir(.{});
284 defer scratch.cleanup();
285
286 var env = sys.process.Environ.Map.init(std.testing.allocator);
287 defer env.deinit();
288 try env.put("SANDBOX_ENV_TEST", "present");
289 try env.put("HOME", "/sandbox-home");
290
291 const command = hostShellCommand(
292 "printf '%s:%s' \"$SANDBOX_ENV_TEST\" \"$HOME\"",
293 "echo %SANDBOX_ENV_TEST%:%HOME%",
294 );
295 var result = try execute(std.testing.allocator, .{
296 .scratch = scratch.dir,
297 .argv = command.slice(),
298 .environ_map = &env,
299 .prefix = "env-test",
300 .max_file_bytes = 1024,
301 });
302 defer result.deinit();
303
304 try std.testing.expect(std.mem.startsWith(u8, result.stdout, "present:/sandbox-home"));
305 try std.testing.expectEqual(audit_data.Environment.replaced, result.audit.environment);
306 }
307
308 test "run rejects unmet process isolation before command execution" {
309 var scratch = std.testing.tmpDir(.{});
310 defer scratch.cleanup();
311
312 const argv = [_][]const u8{ "bash", "-c", "printf ran > touched.txt" };
313 try std.testing.expectError(error.UnmetProcessIsolation, execute(std.testing.allocator, .{
314 .scratch = scratch.dir,
315 .argv = argv[0..],
316 .policy = .{ .process = .require_isolated },
317 .prefix = "policy-process",
318 .max_file_bytes = 1024,
319 }));
320 try std.testing.expectEqual(@as(usize, 0), try scratchEntryCount(scratch.dir, std.testing.allocator));
321 }
322
323 test "run rejects unmet network isolation before command execution" {
324 var scratch = std.testing.tmpDir(.{});
325 defer scratch.cleanup();
326
327 const argv = [_][]const u8{ "bash", "-c", "printf ran > touched.txt" };
328 try std.testing.expectError(error.UnmetNetworkIsolation, execute(std.testing.allocator, .{
329 .scratch = scratch.dir,
330 .argv = argv[0..],
331 .policy = .{ .network = .require_isolated },
332 .prefix = "policy-network",
333 .max_file_bytes = 1024,
334 }));
335 try std.testing.expectEqual(
336 @as(usize, 0),
337 try scratchEntryCount(scratch.dir, std.testing.allocator),
338 );
339 }
340
341 test "run requires a proven bubblewrap network namespace" {
342 var scratch = std.testing.tmpDir(.{});
343 defer scratch.cleanup();
344
345 const argv = [_][]const u8{ "bash", "-c", "printf ran > touched.txt" };
346 const plan = plan_data.Plan{
347 .scratch = scratch.dir,
348 .argv = &argv,
349 .runner = .bubblewrap_overlay,
350 .policy = .{ .network = .require_isolated },
351 .prefix = "policy-network-bwrap",
352 .max_file_bytes = 1024,
353 };
354 if (comptime builtin.os.tag != .linux) {
355 try std.testing.expectError(
356 error.UnmetNetworkIsolation,
357 execute(std.testing.allocator, plan),
358 );
359 return;
360 }
361 if (!bwrap.available(std.testing.allocator, scratch.dir)) return error.SkipZigTest;
362
363 var result = try execute(std.testing.allocator, plan);
364 defer result.deinit();
365 try std.testing.expectEqual(@as(i64, 0), result.status.exitCode().?);
366 try std.testing.expectEqual(
367 audit_data.Network.bubblewrap_namespace,
368 result.audit.network,
369 );
370 try std.testing.expectEqual(
371 change.Operation.put,
372 result.changes.find("touched.txt").?.operation,
373 );
374 }
375
376 test "run isolates a connected caller descriptor without mutating its owner" {
377 if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
378
379 var scratch = std.testing.tmpDir(.{});
380 defer scratch.cleanup();
381 if (!bwrap.available(std.testing.allocator, scratch.dir)) return error.SkipZigTest;
382
383 const loopback = try sys.net.Address.parseIp4("127.0.0.1", 0);
384 var listener = try loopback.listen(.{
385 .backlog = 1,
386 .close_on_exec = true,
387 });
388 defer listener.deinit();
389 const client = try sys.net.tcpStreamSocket(.{ .close_on_exec = false });
390 defer sys.net.close(client);
391 try sys.fd.setInheritOnExec(client);
392 try sys.net.connect(
393 client,
394 listener.listen_address.socketAddress(),
395 listener.listen_address.socketAddressLen(),
396 );
397 var peer = try listener.accept();
398 defer peer.stream.close();
399
400 try std.testing.expect(client > 2);
401 try std.testing.expect(!try sys.fd.closeOnExec(client));
402 try std.testing.expect(try sys.fd.closeOnExec(listener.socket));
403 try std.testing.expect(try sys.fd.closeOnExec(peer.stream.handle));
404
405 const script = try std.fmt.allocPrint(
406 std.testing.allocator,
407 "printf C 2>/dev/null >&{d} || true; printf ran > ran.txt",
408 .{client},
409 );
410 defer std.testing.allocator.free(script);
411 const argv = [_][]const u8{ "bash", "-c", script };
412
413 {
414 var anchor = try execute(std.testing.allocator, .{
415 .scratch = scratch.dir,
416 .argv = &argv,
417 .prefix = "run-descriptor-anchor",
418 .max_file_bytes = 1024,
419 });
420 defer anchor.deinit();
421 try std.testing.expectEqual(@as(i64, 0), anchor.status.exitCode().?);
422 try expectSocketByte(peer.stream.handle, 'C');
423 try std.testing.expect(!try sys.net.pollReadable(peer.stream.handle, 0));
424 }
425
426 var isolated = try execute(std.testing.allocator, .{
427 .scratch = scratch.dir,
428 .argv = &argv,
429 .runner = .bubblewrap_overlay,
430 .policy = .{ .network = .require_isolated },
431 .prefix = "run-descriptor-isolated",
432 .max_file_bytes = 1024,
433 });
434 defer isolated.deinit();
435 try std.testing.expectEqual(@as(i64, 0), isolated.status.exitCode().?);
436 try std.testing.expectEqual(
437 audit_data.Network.bubblewrap_namespace,
438 isolated.audit.network,
439 );
440 const ran = try isolated.readFileAlloc(std.testing.allocator, "ran.txt", 4);
441 defer std.testing.allocator.free(ran);
442 try std.testing.expectEqualStrings("ran", ran);
443 try std.testing.expect(!try sys.net.pollReadable(peer.stream.handle, 100));
444
445 try std.testing.expect(sys.fd.isOpen(client));
446 try std.testing.expect(!try sys.fd.closeOnExec(client));
447 try std.testing.expectEqual(
448 @as(usize, 1),
449 try sys.net.sendNoSignal(client, "P"),
450 );
451 try expectSocketByte(peer.stream.handle, 'P');
452 try peer.stream.writeAll("Q");
453 try expectSocketByte(client, 'Q');
454 }
455
456 test "run cleans disposable layer after output limit failure" {
457 var scratch = std.testing.tmpDir(.{});
458 defer scratch.cleanup();
459
460 const command = hostShellCommand(
461 "printf abcdef",
462 "echo abcdef",
463 );
464 try std.testing.expectError(error.StdoutStreamTooLong, execute(std.testing.allocator, .{
465 .scratch = scratch.dir,
466 .argv = command.slice(),
467 .stdout_limit = 2,
468 .stderr_limit = 1024,
469 .prefix = "limit-test",
470 }));
471 try std.testing.expectEqual(@as(usize, 0), try scratchEntryCount(scratch.dir, std.testing.allocator));
472 }
473
474 test "run cleans writes one level above the materialized root" {
475 var scratch = std.testing.tmpDir(.{});
476 defer scratch.cleanup();
477
478 const command = hostShellCommand(
479 "printf outside > ../outside.txt",
480 "echo outside>..\\outside.txt",
481 );
482 var result = try execute(std.testing.allocator, .{
483 .scratch = scratch.dir,
484 .argv = command.slice(),
485 .prefix = "parent-write",
486 .max_file_bytes = 1024,
487 });
488 try std.testing.expect(result.changes.find("outside.txt") == null);
489 result.deinit();
490 try std.testing.expectEqual(@as(usize, 0), try scratchEntryCount(scratch.dir, std.testing.allocator));
491 }
492
493 test "run cleans disposable layer after timeout" {
494 var scratch = std.testing.tmpDir(.{});
495 defer scratch.cleanup();
496
497 const command = hostShellCommand(
498 "sleep 2",
499 "ping -n 3 127.0.0.1 >NUL",
500 );
501 try std.testing.expectError(error.Timeout, execute(std.testing.allocator, .{
502 .scratch = scratch.dir,
503 .argv = command.slice(),
504 .timeout_ms = 1,
505 .prefix = "timeout-test",
506 }));
507 try std.testing.expectEqual(@as(usize, 0), try scratchEntryCount(scratch.dir, std.testing.allocator));
508 }
509
510 test "run skips oversized source files instead of failing staging" {
511 var source = std.testing.tmpDir(.{});
512 defer source.cleanup();
513 var scratch = std.testing.tmpDir(.{});
514 defer scratch.cleanup();
515
516 try source.dir.writeFile(fs_io, .{ .sub_path = "huge.bin", .data = "0123456789abcdef" });
517 try source.dir.writeFile(fs_io, .{ .sub_path = "small.txt", .data = "ok" });
518
519 const command = hostShellCommand(
520 "cat small.txt > echoed.txt",
521 "type small.txt > echoed.txt",
522 );
523 var result = try execute(std.testing.allocator, .{
524 .scratch = scratch.dir,
525 .source = source.dir,
526 .argv = command.slice(),
527 .prefix = "skip-oversize",
528 .max_file_bytes = 8,
529 });
530 defer result.deinit();
531
532 try std.testing.expectEqual(@as(i64, 0), result.status.exitCode().?);
533 try std.testing.expectEqual(change.Operation.put, result.changes.find("echoed.txt").?.operation);
534 const echoed = try result.readFileAlloc(std.testing.allocator, "echoed.txt", 1024);
535 defer std.testing.allocator.free(echoed);
536 try std.testing.expect(std.mem.startsWith(u8, echoed, "ok"));
537 }
538
539 test "run records generated files regardless of the source file limit" {
540 var scratch = std.testing.tmpDir(.{});
541 defer scratch.cleanup();
542
543 const command = hostShellCommand(
544 "printf abcdef > huge.txt",
545 "echo abcdef>huge.txt",
546 );
547 var result = try execute(std.testing.allocator, .{
548 .scratch = scratch.dir,
549 .argv = command.slice(),
550 .prefix = "size-test",
551 .max_file_bytes = 2,
552 });
553 defer result.deinit();
554
555 try std.testing.expectEqual(change.Operation.put, result.changes.find("huge.txt").?.operation);
556 const generated = try result.readFileAlloc(std.testing.allocator, "huge.txt", 1024);
557 defer std.testing.allocator.free(generated);
558 try std.testing.expect(std.mem.startsWith(u8, generated, "abcdef"));
559 }