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 }