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 }