lib/sandbox/src/confined.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2
3 pub const arguments_bytes_max = 64 * 1024;
4 pub const runtime_paths_max = 128;
5 pub const environment_max = 32;
6 pub const environment_bytes_max = 64 * 1024;
7
8 pub const Variable = struct {
9 name: []const u8,
10 value: []const u8,
11 };
12
13 pub const Command = struct {
14 name: []const u8,
15 path: []const u8,
16 };
17
18 /// Describes the inputs, output directory, command arguments, and environment
19 /// for a single Bubblewrap launch. Calling `launch` uses this description to
20 /// construct argument strings and standard input configuration bytes, but does
21 /// not run the command itself.
22 ///
23 /// Both `source` and `output` must be absolute paths. When configured,
24 /// Bubblewrap binds `source` read-only at `/workspace`, binds `output` writable
25 /// at `/output`, and sets `/workspace` as the working directory. The plan also
26 /// accepts between 1 and 128 top-level `/nix/store` entries mounted read-only
27 /// at their host paths. Validation verifies string formatting for these Nix
28 /// store paths without checking host file existence or dependency closures.
29 /// Standard configuration adds `/proc`, `/dev`, and a temporary `/tmp` mount,
30 /// so child visibility is not restricted to declared read-only inputs.
31 ///
32 /// Execution clears inherited environment variables and sets only explicitly
33 /// declared variables. The plan requests unshared Linux namespaces, a new
34 /// process session, and parent-death termination so the command dies with the
35 /// parent process.
36 ///
37 /// String slices in `Plan` are borrowed from the caller. The returned `Launch`
38 /// copies paths and environment variables into its standard input buffer, but
39 /// borrows the command argument strings directly. Callers must keep those
40 /// argument strings alive as long as the resulting `Launch` is in use.
41 pub const Plan = struct {
42 source: []const u8,
43 output: []const u8,
44 runtime: []const []const u8,
45 environment: []const Variable,
46 argv: []const []const u8,
47 commands: []const Command = &.{},
48 };
49
50 pub const Launch = struct {
51 argv: []const []const u8,
52 stdin: []const u8,
53
54 pub fn deinit(self: *Launch, allocator: std.mem.Allocator) void {
55 allocator.free(self.argv);
56 allocator.free(self.stdin);
57 self.* = undefined;
58 }
59 };
60
61 /// Builds the command arguments and standard input configuration buffer for
62 /// Bubblewrap, keeping child arguments separate from Bubblewrap option parsing.
63 ///
64 /// The resulting argument array begins with `launcher`, `--args`, `0`, and
65 /// `--`, followed by the child command arguments. Configuration options pass as
66 /// NUL-separated bytes over standard input capped at 64 KiB, ensuring child
67 /// arguments matching Bubblewrap flag syntax are never interpreted as launcher
68 /// options.
69 ///
70 /// Before allocating the argument array, structural validation confirms that
71 /// `source` and `output` are absolute, runtime Nix store entries number from 1
72 /// to 128, environment variables satisfy size limits, and command arguments
73 /// contain between 1 and 319 valid strings. Later serialization steps check
74 /// path text formatting and verify that total option bytes fit within the 64
75 /// KiB limit. When serialization or allocation fails, the function frees any
76 /// partial allocations and returns the error.
77 ///
78 /// The returned `Launch` owns the argument array and the serialized option
79 /// bytes, both freed by calling `deinit` with `allocator`. Individual argument
80 /// strings for the launcher and child command remain borrowed from the caller
81 /// and must outlive the `Launch` instance.
82 pub fn launch(allocator: std.mem.Allocator, launcher: []const u8, plan: Plan) !Launch {
83 try validate(plan);
84 const argv = try allocator.alloc([]const u8, plan.argv.len + 4);
85 errdefer allocator.free(argv);
86 @memcpy(argv[0..4], &[_][]const u8{ launcher, "--args", "0", "--" });
87 @memcpy(argv[4..], plan.argv);
88 return .{ .argv = argv, .stdin = try arguments(allocator, plan) };
89 }
90
91 fn arguments(allocator: std.mem.Allocator, plan: Plan) ![]u8 {
92 try validate(plan);
93 var bytes: [arguments_bytes_max]u8 = undefined;
94 var writer = std.Io.Writer.fixed(&bytes);
95 try append(&writer, &.{ "--unshare-all", "--die-with-parent", "--new-session", "--clearenv" });
96 for (plan.runtime) |path| try append(&writer, &.{ "--ro-bind", path, path });
97 try append(&writer, &.{
98 "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
99 "--ro-bind", plan.source, "/workspace", "--bind", plan.output, "/output",
100 "--chdir", "/workspace",
101 });
102 if (plan.commands.len != 0) {
103 try append(&writer, &.{ "--dir", "/tmp/tiny-bin" });
104 for (plan.commands) |command| {
105 const destination = try std.fmt.allocPrint(allocator, "/tmp/tiny-bin/{s}", .{command.name});
106 defer allocator.free(destination);
107 try append(&writer, &.{ "--ro-bind", command.path, destination });
108 }
109 }
110 for (plan.environment) |variable| {
111 try append(&writer, &.{ "--setenv", variable.name, variable.value });
112 }
113 return allocator.dupe(u8, writer.buffered());
114 }
115
116 fn append(writer: *std.Io.Writer, values: []const []const u8) !void {
117 for (values) |value| {
118 try text(value);
119 try writer.writeAll(value);
120 try writer.writeByte(0);
121 }
122 }
123
124 fn validate(plan: Plan) !void {
125 if (!std.fs.path.isAbsolute(plan.source) or !std.fs.path.isAbsolute(plan.output)) {
126 return error.InvalidConfinementPath;
127 }
128 if (plan.runtime.len == 0 or plan.runtime.len > runtime_paths_max) {
129 return error.ConfinementRuntimeCapacityExceeded;
130 }
131 try validateEnvironment(plan.environment);
132 if (plan.argv.len == 0 or plan.argv.len > 319) return error.InvalidConfinementArguments;
133 for (plan.argv) |argument| try text(argument);
134 for (plan.runtime) |path| {
135 if (!std.mem.startsWith(u8, path, "/nix/store/") or
136 std.mem.indexOfScalar(u8, path[11..], '/') != null or path.len < 45)
137 {
138 return error.InvalidConfinementRuntime;
139 }
140 }
141 if (plan.commands.len > 16) return error.ConfinementRuntimeCapacityExceeded;
142 for (plan.commands, 0..) |command, index| {
143 if (command.name.len == 0 or !std.ascii.isAlphabetic(command.name[0]))
144 return error.InvalidConfinementCommand;
145 for (command.name) |byte| {
146 if (!std.ascii.isAlphanumeric(byte) and byte != '-' and byte != '_')
147 return error.InvalidConfinementCommand;
148 }
149 if (!std.fs.path.isAbsolute(command.path)) return error.InvalidConfinementCommand;
150 for (plan.commands[0..index]) |prior| {
151 if (std.mem.eql(u8, prior.name, command.name)) return error.InvalidConfinementCommand;
152 }
153 }
154 }
155
156 /// Validates declared environment variables against structural and encoding
157 /// constraints before a caller copies them for launch.
158 ///
159 /// The slice must contain at most 32 variables, with the combined length of all
160 /// name and value bytes capped at 64 KiB. Exceeding either boundary returns
161 /// `error.ConfinementEnvironmentCapacityExceeded`. Variable names must not be
162 /// empty, contain `=`, or repeat across the slice, or validation fails with
163 /// `error.InvalidConfinementEnvironment`. Any NUL byte or invalid UTF-8
164 /// sequence in a name or value returns `error.InvalidConfinementText`.
165 ///
166 /// Because the 64 KiB check measures only raw name and value bytes without
167 /// option framing overhead, a valid environment is not guaranteed to fit inside
168 /// the final launch option buffer.
169 pub fn validateEnvironment(environment: []const Variable) !void {
170 if (environment.len > environment_max) return error.ConfinementEnvironmentCapacityExceeded;
171 var total: usize = 0;
172 for (environment, 0..) |variable, index| {
173 try text(variable.name);
174 try text(variable.value);
175 total = std.math.add(usize, total, variable.name.len) catch
176 return error.ConfinementEnvironmentCapacityExceeded;
177 total = std.math.add(usize, total, variable.value.len) catch
178 return error.ConfinementEnvironmentCapacityExceeded;
179 if (total > environment_bytes_max) return error.ConfinementEnvironmentCapacityExceeded;
180 if (variable.name.len == 0 or std.mem.indexOfScalar(u8, variable.name, '=') != null) {
181 return error.InvalidConfinementEnvironment;
182 }
183 for (environment[0..index]) |prior| {
184 if (std.mem.eql(u8, prior.name, variable.name)) {
185 return error.InvalidConfinementEnvironment;
186 }
187 }
188 }
189 }
190
191 fn text(value: []const u8) !void {
192 if (std.mem.indexOfScalar(u8, value, 0) != null or !std.unicode.utf8ValidateSlice(value)) {
193 return error.InvalidConfinementText;
194 }
195 }
196
197 test "confined arguments expose only declared runtime and separate captured source" {
198 const runtime = "/nix/store/00000000000000000000000000000000-runtime";
199 var result = try launch(std.testing.allocator, "/usr/bin/bwrap", .{
200 .source = "/retained/source",
201 .output = "/retained/output",
202 .runtime = &.{runtime},
203 .environment = &.{.{ .name = "LANG", .value = "C" }},
204 .argv = &.{ runtime ++ "/bin/check", "literal\n$()", "" },
205 });
206 defer result.deinit(std.testing.allocator);
207 const bytes = result.stdin;
208 try std.testing.expect(std.mem.indexOf(u8, bytes, "--clearenv\x00") != null);
209 try std.testing.expect(std.mem.indexOf(u8, bytes, "--share-net") == null);
210 try std.testing.expect(
211 std.mem.indexOf(u8, bytes, "--ro-bind\x00/retained/source\x00/workspace") != null,
212 );
213 try std.testing.expectEqualStrings("literal\n$()", result.argv[result.argv.len - 2]);
214 try std.testing.expectEqualStrings("", result.argv[result.argv.len - 1]);
215 }
216
217 test "confined private command path binds only named executables" {
218 const runtime = "/nix/store/00000000000000000000000000000000-runtime";
219 var result = try launch(std.testing.allocator, "/usr/bin/bwrap", .{
220 .source = "/retained/source",
221 .output = "/retained/output",
222 .runtime = &.{runtime},
223 .environment = &.{.{ .name = "PATH", .value = "/tmp/tiny-bin" }},
224 .argv = &.{runtime ++ "/bin/check"},
225 .commands = &.{.{ .name = "sh", .path = runtime ++ "/bin/sh" }},
226 });
227 defer result.deinit(std.testing.allocator);
228 try std.testing.expect(std.mem.indexOf(u8, result.stdin, "--dir\x00/tmp/tiny-bin\x00") != null);
229 try std.testing.expect(std.mem.indexOf(u8, result.stdin, "--ro-bind\x00" ++ runtime ++ "/bin/sh\x00/tmp/tiny-bin/sh\x00") != null);
230 try std.testing.expect(std.mem.indexOf(u8, result.stdin, "/tmp/tiny-bin/date") == null);
231 try std.testing.expectError(error.InvalidConfinementCommand, launch(std.testing.allocator, "/usr/bin/bwrap", .{
232 .source = "/retained/source",
233 .output = "/retained/output",
234 .runtime = &.{runtime},
235 .environment = &.{},
236 .argv = &.{runtime ++ "/bin/check"},
237 .commands = &.{.{ .name = "../date", .path = runtime ++ "/bin/date" }},
238 }));
239 }