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 }