lib/sandbox/src/layer.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const builtin = @import("builtin");
3 const sys = @import("sys");
4 const change = @import("change.zig");
5 const scan = @import("scan.zig");
6
7 const Allocator = std.mem.Allocator;
8 const fs_io = sys.fs.debugIo();
9 const root_dir_name = "root";
10
11 pub const Options = struct {
12 scratch: sys.fs.Dir,
13 source: ?sys.fs.Dir = null,
14 prefix: []const u8 = "sandbox",
15 max_file_bytes: usize = 16 * 1024 * 1024,
16 };
17
18 pub const Layer = struct {
19 allocator: Allocator,
20 scratch: sys.fs.Dir,
21 root: sys.fs.Dir,
22 path: []u8,
23 cleanup: bool = true,
24 skipped: std.ArrayList([]u8) = .empty,
25
26 pub fn deinit(self: *Layer) void {
27 self.root.close(fs_io);
28 if (self.cleanup) self.scratch.deleteTree(fs_io, self.path) catch {};
29 for (self.skipped.items) |item| self.allocator.free(item);
30 self.skipped.deinit(self.allocator);
31 self.allocator.free(self.path);
32 self.* = undefined;
33 }
34
35 pub fn readFileAlloc(self: *const Layer, allocator: Allocator, path: []const u8, limit: usize) ![]u8 {
36 return try self.root.readFileAlloc(fs_io, path, allocator, .limited(limit));
37 }
38 };
39
40 pub fn create(allocator: Allocator, options: Options) !Layer {
41 var layer = try createEmpty(allocator, options.scratch, options.prefix);
42 errdefer layer.deinit();
43 if (options.source) |source| try copyTree(allocator, source, layer.root, options.max_file_bytes, &layer.skipped);
44 return layer;
45 }
46
47 fn createEmpty(allocator: Allocator, scratch: sys.fs.Dir, prefix: []const u8) !Layer {
48 var attempt: usize = 0;
49 while (attempt < 128) : (attempt += 1) {
50 const name = try uniqueNameAlloc(allocator, prefix, attempt);
51 errdefer allocator.free(name);
52 scratch.createDir(fs_io, name, .default_dir) catch |err| switch (err) {
53 error.PathAlreadyExists => {
54 allocator.free(name);
55 continue;
56 },
57 else => return err,
58 };
59 errdefer scratch.deleteTree(fs_io, name) catch {};
60 var container = try scratch.openDir(fs_io, name, .{});
61 defer container.close(fs_io);
62 try container.createDir(fs_io, root_dir_name, .default_dir);
63 const root = try container.openDir(fs_io, root_dir_name, .{ .iterate = true });
64 return .{
65 .allocator = allocator,
66 .scratch = scratch,
67 .root = root,
68 .path = name,
69 };
70 }
71 return error.UniquePathExhausted;
72 }
73
74 fn uniqueNameAlloc(allocator: Allocator, prefix: []const u8, attempt: usize) Allocator.Error![]u8 {
75 const pid = sys.process.currentProcessId() catch 0;
76 const now = sys.time.nanoTimestamp();
77 return try std.fmt.allocPrint(allocator, "{s}-{x}-{x}-{d}", .{ prefix, pid, now, attempt });
78 }
79
80 fn copyTree(allocator: Allocator, source: sys.fs.Dir, dest: sys.fs.Dir, max_file_bytes: usize, skipped: *std.ArrayList([]u8)) !void {
81 var dir = try source.openDir(fs_io, ".", .{ .iterate = true });
82 defer dir.close(fs_io);
83 var walker = try dir.walk(allocator);
84 defer walker.deinit();
85 while (try walker.next(fs_io)) |entry| {
86 switch (entry.kind) {
87 .directory => try dest.createDirPath(fs_io, entry.path),
88 .file => switch (try copyFile(source, dest, entry.path, max_file_bytes)) {
89 .copied => {},
90 .skipped => try skipped.append(allocator, try allocator.dupe(u8, entry.path)),
91 },
92 .sym_link => try copyLink(source, dest, entry.path),
93 else => return error.UnsupportedSourceEntry,
94 }
95 }
96 }
97
98 const CopyOutcome = enum { copied, skipped };
99
100 fn copyFile(source: sys.fs.Dir, dest: sys.fs.Dir, path: []const u8, max_file_bytes: usize) !CopyOutcome {
101 var input = try source.openFile(fs_io, path, .{});
102 defer input.close(fs_io);
103 const source_stat = try input.stat(fs_io);
104 if (source_stat.size > max_file_bytes) return .skipped;
105 if (std.fs.path.dirname(path)) |parent| {
106 if (parent.len != 0) try dest.createDirPath(fs_io, parent);
107 }
108 var output = try dest.createFile(fs_io, path, .{
109 .truncate = true,
110 .permissions = source_stat.permissions,
111 });
112 defer output.close(fs_io);
113
114 var reader_buffer: [16 * 1024]u8 = undefined;
115 var reader = input.reader(fs_io, &reader_buffer);
116 var writer_buffer: [16 * 1024]u8 = undefined;
117 var writer = output.writer(fs_io, &writer_buffer);
118 var chunk: [16 * 1024]u8 = undefined;
119 while (true) {
120 const count = reader.interface.readSliceShort(&chunk) catch |err| switch (err) {
121 error.ReadFailed => return reader.err orelse error.ReadFailed,
122 };
123 if (count == 0) break;
124 try writer.interface.writeAll(chunk[0..count]);
125 }
126 try writer.interface.flush();
127 try output.setPermissions(fs_io, source_stat.permissions);
128 return .copied;
129 }
130
131 fn copyLink(source: sys.fs.Dir, dest: sys.fs.Dir, path: []const u8) !void {
132 if (std.fs.path.dirname(path)) |parent| {
133 if (parent.len != 0) try dest.createDirPath(fs_io, parent);
134 }
135 var target_buffer: [std.fs.max_path_bytes]u8 = undefined;
136 const target_len = try source.readLink(fs_io, path, &target_buffer);
137 try dest.symLink(fs_io, target_buffer[0..target_len], path, .{ .is_directory = linkPointsToDirectory(source, path) });
138 }
139
140 fn linkPointsToDirectory(source: sys.fs.Dir, path: []const u8) bool {
141 const stat = source.statFile(fs_io, path, .{}) catch return false;
142 return stat.kind == .directory;
143 }
144
145 fn createFifo(dir: sys.fs.Dir, path: [:0]const u8) !void {
146 if (builtin.os.tag != .linux) return error.SkipZigTest;
147 return sys.fs.createNamedPipe(dir, path, 0o600) catch |err| switch (err) {
148 error.AccessDenied, error.UnsupportedPlatform => error.SkipZigTest,
149 error.CreateFailed => error.CreateFifoFailed,
150 };
151 }
152
153 fn scratchEntryCount(dir: sys.fs.Dir, allocator: Allocator) !usize {
154 var opened = try dir.openDir(fs_io, ".", .{ .iterate = true });
155 defer opened.close(fs_io);
156 var walker = try opened.walk(allocator);
157 defer walker.deinit();
158 var count: usize = 0;
159 while (try walker.next(fs_io)) |_| count += 1;
160 return count;
161 }
162
163 test "layer copies source tree and cleans up" {
164 var source = std.testing.tmpDir(.{});
165 defer source.cleanup();
166 var scratch = std.testing.tmpDir(.{});
167 defer scratch.cleanup();
168
169 try source.dir.createDirPath(fs_io, "dir");
170 try source.dir.writeFile(fs_io, .{ .sub_path = "dir/a.txt", .data = "alpha" });
171
172 var value = try create(std.testing.allocator, .{
173 .scratch = scratch.dir,
174 .source = source.dir,
175 .prefix = "layer-test",
176 });
177 const path = try std.testing.allocator.dupe(u8, value.path);
178 defer std.testing.allocator.free(path);
179
180 const content = try value.readFileAlloc(std.testing.allocator, "dir/a.txt", 1024);
181 defer std.testing.allocator.free(content);
182 try std.testing.expectEqualStrings("alpha", content);
183
184 value.deinit();
185 try std.testing.expectError(error.FileNotFound, scratch.dir.statFile(fs_io, path, .{}));
186 }
187
188 test "layer preserves source symlinks" {
189 var source = std.testing.tmpDir(.{});
190 defer source.cleanup();
191 var scratch = std.testing.tmpDir(.{});
192 defer scratch.cleanup();
193
194 try source.dir.writeFile(fs_io, .{ .sub_path = "target.txt", .data = "target" });
195 source.dir.symLink(fs_io, "target.txt", "link", .{}) catch |err| switch (err) {
196 error.AccessDenied, error.PermissionDenied => return error.SkipZigTest,
197 else => return err,
198 };
199
200 var value = try create(std.testing.allocator, .{
201 .scratch = scratch.dir,
202 .source = source.dir,
203 .prefix = "layer-link",
204 });
205 defer value.deinit();
206
207 var target_buffer: [std.fs.max_path_bytes]u8 = undefined;
208 const target_len = try value.root.readLink(fs_io, "link", &target_buffer);
209 try std.testing.expectEqualStrings("target.txt", target_buffer[0..target_len]);
210
211 var snapshot = try scan.capture(value.root, std.testing.allocator);
212 defer snapshot.deinit();
213 try std.testing.expectEqual(change.Kind.sym_link, snapshot.find("link").?.kind);
214 }
215
216 test "layer preserves source file permissions" {
217 if (comptime !sys.fs.FilePermissions.has_executable_bit) return error.SkipZigTest;
218
219 var source = std.testing.tmpDir(.{});
220 defer source.cleanup();
221 var scratch = std.testing.tmpDir(.{});
222 defer scratch.cleanup();
223
224 try source.dir.writeFile(fs_io, .{ .sub_path = "tool.sh", .data = "#!/bin/sh\n" });
225 try source.dir.setFilePermissions(fs_io, "tool.sh", .executable_file, .{});
226
227 var value = try create(std.testing.allocator, .{
228 .scratch = scratch.dir,
229 .source = source.dir,
230 .prefix = "layer-mode",
231 });
232 defer value.deinit();
233
234 const stat = try value.root.statFile(fs_io, "tool.sh", .{});
235 try std.testing.expect((stat.permissions.toMode() & 0o111) != 0);
236 }
237
238 test "layer rejects unsupported source entries and cleans up" {
239 var source = std.testing.tmpDir(.{});
240 defer source.cleanup();
241 var scratch = std.testing.tmpDir(.{});
242 defer scratch.cleanup();
243
244 try createFifo(source.dir, "pipe");
245
246 try std.testing.expectError(error.UnsupportedSourceEntry, create(std.testing.allocator, .{
247 .scratch = scratch.dir,
248 .source = source.dir,
249 .prefix = "layer-fifo",
250 }));
251 try std.testing.expectEqual(@as(usize, 0), try scratchEntryCount(scratch.dir, std.testing.allocator));
252 }
253
254 test "layer skips oversized source files and records them" {
255 var source = std.testing.tmpDir(.{});
256 defer source.cleanup();
257 var scratch = std.testing.tmpDir(.{});
258 defer scratch.cleanup();
259
260 try source.dir.writeFile(fs_io, .{ .sub_path = "huge.txt", .data = "abcdef" });
261 try source.dir.writeFile(fs_io, .{ .sub_path = "small.txt", .data = "ok" });
262
263 var value = try create(std.testing.allocator, .{
264 .scratch = scratch.dir,
265 .source = source.dir,
266 .prefix = "layer-size",
267 .max_file_bytes = 2,
268 });
269 defer value.deinit();
270
271 try std.testing.expectEqual(@as(usize, 1), value.skipped.items.len);
272 try std.testing.expectEqualStrings("huge.txt", value.skipped.items[0]);
273 try std.testing.expectError(error.FileNotFound, value.root.statFile(fs_io, "huge.txt", .{}));
274
275 const small = try value.readFileAlloc(std.testing.allocator, "small.txt", 1024);
276 defer std.testing.allocator.free(small);
277 try std.testing.expectEqualStrings("ok", small);
278 }