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 }