lib/sandbox/src/command.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const builtin = @import("builtin");
3 const sys = @import("sys");
4
5 const Allocator = std.mem.Allocator;
6 const poll_ms = 25;
7
8 const Deadline = struct {
9 clock: sys.time.AwakeClock,
10 end: sys.time.AwakeInstant,
11
12 fn init(clock: sys.time.AwakeClock, timeout_ms: u64) sys.time.ClockError!Deadline {
13 const timeout = sys.time.Duration.fromMilliseconds(@max(timeout_ms, 1));
14 return .{
15 .clock = clock,
16 .end = (try clock.now()).deadlineAfter(timeout),
17 };
18 }
19
20 fn expired(self: Deadline) sys.time.ClockError!bool {
21 return (try self.clock.now()).reached(self.end);
22 }
23
24 fn remainingMilliseconds(self: Deadline) sys.time.ClockError!?u64 {
25 const remaining = (try self.clock.now()).remainingUntil(self.end);
26 if (remaining.isZero()) return null;
27 return remaining.asMillisecondsCeil();
28 }
29 };
30
31 pub const Status = union(enum) {
32 term: sys.process.Termination,
33
34 pub fn exitCode(self: Status) ?i64 {
35 return switch (self) {
36 .term => |term| sys.process.exitCode(term),
37 };
38 }
39 };
40
41 pub const Interrupt = struct {
42 ptr: ?*const anyopaque = null,
43 checkFn: ?*const fn (?*const anyopaque) anyerror!void = null,
44
45 pub fn check(self: Interrupt) !void {
46 const checkFn = self.checkFn orelse return;
47 try checkFn(self.ptr);
48 }
49 };
50
51 pub const Result = struct {
52 term: sys.process.Termination,
53 stdout: []u8,
54 stderr: []u8,
55
56 pub fn deinit(self: *Result, allocator: Allocator) void {
57 allocator.free(self.stdout);
58 allocator.free(self.stderr);
59 self.* = undefined;
60 }
61 };
62
63 pub const DescriptorPolicy = enum {
64 inherited,
65 isolated,
66 };
67
68 pub const Options = struct {
69 argv: []const []const u8,
70 cwd: sys.process.Child.Cwd,
71 environ_map: ?*const sys.process.Environ.Map = null,
72 stdout_limit: usize,
73 stderr_limit: usize,
74 timeout_ms: u64,
75 interrupt: Interrupt = .{},
76 awake_clock: sys.time.AwakeClock = .system(),
77 descriptor_policy: DescriptorPolicy = .inherited,
78 };
79
80 pub fn run(allocator: Allocator, options: Options) !Result {
81 var io_state = sys.thread.initThreadedIo(allocator, .{});
82 defer io_state.deinit();
83 const io = io_state.io();
84 var child = switch (options.descriptor_policy) {
85 .inherited => try sys.process.spawn(io, .{
86 .argv = options.argv,
87 .cwd = options.cwd,
88 .environ_map = options.environ_map,
89 .stdin = .ignore,
90 .stdout = .pipe,
91 .stderr = .pipe,
92 .pgid = childProcessGroup(),
93 }),
94 .isolated => isolated: {
95 var empty_environment = sys.process.Environ.Map.init(allocator);
96 defer empty_environment.deinit();
97 switch (options.cwd) {
98 .inherit => {},
99 else => return error.DescriptorIsolationFailed,
100 }
101 break :isolated try sys.process.spawnCapturedIsolated(allocator, .{
102 .argv = options.argv,
103 .environment = options.environ_map orelse &empty_environment,
104 });
105 },
106 };
107
108 var multi_reader_buffer: std.Io.File.MultiReader.Buffer(2) = undefined;
109 var multi_reader: std.Io.File.MultiReader = undefined;
110 multi_reader.init(allocator, io, multi_reader_buffer.toStreams(), &.{ child.stdout.?, child.stderr.? });
111 defer multi_reader.deinit();
112 defer sys.process.killAndReap(&child, io);
113
114 const stdout_reader = multi_reader.reader(0);
115 const stderr_reader = multi_reader.reader(1);
116 const deadline = try Deadline.init(options.awake_clock, options.timeout_ms);
117
118 while (true) {
119 options.interrupt.check() catch |err| {
120 stopChild(&child, io);
121 return err;
122 };
123 if (try deadline.expired()) {
124 stopChild(&child, io);
125 return error.Timeout;
126 }
127 multi_reader.fill(64, timeoutFromMilliseconds(io, poll_ms)) catch |err| switch (err) {
128 error.Timeout => continue,
129 error.EndOfStream => break,
130 else => |actual| return actual,
131 };
132 if (stdout_reader.buffered().len > options.stdout_limit) {
133 stopChild(&child, io);
134 return error.StdoutStreamTooLong;
135 }
136 if (stderr_reader.buffered().len > options.stderr_limit) {
137 stopChild(&child, io);
138 return error.StderrStreamTooLong;
139 }
140 }
141
142 try multi_reader.checkAnyError();
143 const term = try waitUntilDeadline(
144 &child,
145 io,
146 deadline,
147 options.interrupt,
148 );
149 const stdout = try multi_reader.toOwnedSlice(0);
150 errdefer allocator.free(stdout);
151 const stderr = try multi_reader.toOwnedSlice(1);
152 return .{
153 .term = term,
154 .stdout = stdout,
155 .stderr = stderr,
156 };
157 }
158
159 fn childProcessGroup() @TypeOf(@as(sys.process.SpawnOptions, undefined).pgid) {
160 if (comptime builtin.os.tag == .windows) return null;
161 return 0;
162 }
163
164 fn stopChild(child: *sys.process.Child, io: std.Io) void {
165 const child_id = child.id orelse return;
166 sys.process.signalChildGroup(child_id, .terminate) catch sys.process.requestTermination(child, io);
167 sys.process.signalChildGroup(child_id, .kill) catch sys.process.forceKillChildId(child_id);
168 }
169
170 fn waitUntilDeadline(
171 child: *sys.process.Child,
172 io: std.Io,
173 deadline: Deadline,
174 interrupt: Interrupt,
175 ) !sys.process.Termination {
176 const Outcome = union(enum) {
177 exit: sys.process.WaitError!sys.process.Termination,
178 tick: std.Io.Cancelable!void,
179 };
180 var outcomes: [2]Outcome = undefined;
181 var select = std.Io.Select(Outcome).init(io, &outcomes);
182 select.async(.exit, waitChild, .{ child, io });
183 while (true) {
184 interrupt.check() catch |err| {
185 stopChild(child, io);
186 select.cancelDiscard();
187 return err;
188 };
189 const remaining_ms = try deadline.remainingMilliseconds() orelse {
190 stopChild(child, io);
191 select.cancelDiscard();
192 return error.Timeout;
193 };
194 select.async(.tick, waitTick, .{ io, @min(remaining_ms, poll_ms) });
195 switch (try select.await()) {
196 .exit => |result| {
197 select.cancelDiscard();
198 return try result;
199 },
200 .tick => |result| try result,
201 }
202 }
203 }
204
205 fn waitChild(
206 child: *sys.process.Child,
207 io: std.Io,
208 ) sys.process.WaitError!sys.process.Termination {
209 return try child.wait(io);
210 }
211
212 fn waitTick(io: std.Io, milliseconds: u64) std.Io.Cancelable!void {
213 return try timeoutFromMilliseconds(io, milliseconds).sleep(io);
214 }
215
216 fn timeoutFromMilliseconds(io: std.Io, milliseconds: u64) std.Io.Timeout {
217 const max_milliseconds: u64 = @intCast(std.math.maxInt(i64));
218 const bounded_ms = @min(milliseconds, max_milliseconds);
219 const duration: std.Io.Clock.Duration = .{
220 .clock = .awake,
221 .raw = std.Io.Duration.fromMilliseconds(@intCast(bounded_ms)),
222 };
223 return .{ .deadline = std.Io.Clock.Timestamp.fromNow(io, duration) };
224 }
225
226 test "command deadline kills descendants after output streams close" {
227 if (comptime builtin.os.tag == .windows or builtin.os.tag == .wasi) {
228 return error.SkipZigTest;
229 }
230 var temporary = std.testing.tmpDir(.{});
231 defer temporary.cleanup();
232 const root = try temporary.dir.realPathFileAlloc(
233 std.Options.debug_io,
234 ".",
235 std.testing.allocator,
236 );
237 defer std.testing.allocator.free(root);
238 const marker = try std.fs.path.join(
239 std.testing.allocator,
240 &.{ root, "survived" },
241 );
242 defer std.testing.allocator.free(marker);
243 const argv = [_][]const u8{
244 "sh",
245 "-c",
246 "exec 1>&- 2>&-; (sleep .2; : > \"$1\") & wait",
247 "sandbox-command",
248 marker,
249 };
250 const started = try sys.time.awakeNow();
251 try std.testing.expectError(error.Timeout, run(std.testing.allocator, .{
252 .argv = &argv,
253 .cwd = .{ .path = root },
254 .stdout_limit = 4096,
255 .stderr_limit = 4096,
256 .timeout_ms = 25,
257 }));
258 const elapsed = try (try sys.time.awakeNow()).elapsedSince(started);
259 try std.testing.expect(elapsed.asMillisecondsFloor() < 1_000);
260 sys.time.sleepMilliseconds(300);
261 const survived = exists: {
262 std.Io.Dir.cwd().access(std.Options.debug_io, marker, .{}) catch
263 break :exists false;
264 break :exists true;
265 };
266 try std.testing.expect(!survived);
267 }
268
269 test "command deadline ignores wall jumps and suspend gaps" {
270 var clock = sys.time.FakeClock.zero();
271 const deadline = try Deadline.init(clock.awakeClock(), 10);
272 clock.setWall(.fromNanoseconds(std.math.maxInt(u64)));
273 clock.suspendGap(.fromMilliseconds(20));
274 try std.testing.expect(!try deadline.expired());
275 clock.advance(.fromMilliseconds(10));
276 try std.testing.expect(try deadline.expired());
277 }
278
279 test "isolated command lookup ignores replacement child PATH" {
280 if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
281 if (comptime !sys.fs.FilePermissions.has_executable_bit) {
282 return error.SkipZigTest;
283 }
284
285 var temporary = std.testing.tmpDir(.{});
286 defer temporary.cleanup();
287 const root = try temporary.dir.realPathFileAlloc(
288 std.Options.debug_io,
289 ".",
290 std.testing.allocator,
291 );
292 defer std.testing.allocator.free(root);
293 try temporary.dir.writeFile(std.Options.debug_io, .{
294 .sub_path = "sh",
295 .data = "#!/bin/sh\nprintf fake",
296 });
297 try temporary.dir.setFilePermissions(
298 std.Options.debug_io,
299 "sh",
300 .executable_file,
301 .{},
302 );
303
304 var environment = sys.process.Environ.Map.init(std.testing.allocator);
305 defer environment.deinit();
306 try environment.put("PATH", root);
307
308 const argv = [_][]const u8{
309 "sh",
310 "-c",
311 "printf '%s' \"$PATH\"",
312 };
313 var result = try run(std.testing.allocator, .{
314 .argv = &argv,
315 .cwd = .inherit,
316 .environ_map = &environment,
317 .stdout_limit = 4096,
318 .stderr_limit = 4096,
319 .timeout_ms = 1_000,
320 .descriptor_policy = .isolated,
321 });
322 defer result.deinit(std.testing.allocator);
323
324 try std.testing.expectEqual(@as(i64, 0), result.term.exited);
325 try std.testing.expectEqualStrings(root, result.stdout);
326 }