diff --git a/src/core/dsl/builtins/process.zig b/src/core/dsl/builtins/process.zig index 191b33ba..6504c6d8 100644 --- a/src/core/dsl/builtins/process.zig +++ b/src/core/dsl/builtins/process.zig @@ -12,59 +12,6 @@ const Value = values.Value; const BuiltinError = pathname.BuiltinError; const ExecCtx = pathname.ExecCtx; -/// Post_install output is attacker-influenced: a formula's `system` call can -/// emit whatever the child writes, and a terminal will act on it. The -/// `--use-system-ruby` path already pumps the child through -/// `ui/term_sanitize.zig`, which drops OSC (including OSC 52 clipboard -/// writes), DCS, absolute cursor positioning, and scrollback erase. The -/// native interpreter — the default path, and the one the README leads with — -/// inherited the terminal directly, so none of that applied to it. -/// -/// Piping means the child no longer sees a TTY, so colour-on-TTY heuristics -/// turn themselves off. That is the same trade the ruby path already makes, -/// and `MALT_ALLOW_RAW_POST_INSTALL=1` opts out of both. -fn childStdioMode(suppress: bool, raw: bool) std.process.SpawnOptions.StdIo { - if (suppress) return .ignore; - return if (raw) .inherit else .pipe; -} - -/// Test seam mirroring `sandbox/macos.zig`'s `SpawnHooks`: forces the Nth pump -/// spawn to fail, so the undrained-pipe path is reachable without exhausting -/// threads for real. -const PumpHooks = struct { fail_spawn_on: ?u32 = null }; - -fn spawnPump(hooks: PumpHooks, idx: u32, fd: c_int, out_fd: c_int) std.Thread.SpawnError!std.Thread { - if (hooks.fail_spawn_on) |n| if (idx == n) return error.SystemResources; - return std.Thread.spawn(.{}, macos_sandbox.filterInto, .{ fd, out_fd }); -} - -/// Pump `child`'s piped stdout/stderr through the sanitizer, then reap it. -/// One thread each, so a chatty child cannot wedge one pipe while we block on -/// the other. `wait` owns the pipe fds, hence the non-closing `filterInto`. -fn waitSanitized(ctx: ExecCtx, child: *std.process.Child, hooks: PumpHooks) !std.process.Child.Term { - var out_thread: ?std.Thread = null; - var err_thread: ?std.Thread = null; - // A pump that never starts leaves its pipe undrained, so the child blocks - // on a full buffer and `wait` never returns. Kill first — that EOFs any - // pump that did start — then join. - errdefer { - child.kill(ctx.io); - if (out_thread) |t| t.join(); - if (err_thread) |t| t.join(); - } - - if (child.stdout) |f| out_thread = try spawnPump(hooks, 0, f.handle, std.c.STDOUT_FILENO); - if (child.stderr) |f| err_thread = try spawnPump(hooks, 1, f.handle, std.c.STDERR_FILENO); - - // The pumps see EOF when the child's write ends close, i.e. when it exits, - // so joining before `wait` cannot hang on a live child. - if (out_thread) |t| t.join(); - if (err_thread) |t| t.join(); - out_thread = null; - err_thread = null; - return child.wait(ctx.io); -} - /// Coerce every argument to a string and collect them into an owned argv. /// A coercion/allocation failure aborts the whole call rather than silently /// dropping an element and shifting the command line — the DSL runs untrusted @@ -102,10 +49,10 @@ pub fn system(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Value { const raw = macos_sandbox.rawPassthroughEnabled(ctx.environ); var child = std.process.spawn(ctx.io, .{ .argv = spawn_argv, - .stdout = childStdioMode(ctx.suppress_child_stdout, raw), - .stderr = childStdioMode(false, raw), + .stdout = macos_sandbox.childStdioMode(ctx.suppress_child_stdout, raw), + .stderr = macos_sandbox.childStdioMode(false, raw), }) catch return BuiltinError.SystemCommandFailed; - const term = waitSanitized(ctx, &child, .{}) catch return BuiltinError.SystemCommandFailed; + const term = macos_sandbox.waitSanitizedChild(ctx.io, &child, .{}) catch return BuiltinError.SystemCommandFailed; switch (term) { .exited => |code| if (code == 0) return Value{ .bool = true } else recordFailure(ctx, argv_slice[0], code), @@ -327,12 +274,12 @@ fn readPipeAll(file: std.Io.File, allocator: std.mem.Allocator, max_bytes: usize test "childStdioMode: suppression wins, then raw passthrough, else sanitized pipe" { // Under --json/--ndjson the caller suppresses the child's stdout so it // can't corrupt the document — that still takes precedence. - try std.testing.expect(std.meta.activeTag(childStdioMode(true, false)) == .ignore); - try std.testing.expect(std.meta.activeTag(childStdioMode(true, true)) == .ignore); + try std.testing.expect(std.meta.activeTag(macos_sandbox.childStdioMode(true, false)) == .ignore); + try std.testing.expect(std.meta.activeTag(macos_sandbox.childStdioMode(true, true)) == .ignore); // MALT_ALLOW_RAW_POST_INSTALL=1 hands the terminal straight to the child. - try std.testing.expect(std.meta.activeTag(childStdioMode(false, true)) == .inherit); + try std.testing.expect(std.meta.activeTag(macos_sandbox.childStdioMode(false, true)) == .inherit); // Default: pipe, so the bytes can be run through the sanitizer first. - try std.testing.expect(std.meta.activeTag(childStdioMode(false, false)) == .pipe); + try std.testing.expect(std.meta.activeTag(macos_sandbox.childStdioMode(false, false)) == .pipe); } // Homebrew's formula-context `system` raises on failure, so a child @@ -711,7 +658,7 @@ test "waitSanitized fails loudly when a sanitizer pump cannot start" { }); try std.testing.expectError( error.SystemResources, - waitSanitized(ctx, &child, .{ .fail_spawn_on = 0 }), + macos_sandbox.waitSanitizedChild(ctx.io, &child, .{ .fail_spawn_on = 0 }), ); } diff --git a/src/core/post_install_steps.zig b/src/core/post_install_steps.zig index fa8c1cb9..d4facf36 100644 --- a/src/core/post_install_steps.zig +++ b/src/core/post_install_steps.zig @@ -1186,15 +1186,17 @@ fn spawnFenced(ctx: StepsCtx, argv: []const []const u8, env_map: ?*const std.pro return false; }; + const raw = sandbox_macos.rawPassthroughEnabled(ctx.environ); var child = std.process.spawn(ctx.io, .{ .argv = fenced, - .stdout = if (ctx.suppress_child_stdout) .ignore else .inherit, + .stdout = sandbox_macos.childStdioMode(ctx.suppress_child_stdout, raw), + .stderr = sandbox_macos.childStdioMode(false, raw), .environ_map = env_map, }) catch { logCmdFail(ctx, std.fmt.allocPrint(ctx.allocator, "{s} failed to spawn", .{label}) catch label); return false; }; - const term = child.wait(ctx.io) catch { + const term = sandbox_macos.waitSanitizedChild(ctx.io, &child, .{}) catch { logCmdFail(ctx, std.fmt.allocPrint(ctx.allocator, "{s} did not terminate cleanly", .{label}) catch label); return false; }; @@ -1360,6 +1362,44 @@ const TestHarness = struct { } }; +/// Capture what a declarative child writes to a terminal fd. The production +/// path uses the process's real descriptors, so an in-memory output capture +/// would miss the behavior under test. +const TestFdCapture = struct { + fd: c_int, + saved: c_int, + path: [:0]const u8, + io: std.Io, + + fn start(alloc: std.mem.Allocator, io: std.Io, fd: c_int) !TestFdCapture { + const path = try std.fmt.allocPrintSentinel(alloc, "/tmp/malt_steps_output_{d}", .{std.c.getpid()}, 0); + const saved = std.c.dup(fd); + const cap = std.c.open(path.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(std.c.mode_t, 0o600)); + if (saved < 0 or cap < 0) return error.CaptureFailed; + _ = std.c.dup2(cap, fd); + _ = std.c.close(cap); + return .{ .fd = fd, .saved = saved, .path = path, .io = io }; + } + + fn finish(self: *TestFdCapture, alloc: std.mem.Allocator) ![]const u8 { + self.restore(); + defer std.Io.Dir.cwd().deleteFile(self.io, self.path) catch {}; + const f = try std.Io.Dir.openFileAbsolute(self.io, self.path, .{}); + defer f.close(self.io); + const st = try f.stat(self.io); + const buf = try alloc.alloc(u8, @intCast(st.size)); + _ = try f.readPositionalAll(self.io, buf, 0); + return buf; + } + + fn restore(self: *TestFdCapture) void { + if (self.saved < 0) return; + _ = std.c.dup2(self.saved, self.fd); + _ = std.c.close(self.saved); + self.saved = -1; + } +}; + fn testFormulaJson(h: *TestHarness, steps_json: []const u8) ![]const u8 { return std.fmt.allocPrint( h.arena.allocator(), @@ -2556,6 +2596,31 @@ test "run executes the formula's own helper with its arguments expanded" { try testing.expect(dirExists(h.io, try std.fmt.allocPrint(a, "{s}/var/lib/dbus", .{h.prefix}))); } +test "run strips terminal escapes a formula emits while preserving text" { + if (@import("builtin").os.tag != .macos) return error.SkipZigTest; + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + var h = try TestHarness.init(); + defer h.deinit(); + h.io = threaded.io(); + const a = h.arena.allocator(); + + const bin = try std.fmt.allocPrint(a, "{s}/bin", .{h.keg}); + try std.Io.Dir.cwd().createDirPath(h.io, bin); + try std.Io.Dir.symLinkAbsolute(h.io, "/bin/echo", try std.fmt.allocPrint(a, "{s}/emit", .{bin}), .{}); + + var cap = try TestFdCapture.start(a, h.io, std.c.STDOUT_FILENO); + defer cap.restore(); + try testing.expect(execute(h.ctx(), try testFormulaJson(&h, + \\[{"type":"run","command":{"base":"bin","path":"emit"}, + \\ "args":["\u001b]52;c;ZXZpbA==\u0007VISIBLE"]}] + ))); + const got = try cap.finish(a); + + try testing.expect(std.mem.indexOf(u8, got, "VISIBLE") != null); + try testing.expect(std.mem.indexOfScalar(u8, got, 0x1b) == null); +} + test "run refuses the execution fields malt does not honour" { // Compaction drops defaults, so a key that survives into the JSON is one // the formula meant. Running without honouring it would be a truncated diff --git a/src/core/sandbox/macos.zig b/src/core/sandbox/macos.zig index d848ee6c..238101d9 100644 --- a/src/core/sandbox/macos.zig +++ b/src/core/sandbox/macos.zig @@ -287,6 +287,52 @@ pub fn rawPassthroughEnabled(environ: std.process.Environ) bool { return std.mem.eql(u8, v, "1"); } +/// Select child stdio for every native post_install executor. Formula output +/// is attacker-controlled, so normal output is piped through the sanitizer +/// that drops OSC 52 clipboard writes and other active terminal controls. +/// JSON modes may suppress stdout; the explicit raw opt-out preserves +/// inherited terminal behavior. Piping also disables colour-on-TTY heuristics. +pub fn childStdioMode(suppress: bool, raw: bool) std.process.SpawnOptions.StdIo { + if (suppress) return .ignore; + return if (raw) .inherit else .pipe; +} + +/// Test seam for a failed sanitizer-pump spawn. Production uses the zero +/// value; tests force the error path without exhausting threads for real. +pub const ChildPumpHooks = struct { fail_spawn_on: ?u32 = null }; + +fn spawnChildPump(hooks: ChildPumpHooks, idx: u32, fd: c_int, out_fd: c_int) std.Thread.SpawnError!std.Thread { + if (hooks.fail_spawn_on) |n| if (idx == n) return error.SystemResources; + return std.Thread.spawn(.{}, filterInto, .{ fd, out_fd }); +} + +/// Pump a std.process child through the terminal sanitizer and reap it. Both +/// pipes drain concurrently so a chatty child cannot fill one while the +/// caller blocks on the other. `wait` owns the pipe descriptors, so the pump +/// uses `filterInto`, which leaves them open for `wait` to close. +pub fn waitSanitizedChild( + io: std.Io, + child: *std.process.Child, + hooks: ChildPumpHooks, +) !std.process.Child.Term { + var out_thread: ?std.Thread = null; + var err_thread: ?std.Thread = null; + errdefer { + child.kill(io); + if (out_thread) |t| t.join(); + if (err_thread) |t| t.join(); + } + + if (child.stdout) |f| out_thread = try spawnChildPump(hooks, 0, f.handle, std.c.STDOUT_FILENO); + if (child.stderr) |f| err_thread = try spawnChildPump(hooks, 1, f.handle, std.c.STDERR_FILENO); + + if (out_thread) |t| t.join(); + if (err_thread) |t| t.join(); + out_thread = null; + err_thread = null; + return child.wait(io); +} + fn spawnInherit( argv_z: [:null]?[*:0]u8, envp: [:null]?[*:0]u8,