tiny.sandbox.confined
Defined in tiny.sandbox.
API (11)
Actions
Public operations.
Launch.deinitlaunch: Builds the command arguments and standard input configuration buffer for Bubblewrap, keeping child arguments separate from Bubblewrap option parsing.validateEnvironment: Validates declared environment variables against structural and encoding constraints before a caller copies them for launch.
Types and contracts
Public types and contracts.
CommandLaunchPlan: Describes the inputs, output directory, command arguments, and environment for a single Bubblewrap launch.Variable
Values and defaults
Public values and defaults.
Source
Source: lib/sandbox/src/confined.zig
zig
const std = @import("std");pub const arguments_bytes_max = 64 * 1024;pub const runtime_paths_max = 128;pub const environment_max = 32;pub const environment_bytes_max = 64 * 1024;pub const Variable = struct { name: []const u8, value: []const u8,};pub const Command = struct { name: []const u8, path: []const u8,};/// Describes the inputs, output directory, command arguments, and environment/// for a single Bubblewrap launch. Calling `launch` uses this description to/// construct argument strings and standard input configuration bytes, but does/// not run the command itself.////// Both `source` and `output` must be absolute paths. When configured,/// Bubblewrap binds `source` read-only at `/workspace`, binds `output` writable/// at `/output`, and sets `/workspace` as the working directory. The plan also/// accepts between 1 and 128 top-level `/nix/store` entries mounted read-only/// at their host paths. Validation verifies string formatting for these Nix/// store paths without checking host file existence or dependency closures./// Standard configuration adds `/proc`, `/dev`, and a temporary `/tmp` mount,/// so child visibility is not restricted to declared read-only inputs.////// Execution clears inherited environment variables and sets only explicitly/// declared variables. The plan requests unshared Linux namespaces, a new/// process session, and parent-death termination so the command dies with the/// parent process.////// String slices in `Plan` are borrowed from the caller. The returned `Launch`/// copies paths and environment variables into its standard input buffer, but/// borrows the command argument strings directly. Callers must keep those/// argument strings alive as long as the resulting `Launch` is in use.pub const Plan = struct { source: []const u8, output: []const u8, runtime: []const []const u8, environment: []const Variable, argv: []const []const u8, commands: []const Command = &.{},};pub const Launch = struct { argv: []const []const u8, stdin: []const u8, pub fn deinit(self: *Launch, allocator: std.mem.Allocator) void { allocator.free(self.argv); allocator.free(self.stdin); self.* = undefined; }};/// Builds the command arguments and standard input configuration buffer for/// Bubblewrap, keeping child arguments separate from Bubblewrap option parsing.////// The resulting argument array begins with `launcher`, `--args`, `0`, and/// `--`, followed by the child command arguments. Configuration options pass as/// NUL-separated bytes over standard input capped at 64 KiB, ensuring child/// arguments matching Bubblewrap flag syntax are never interpreted as launcher/// options.////// Before allocating the argument array, structural validation confirms that/// `source` and `output` are absolute, runtime Nix store entries number from 1/// to 128, environment variables satisfy size limits, and command arguments/// contain between 1 and 319 valid strings. Later serialization steps check/// path text formatting and verify that total option bytes fit within the 64/// KiB limit. When serialization or allocation fails, the function frees any/// partial allocations and returns the error.////// The returned `Launch` owns the argument array and the serialized option/// bytes, both freed by calling `deinit` with `allocator`. Individual argument/// strings for the launcher and child command remain borrowed from the caller/// and must outlive the `Launch` instance.pub fn launch(allocator: std.mem.Allocator, launcher: []const u8, plan: Plan) !Launch { try validate(plan); const argv = try allocator.alloc([]const u8, plan.argv.len + 4); errdefer allocator.free(argv); @memcpy(argv[0..4], &[_][]const u8{ launcher, "--args", "0", "--" }); @memcpy(argv[4..], plan.argv); return .{ .argv = argv, .stdin = try arguments(allocator, plan) };}fn arguments(allocator: std.mem.Allocator, plan: Plan) ![]u8 { try validate(plan); var bytes: [arguments_bytes_max]u8 = undefined; var writer = std.Io.Writer.fixed(&bytes); try append(&writer, &.{ "--unshare-all", "--die-with-parent", "--new-session", "--clearenv" }); for (plan.runtime) |path| try append(&writer, &.{ "--ro-bind", path, path }); try append(&writer, &.{ "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--ro-bind", plan.source, "/workspace", "--bind", plan.output, "/output", "--chdir", "/workspace", }); if (plan.commands.len != 0) { try append(&writer, &.{ "--dir", "/tmp/tiny-bin" }); for (plan.commands) |command| { const destination = try std.fmt.allocPrint(allocator, "/tmp/tiny-bin/{s}", .{command.name}); defer allocator.free(destination); try append(&writer, &.{ "--ro-bind", command.path, destination }); } } for (plan.environment) |variable| { try append(&writer, &.{ "--setenv", variable.name, variable.value }); } return allocator.dupe(u8, writer.buffered());}fn append(writer: *std.Io.Writer, values: []const []const u8) !void { for (values) |value| { try text(value); try writer.writeAll(value); try writer.writeByte(0); }}fn validate(plan: Plan) !void { if (!std.fs.path.isAbsolute(plan.source) or !std.fs.path.isAbsolute(plan.output)) { return error.InvalidConfinementPath; } if (plan.runtime.len == 0 or plan.runtime.len > runtime_paths_max) { return error.ConfinementRuntimeCapacityExceeded; } try validateEnvironment(plan.environment); if (plan.argv.len == 0 or plan.argv.len > 319) return error.InvalidConfinementArguments; for (plan.argv) |argument| try text(argument); for (plan.runtime) |path| { if (!std.mem.startsWith(u8, path, "/nix/store/") or std.mem.indexOfScalar(u8, path[11..], '/') != null or path.len < 45) { return error.InvalidConfinementRuntime; } } if (plan.commands.len > 16) return error.ConfinementRuntimeCapacityExceeded; for (plan.commands, 0..) |command, index| { if (command.name.len == 0 or !std.ascii.isAlphabetic(command.name[0])) return error.InvalidConfinementCommand; for (command.name) |byte| { if (!std.ascii.isAlphanumeric(byte) and byte != '-' and byte != '_') return error.InvalidConfinementCommand; } if (!std.fs.path.isAbsolute(command.path)) return error.InvalidConfinementCommand; for (plan.commands[0..index]) |prior| { if (std.mem.eql(u8, prior.name, command.name)) return error.InvalidConfinementCommand; } }}/// Validates declared environment variables against structural and encoding/// constraints before a caller copies them for launch.////// The slice must contain at most 32 variables, with the combined length of all/// name and value bytes capped at 64 KiB. Exceeding either boundary returns/// `error.ConfinementEnvironmentCapacityExceeded`. Variable names must not be/// empty, contain `=`, or repeat across the slice, or validation fails with/// `error.InvalidConfinementEnvironment`. Any NUL byte or invalid UTF-8/// sequence in a name or value returns `error.InvalidConfinementText`.////// Because the 64 KiB check measures only raw name and value bytes without/// option framing overhead, a valid environment is not guaranteed to fit inside/// the final launch option buffer.pub fn validateEnvironment(environment: []const Variable) !void { if (environment.len > environment_max) return error.ConfinementEnvironmentCapacityExceeded; var total: usize = 0; for (environment, 0..) |variable, index| { try text(variable.name); try text(variable.value); total = std.math.add(usize, total, variable.name.len) catch return error.ConfinementEnvironmentCapacityExceeded; total = std.math.add(usize, total, variable.value.len) catch return error.ConfinementEnvironmentCapacityExceeded; if (total > environment_bytes_max) return error.ConfinementEnvironmentCapacityExceeded; if (variable.name.len == 0 or std.mem.indexOfScalar(u8, variable.name, '=') != null) { return error.InvalidConfinementEnvironment; } for (environment[0..index]) |prior| { if (std.mem.eql(u8, prior.name, variable.name)) { return error.InvalidConfinementEnvironment; } } }}fn text(value: []const u8) !void { if (std.mem.indexOfScalar(u8, value, 0) != null or !std.unicode.utf8ValidateSlice(value)) { return error.InvalidConfinementText; }}test "confined arguments expose only declared runtime and separate captured source" { const runtime = "/nix/store/00000000000000000000000000000000-runtime"; var result = try launch(std.testing.allocator, "/usr/bin/bwrap", .{ .source = "/retained/source", .output = "/retained/output", .runtime = &.{runtime}, .environment = &.{.{ .name = "LANG", .value = "C" }}, .argv = &.{ runtime ++ "/bin/check", "literal\n$()", "" }, }); defer result.deinit(std.testing.allocator); const bytes = result.stdin; try std.testing.expect(std.mem.indexOf(u8, bytes, "--clearenv\x00") != null); try std.testing.expect(std.mem.indexOf(u8, bytes, "--share-net") == null); try std.testing.expect( std.mem.indexOf(u8, bytes, "--ro-bind\x00/retained/source\x00/workspace") != null, ); try std.testing.expectEqualStrings("literal\n$()", result.argv[result.argv.len - 2]); try std.testing.expectEqualStrings("", result.argv[result.argv.len - 1]);}test "confined private command path binds only named executables" { const runtime = "/nix/store/00000000000000000000000000000000-runtime"; var result = try launch(std.testing.allocator, "/usr/bin/bwrap", .{ .source = "/retained/source", .output = "/retained/output", .runtime = &.{runtime}, .environment = &.{.{ .name = "PATH", .value = "/tmp/tiny-bin" }}, .argv = &.{runtime ++ "/bin/check"}, .commands = &.{.{ .name = "sh", .path = runtime ++ "/bin/sh" }}, }); defer result.deinit(std.testing.allocator); try std.testing.expect(std.mem.indexOf(u8, result.stdin, "--dir\x00/tmp/tiny-bin\x00") != null); try std.testing.expect(std.mem.indexOf(u8, result.stdin, "--ro-bind\x00" ++ runtime ++ "/bin/sh\x00/tmp/tiny-bin/sh\x00") != null); try std.testing.expect(std.mem.indexOf(u8, result.stdin, "/tmp/tiny-bin/date") == null); try std.testing.expectError(error.InvalidConfinementCommand, launch(std.testing.allocator, "/usr/bin/bwrap", .{ .source = "/retained/source", .output = "/retained/output", .runtime = &.{runtime}, .environment = &.{}, .argv = &.{runtime ++ "/bin/check"}, .commands = &.{.{ .name = "../date", .path = runtime ++ "/bin/date" }}, }));}Source: lib/sandbox/src/root.zig:33
zig
pub const confined = @import("confined.zig");Audit
| Definitions | 12 |
|---|---|
| Public names | 12 |
| Members | 12 |
| Version | 26.7.0 |
| Revision | daab053ee433 |