Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 8 additions & 61 deletions src/core/dsl/builtins/process.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }),
);
}

Expand Down
69 changes: 67 additions & 2 deletions src/core/post_install_steps.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/core/sandbox/macos.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading