Skip to content
Merged
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
68 changes: 67 additions & 1 deletion src/core/dsl/builtins/process.zig
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,66 @@ fn buildArgv(ctx: ExecCtx, args: []const Value) BuiltinError![]const []const u8
return argv.toOwnedSlice(ctx.allocator);
}

const inherited_env_keys = std.StaticStringMap(void).initComptime(.{
.{ "LANG", {} },
.{ "LC_ALL", {} },
.{ "LC_CTYPE", {} },
.{ "TERM", {} },
.{ "SDKROOT", {} },
.{ "MACOSX_DEPLOYMENT_TARGET", {} },
.{ "CC", {} },
.{ "CXX", {} },
.{ "CFLAGS", {} },
.{ "CPPFLAGS", {} },
.{ "CXXFLAGS", {} },
.{ "LDFLAGS", {} },
.{ "MAKEFLAGS", {} },
});

/// Return the environment visible to formula code. Values that describe the
/// installation are derived from the current sandbox instead of trusting the
/// parent process; only non-secret build and locale settings are inherited.
fn formulaEnvValue(ctx: ExecCtx, key: []const u8) BuiltinError!?[]const u8 {
if (std.mem.eql(u8, key, "HOME")) return ctx.malt_prefix;
if (std.mem.eql(u8, key, "PATH")) return macos_sandbox.sandbox_path;
if (std.mem.eql(u8, key, "MALT_PREFIX") or std.mem.eql(u8, key, "HOMEBREW_PREFIX"))
return ctx.malt_prefix;
if (std.mem.eql(u8, key, "HOMEBREW_CELLAR"))
return std.fmt.allocPrint(ctx.allocator, "{s}/Cellar", .{ctx.malt_prefix}) catch
return BuiltinError.OutOfMemory;
if (std.mem.eql(u8, key, "TMPDIR")) return "/tmp";
if (inherited_env_keys.has(key)) return std.process.Environ.getPosix(ctx.environ, key);
return null;
}

fn buildFormulaEnv(ctx: ExecCtx) BuiltinError!std.process.Environ.Map {
var map = std.process.Environ.Map.init(ctx.allocator);
errdefer map.deinit();
for ([_][]const u8{
"HOME",
"PATH",
"MALT_PREFIX",
"HOMEBREW_PREFIX",
"HOMEBREW_CELLAR",
"TMPDIR",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TERM",
"SDKROOT",
"MACOSX_DEPLOYMENT_TARGET",
"CC",
"CXX",
"CFLAGS",
"CPPFLAGS",
"CXXFLAGS",
"LDFLAGS",
"MAKEFLAGS",
}) |key| if (try formulaEnvValue(ctx, key)) |value|
map.put(key, value) catch return BuiltinError.OutOfMemory;
return map;
}

/// system — execute a command
pub fn system(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Value {
if (args.len == 0) return Value{ .nil = {} };
Expand All @@ -100,10 +160,13 @@ pub fn system(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Value {
};

const raw = macos_sandbox.rawPassthroughEnabled(ctx.environ);
var env_map = try buildFormulaEnv(ctx);
defer env_map.deinit();
var child = std.process.spawn(ctx.io, .{
.argv = spawn_argv,
.stdout = childStdioMode(ctx.suppress_child_stdout, raw),
.stderr = childStdioMode(false, raw),
.environ_map = &env_map,
}) catch return BuiltinError.SystemCommandFailed;
const term = waitSanitized(ctx, &child, .{}) catch return BuiltinError.SystemCommandFailed;

Expand Down Expand Up @@ -265,7 +328,7 @@ pub fn pathnameNew(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Va
pub fn envGet(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!Value {
if (args.len == 0) return Value{ .nil = {} };
const key = args[0].asString(ctx.allocator) catch return Value{ .nil = {} };
if (std.process.Environ.getPosix(ctx.environ, key)) |val| {
if (try formulaEnvValue(ctx, key)) |val| {
return Value{ .string = val };
}
return Value{ .nil = {} };
Expand Down Expand Up @@ -296,10 +359,13 @@ pub fn safePopenRead(ctx: ExecCtx, _: ?Value, args: []const Value) BuiltinError!
error.PathSandboxViolation => return BuiltinError.PathSandboxViolation,
};

var env_map = try buildFormulaEnv(ctx);
defer env_map.deinit();
var child = std.process.spawn(ctx.io, .{
.argv = spawn_argv,
.stdout = .pipe,
.stderr = .ignore,
.environ_map = &env_map,
}) catch return Value{ .string = "" };

const stdout = child.stdout orelse return Value{ .string = "" };
Expand Down
42 changes: 35 additions & 7 deletions tests/dsl_builtins_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1033,13 +1033,11 @@ test "pathnameNew wraps a string into a Pathname value" {
test "envGet returns nil for absent keys, string for present keys" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
// ctx.environ is a snapshot captured at construction; mutate the
// process env first so the snapshot reflects the test fixture.
_ = c.setenv("MALT_DSL_ENV_TEST", "yes", 1);
defer _ = c.unsetenv("MALT_DSL_ENV_TEST");
const ctx = arenaCtx(&arena, "/tmp/malt");
const got = try process.envGet(ctx, null, &.{.{ .string = "MALT_DSL_ENV_TEST" }});
try testing.expectEqualStrings("yes", got.string);
const entries = [_:null]?[*:0]const u8{"LANG=malt-test-locale"};
var ctx = arenaCtx(&arena, "/tmp/malt");
ctx.environ = .{ .block = .{ .slice = &entries } };
const got = try process.envGet(ctx, null, &.{.{ .string = "LANG" }});
try testing.expectEqualStrings("malt-test-locale", got.string);

const missing = try process.envGet(ctx, null, &.{.{ .string = "MALT_DSL_DOES_NOT_EXIST_XYZ" }});
try testing.expect(missing == .nil);
Expand All @@ -1048,6 +1046,17 @@ test "envGet returns nil for absent keys, string for present keys" {
try testing.expect(noargs == .nil);
}

test "envGet refuses sensitive parent credentials" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const entries = [_:null]?[*:0]const u8{"MALT_GITHUB_TOKEN=private-token"};
var ctx = arenaCtx(&arena, "/tmp/malt");
ctx.environ = .{ .block = .{ .slice = &entries } };

const got = try process.envGet(ctx, null, &.{.{ .string = "MALT_GITHUB_TOKEN" }});
try testing.expect(got == .nil);
}

test "envSet does not touch the real environment but returns the written value" {
const ctx = mkCtx("/tmp/malt");
const v = try process.envSet(ctx, null, &.{ .{ .string = "MALT_DSL_UNSET_KEY" }, .{ .string = "val" } });
Expand Down Expand Up @@ -1078,6 +1087,25 @@ test "safePopenRead captures stdout and chomps trailing newline" {
try testing.expectEqualStrings("hello", v.string);
}

test "safePopenRead does not pass parent credentials to the child" {
try test_io.skipIfNoSubprocess();
if (@import("builtin").os.tag != .macos) return error.SkipZigTest;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();

_ = c.setenv("MALT_GITHUB_TOKEN", "private-child-token", 1);
defer _ = c.unsetenv("MALT_GITHUB_TOKEN");
var lio = LiveIo.init();
defer lio.deinit();

const got = try process.safePopenRead(
arenaCtxLive(&arena, &lio, "/tmp/malt"),
null,
&.{.{ .string = "/usr/bin/env" }},
);
try testing.expect(std.mem.indexOf(u8, got.string, "private-child-token") == null);
}

// ---------------------------------------------------------------------------
// Version-style accessors on strings — `.major`, `.minor`, `.patch`, `.to_i`
//
Expand Down
Loading