1//! Represents one independent job whose responsibility is to:
2//!
3//! 1. Check the local zig package directory to see if the hash already exists.
4//! If so, load, parse, and validate the build.zig.zon file therein, and
5//! goto step 9. Likewise if the location is a relative path, treat this
6//! the same as a cache hit. Otherwise, proceed.
7//! 2. Check the global package cache for a compressed tarball matching the
8//! hash. If it is found, unpack the contents into a temporary directory inside
9//! project local zig cache. Rename this directory into the local zig package
10//! directory and goto step 9, skipping step 10.
11//! 3. Fetch and unpack a URL into a temporary directory.
12//! 4. Load, parse, and validate the build.zig.zon file therein. It is allowed
13//! for the file to be missing, in which case this fetched package is considered
14//! to be a "naked" package.
15//! 5. Apply inclusion rules of the build.zig.zon to the temporary directory by
16//! deleting excluded files. If any files had errors for files that were
17//! ultimately excluded, those errors should be ignored, such as failure to
18//! create symlinks that weren't supposed to be included anyway.
19//! 6. Compute the package hash based on the remaining files in the temporary
20//! directory.
21//! 7. Rename the temporary directory into the local zig package directory. If
22//! the hash already exists, delete the temporary directory and leave the zig
23//! package directory untouched as it may be in use. This is done even if
24//! the hash is invalid, in case the package with the different hash is used
25//! in the future.
26//! 8. Validate the computed hash against the expected hash. If invalid,
27//! this job is done.
28//! 9. Spawn a new fetch job for each dependency in the manifest file. Use
29//! a mutex and a hash map so that redundant jobs do not get queued up.
30//! 10.Compress the package directory and store it into the global package
31//! cache.
32//!
33//! All of this must be done with only referring to the state inside this struct
34//! because this work will be done in a dedicated thread.
35const Fetch = @This();
36
37const builtin = @import("builtin");
38const native_os = builtin.os.tag;
39
40const std = @import("std");
41const Io = std.Io;
42const fs = std.fs;
43const log = std.log.scoped(.fetch);
44const assert = std.debug.assert;
45const ascii = std.ascii;
46const Allocator = std.mem.Allocator;
47const Path = std.Build.Cache.Path;
48const Directory = std.Build.Cache.Directory;
49const git = @import("Fetch/git.zig");
50const Package = @import("Package.zig");
51const Manifest = Package.Manifest;
52const ErrorBundle = std.zig.ErrorBundle;
53
54arena: std.heap.ArenaAllocator,
55location: Location,
56location_tok: std.zig.Ast.TokenIndex,
57hash_tok: std.zig.Ast.OptionalTokenIndex,
58name_tok: std.zig.Ast.TokenIndex,
59lazy_status: LazyStatus,
60/// Same as `parent_packge_root` except it is unchanged when recursing into
61/// relative file paths (as opposed to URL).
62remote_package_root: Path,
63parent_package_root: Path,
64parent_manifest_ast: ?*const std.zig.Ast,
65prog_node: std.Progress.Node,
66job_queue: *JobQueue,
67/// If true, don't add an error for a missing hash. This flag is not passed
68/// down to recursive dependencies. It's intended to be used only be the CLI.
69omit_missing_hash_error: bool,
70/// If true, don't fail when a manifest file is missing the `paths` field,
71/// which specifies inclusion rules. This is intended to be true for the first
72/// fetch task and false for the recursive dependencies.
73allow_missing_paths_field: bool,
74/// If true and URL points to a Git repository, will use the latest commit.
75use_latest_commit: bool,
76
77// Above this are fields provided as inputs to `run`.
78// Below this are fields populated by `run`.
79
80/// Relative to the build root of the root package.
81package_root: Path,
82error_bundle: ErrorBundle.Wip,
83manifest: Manifest,
84manifest_ast: std.zig.Ast,
85have_manifest: bool,
86computed_hash: ComputedHash,
87/// Fetch logic notices whether a package has a build.zig file and sets this flag.
88has_build_zig: bool,
89/// Indicates whether the task aborted due to an out-of-memory condition.
90oom_flag: bool,
91/// If `use_latest_commit` was true, this will be set to the commit that was used.
92/// If the resource pointed to by the location is not a Git-repository, this
93/// will be left unchanged.
94latest_commit: ?git.Oid,
95
96// This field is used by the CLI only, untouched by this file.
97
98/// The module for this `Fetch` tasks's package, which exposes `build.zig` as
99/// the root source file.
100///
101/// This could be an opaque "userdata" field because this code does not observe
102/// this data in any way but let's have some type safety because we can.
103cli_module: ?*@import("../Maker.zig").CliModule,
104
105pub const LazyStatus = enum {
106 /// Not lazy.
107 eager,
108 /// Lazy, found.
109 available,
110 /// Lazy, not found.
111 unavailable,
112};
113
114pub const LocalStorage = struct {
115 cache_root: Path,
116 /// Path to "zig-pkg" inside the package in which the user ran `zig build`.
117 pkg_root: Path,
118};
119
120/// Contains shared state among all `Fetch` tasks.
121pub const JobQueue = struct {
122 io: Io,
123 mutex: Io.Mutex = .init,
124 /// It's an array hash map so that it can be sorted before rendering the
125 /// dependencies.zig source file.
126 /// Protected by `mutex`.
127 table: Table = .{},
128 /// `table` may be missing some tasks such as ones that failed, so this
129 /// field contains references to all of them.
130 /// Protected by `mutex`.
131 all_fetches: std.ArrayList(*Fetch) = .empty,
132 prog_node: std.Progress.Node,
133
134 http_client: *std.http.Client,
135 /// This tracks `Fetch` tasks as well as recompression tasks.
136 group: Io.Group = .init,
137 global_cache: Directory,
138 /// If `null`, indicates fetch globally only.
139 local_storage: ?*const LocalStorage,
140 /// If true then, no fetching occurs, and:
141 /// * The `global_cache` directory is assumed to be the direct parent
142 /// directory of on-disk packages rather than having the "p/" directory
143 /// prefix inside of it.
144 /// * An error occurs if any non-lazy packages are not already present in
145 /// the package cache directory.
146 /// * Missing hash field causes an error, and no fetching occurs so it does
147 /// not print the correct hash like usual.
148 read_only: bool,
149 recursive: bool,
150 /// Dumps hash information to stdout which can be used to troubleshoot why
151 /// two hashes of the same package do not match.
152 /// If this is true, `recursive` must be false.
153 debug_hash: bool,
154 mode: Mode,
155 /// Set of hashes that will be additionally fetched even if they are marked
156 /// as lazy.
157 unlazy_set: UnlazySet = .{},
158 /// Identifies paths that override all packages in the tree with matching
159 /// project ids.
160 fork_set: ForkSet = .{},
161
162 pub const Mode = enum {
163 /// Non-lazy dependencies are always fetched.
164 /// Lazy dependencies are fetched only when needed.
165 needed,
166 /// Both non-lazy and lazy dependencies are always fetched.
167 all,
168 };
169 pub const Table = std.array_hash_map.Auto(Package.Hash, *Fetch);
170 pub const UnlazySet = std.array_hash_map.Auto(Package.Hash, void);
171 pub const ForkSet = std.array_hash_map.Custom(Fork, void, Fork.Context, false);
172
173 pub const Fork = struct {
174 path: Path,
175 manifest_ast: std.zig.Ast,
176 manifest: Package.Manifest,
177 uses: usize,
178
179 pub const Context = struct {
180 pub fn hash(_: @This(), a: Fork) u32 {
181 const project_id: Package.ProjectId = .init(a.manifest.name, a.manifest.id);
182 return @truncate(project_id.hash());
183 }
184
185 pub fn eql(_: @This(), a: Fork, b: Fork, _: usize) bool {
186 const a_project_id: Package.ProjectId = .init(a.manifest.name, a.manifest.id);
187 const b_project_id: Package.ProjectId = .init(b.manifest.name, b.manifest.id);
188 return a_project_id.eql(&b_project_id);
189 }
190 };
191
192 pub const Adapter = struct {
193 pub fn hash(_: @This(), a: Package.ProjectId) u32 {
194 return @truncate(a.hash());
195 }
196
197 pub fn eql(_: @This(), a_project_id: Package.ProjectId, b: Fork, _: usize) bool {
198 const b_project_id: Package.ProjectId = .init(b.manifest.name, b.manifest.id);
199 return a_project_id.eql(&b_project_id);
200 }
201 };
202 };
203
204 pub fn deinit(jq: *JobQueue) void {
205 const io = jq.io;
206 jq.group.cancel(io);
207 if (jq.all_fetches.items.len == 0) return;
208 const gpa = jq.all_fetches.items[0].arena.child_allocator;
209 jq.table.deinit(gpa);
210 // These must be deinitialized in reverse order because subsequent
211 // `Fetch` instances are allocated in prior ones' arenas.
212 // Sorry, I know it's a bit weird, but it slightly simplifies the
213 // critical section.
214 while (jq.all_fetches.pop()) |f| f.deinit();
215 jq.all_fetches.deinit(gpa);
216 jq.* = undefined;
217 }
218
219 /// Dumps all subsequent error bundles into the first one.
220 pub fn consolidateErrors(jq: *JobQueue) !void {
221 const root = &jq.all_fetches.items[0].error_bundle;
222 const gpa = root.gpa;
223 for (jq.all_fetches.items[1..]) |fetch| {
224 if (fetch.error_bundle.root_list.items.len > 0) {
225 var bundle = try fetch.error_bundle.toOwnedBundle("");
226 defer bundle.deinit(gpa);
227 try root.addBundleAsRoots(bundle);
228 }
229 }
230 }
231
232 /// Creates the dependencies.zig source code for the build runner to obtain
233 /// via `@import("@dependencies")`.
234 pub fn createDependenciesSource(jq: *JobQueue, w: *Io.Writer) Io.Writer.Error!void {
235 const keys = jq.table.keys();
236
237 assert(keys.len != 0); // caller should have added the first one
238 if (keys.len == 1) {
239 // This is the first one. It must have no dependencies.
240 return createEmptyDependenciesSource(w);
241 }
242
243 try w.writeAll("pub const packages = struct {\n");
244
245 // Ensure the generated .zig file is deterministic.
246 jq.table.sortUnstable(@as(struct {
247 keys: []const Package.Hash,
248 pub fn lessThan(ctx: @This(), a_index: usize, b_index: usize) bool {
249 return std.mem.lessThan(u8, &ctx.keys[a_index].bytes, &ctx.keys[b_index].bytes);
250 }
251 }, .{ .keys = keys }));
252
253 for (keys, jq.table.values()) |*hash, fetch| {
254 if (fetch == jq.all_fetches.items[0]) {
255 // The first one is a dummy package for the current project.
256 continue;
257 }
258
259 const hash_slice = hash.toSlice();
260
261 try w.print(
262 \\ pub const {f} = struct {{
263 \\
264 , .{std.zig.fmtId(hash_slice)});
265
266 lazy: {
267 switch (fetch.lazy_status) {
268 .eager => break :lazy,
269 .available => {
270 try w.writeAll(
271 \\ pub const available = true;
272 \\
273 );
274 break :lazy;
275 },
276 .unavailable => {
277 try w.writeAll(
278 \\ pub const available = false;
279 \\ };
280 \\
281 );
282 continue;
283 },
284 }
285 }
286
287 try w.print(
288 \\ pub const build_root = "{f}";
289 \\
290 , .{std.fmt.alt(fetch.package_root, .formatEscapeString)});
291
292 if (fetch.has_build_zig) {
293 try w.print(
294 \\ pub const build_zig = @import("{f}");
295 \\
296 , .{std.zig.fmtString(hash_slice)});
297 }
298
299 if (fetch.have_manifest) {
300 const manifest = &fetch.manifest;
301 try w.writeAll(
302 \\ pub const deps: []const struct { []const u8, []const u8 } = &.{
303 \\
304 );
305 for (manifest.dependencies.keys(), manifest.dependencies.values()) |name, dep| {
306 const h = depDigest(fetch.package_root, jq.global_cache, dep) orelse continue;
307 try w.print(
308 " .{{ \"{f}\", \"{f}\" }},\n",
309 .{ std.zig.fmtString(name), std.zig.fmtString(h.toSlice()) },
310 );
311 }
312
313 try w.writeAll(
314 \\ };
315 \\ };
316 \\
317 );
318 } else {
319 try w.writeAll(
320 \\ pub const deps: []const struct { []const u8, []const u8 } = &.{};
321 \\ };
322 \\
323 );
324 }
325 }
326
327 try w.writeAll(
328 \\};
329 \\
330 \\pub const root_deps: []const struct { []const u8, []const u8 } = &.{
331 \\
332 );
333
334 const root_fetch = jq.all_fetches.items[0];
335 assert(root_fetch.have_manifest);
336 const root_manifest = &root_fetch.manifest;
337
338 for (root_manifest.dependencies.keys(), root_manifest.dependencies.values()) |name, dep| {
339 const h = depDigest(root_fetch.package_root, jq.global_cache, dep) orelse continue;
340 try w.print(
341 " .{{ \"{f}\", \"{f}\" }},\n",
342 .{ std.zig.fmtString(name), std.zig.fmtString(h.toSlice()) },
343 );
344 }
345 try w.writeAll("};\n");
346 }
347
348 pub fn createEmptyDependenciesSource(w: *Io.Writer) Io.Writer.Error!void {
349 try w.writeAll(
350 \\pub const packages = struct {};
351 \\pub const root_deps: []const struct { []const u8, []const u8 } = &.{};
352 \\
353 );
354 }
355
356 fn recompress(jq: *JobQueue, package_hash: Package.Hash, package_root: Path) Io.Cancelable!void {
357 const pkg_hash_slice = package_hash.toSlice();
358
359 const prog_node = jq.prog_node.startFmt(0, "recompress {s}", .{pkg_hash_slice});
360 defer prog_node.end();
361
362 var dest_sub_path_buf: ["p/".len + Package.Hash.max_len + ".tar.gz".len]u8 = undefined;
363 const dest_path: Path = .{
364 .root_dir = jq.global_cache,
365 .sub_path = std.mem.print(&dest_sub_path_buf, "p/{s}.tar.gz", .{pkg_hash_slice}) catch unreachable,
366 };
367
368 const gpa = jq.http_client.allocator;
369
370 var arena_instance = std.heap.ArenaAllocator.init(gpa);
371 defer arena_instance.deinit();
372 const arena = arena_instance.allocator();
373
374 recompressFallible(jq, arena, dest_path, pkg_hash_slice, package_root, prog_node) catch |err| switch (err) {
375 error.Canceled => |e| return e,
376 error.ReadFailed => comptime unreachable,
377 error.WriteFailed => comptime unreachable,
378 else => |e| log.warn("failed caching recompressed tarball to {f}: {t}", .{ dest_path, e }),
379 };
380 }
381
382 fn recompressFallible(
383 jq: *JobQueue,
384 arena: Allocator,
385 dest_path: Path,
386 pkg_hash_slice: []const u8,
387 package_root: Path,
388 prog_node: std.Progress.Node,
389 ) !void {
390 const gpa = jq.http_client.allocator;
391 const io = jq.io;
392
393 // We have to walk the file system up front in order to sort the file
394 // list for determinism purposes. The hash of the recompressed file is
395 // not critical because the true hash is based on the content alone.
396 // However, if we want Zig users to be able to share cached package
397 // data with each other via peer-to-peer protocols, we benefit greatly
398 // from the data being identical on everyone's computers.
399 var scanned_files: std.ArrayList(ScannedFile) = .empty;
400 defer scanned_files.deinit(gpa);
401
402 var pkg_dir = try package_root.root_dir.handle.openDir(io, package_root.sub_path, .{ .iterate = true });
403 defer pkg_dir.close(io);
404
405 {
406 var walker = try pkg_dir.walk(gpa);
407 defer walker.deinit();
408
409 while (try walker.next(io)) |entry| {
410 const symlink = switch (entry.kind) {
411 .directory => continue,
412 .file => false,
413 .sym_link => true,
414 else => return error.IllegalFileType,
415 };
416 const entry_path = try arena.dupe(u8, entry.path);
417 // If necessary, normalize path separators to POSIX-style since the tar format requires that.
418 if (comptime (std.fs.path.sep != std.fs.path.sep_posix)) {
419 std.mem.replaceScalar(u8, entry_path, std.fs.path.sep, std.fs.path.sep_posix);
420 }
421 try scanned_files.append(gpa, .{
422 .ptr = entry_path.ptr,
423 .len = @intCast(entry_path.len),
424 .symlink = symlink,
425 });
426 }
427
428 std.mem.sortUnstable(ScannedFile, scanned_files.items, {}, stringCmp);
429 }
430
431 prog_node.setEstimatedTotalItems(scanned_files.items.len);
432
433 var atomic_file = try dest_path.root_dir.handle.createFileAtomic(io, dest_path.sub_path, .{
434 .make_path = true,
435 .replace = true,
436 });
437 defer atomic_file.deinit(io);
438
439 var file_write_buffer: [4096]u8 = undefined;
440 var file_writer = atomic_file.file.writer(io, &file_write_buffer);
441
442 var compress_buffer: [std.compress.flate.max_window_len]u8 = undefined;
443 var compress = std.compress.flate.Compress.init(&file_writer.interface, &compress_buffer, .gzip, .level_9) catch |err| switch (err) {
444 error.WriteFailed => return file_writer.err.?,
445 };
446
447 var archiver: std.tar.Writer = .{ .underlying_writer = &compress.writer };
448 archiver.prefix = pkg_hash_slice;
449
450 var file_read_buffer: [4096]u8 = undefined;
451 var link_buf: [fs.max_path_bytes]u8 = undefined;
452
453 for (scanned_files.items) |scanned_file| {
454 const entry_path = scanned_file.ptr[0..scanned_file.len];
455 if (scanned_file.symlink) {
456 const link_name = link_buf[0..try pkg_dir.readLink(io, entry_path, &link_buf)];
457 archiver.writeLink(entry_path, link_name, .{}) catch |err| switch (err) {
458 error.WriteFailed => return file_writer.err.?,
459 else => |e| return e,
460 };
461 } else {
462 var file = try pkg_dir.openFile(io, entry_path, .{});
463 defer file.close(io);
464 var file_reader: Io.File.Reader = .init(file, io, &file_read_buffer);
465 archiver.writeFile(entry_path, &file_reader, 0) catch |err| switch (err) {
466 error.ReadFailed => return file_reader.err.?,
467 error.WriteFailed => return file_writer.err.?,
468 else => |e| return e,
469 };
470 }
471 prog_node.completeOne();
472 }
473
474 // intentionally omitting the pointless trailer
475 //try archiver.finish();
476 compress.finish() catch |err| switch (err) {
477 error.WriteFailed => return file_writer.err.?,
478 };
479 try file_writer.flush();
480 try atomic_file.replace(io);
481 }
482};
483
484const ScannedFile = struct {
485 ptr: [*]const u8,
486 len: u32,
487 symlink: bool,
488};
489
490fn stringCmp(_: void, lhs: ScannedFile, rhs: ScannedFile) bool {
491 return std.mem.lessThan(u8, lhs.ptr[0..lhs.len], rhs.ptr[0..rhs.len]);
492}
493
494pub const Location = union(enum) {
495 remote: Remote,
496 /// A directory found inside the parent package.
497 relative_path: Path,
498 /// Recursive Fetch tasks will never use this Location, but it may be
499 /// passed in by the CLI. Indicates the file contents here should be copied
500 /// into the global package cache. It may be a file relative to the cwd or
501 /// absolute, in which case it should be treated exactly like a `file://`
502 /// URL, or a directory, in which case it should be treated as an
503 /// already-unpacked directory (but still needs to be copied into the
504 /// global package cache and have inclusion rules applied).
505 path_or_url: []const u8,
506
507 pub const Remote = struct {
508 url: []const u8,
509 /// If this is null it means the user omitted the hash field from a dependency.
510 /// It will be an error but the logic should still fetch and print the discovered hash.
511 hash: ?Package.Hash,
512 };
513};
514
515pub const RunError = error{
516 OutOfMemory,
517 Canceled,
518 /// This error code is intended to be handled by inspecting the
519 /// `error_bundle` field.
520 FetchFailed,
521};
522
523pub fn run(f: *Fetch) RunError!void {
524 const job_queue = f.job_queue;
525 const io = job_queue.io;
526 const eb = &f.error_bundle;
527 const arena = f.arena.allocator();
528 const gpa = f.arena.child_allocator;
529
530 try eb.init(gpa);
531
532 // Check the global zig package cache to see if the hash already exists. If
533 // so, load, parse, and validate the build.zig.zon file therein, and skip
534 // ahead to queuing up jobs for dependencies. Likewise if the location is a
535 // relative path, treat this the same as a cache hit. Otherwise, proceed.
536
537 const remote = switch (f.location) {
538 .relative_path => |pkg_root| {
539 if (fs.path.isAbsolute(pkg_root.sub_path)) return f.fail(
540 f.location_tok,
541 try eb.addString("expected path relative to build root; found absolute path"),
542 );
543 if (f.hash_tok.unwrap()) |hash_tok| return f.fail(
544 hash_tok,
545 try eb.addString("path-based dependencies are not hashed"),
546 );
547 // Packages fetched by URL may not use relative paths to escape outside the
548 // fetched package directory from within the package cache.
549
550 // This code path is only reachable recursively and the sub_path
551 // will already have been resolved to no longer have extra ".." or
552 // "." components.
553 assert(job_queue.local_storage != null);
554 assert(pkg_root.root_dir.eql(f.remote_package_root.root_dir));
555 if (!std.mem.startsWith(u8, pkg_root.sub_path, f.remote_package_root.sub_path)) return f.fail(
556 f.location_tok,
557 try eb.printString("dependency path outside project: '{f}'", .{pkg_root}),
558 );
559 f.package_root = pkg_root;
560 try loadManifest(f, pkg_root);
561 if (!f.has_build_zig) try checkBuildFileExistence(f);
562 if (!job_queue.recursive) return;
563 return queueJobsForDeps(f);
564 },
565 .remote => |remote| remote,
566 .path_or_url => |path_or_url| {
567 if (Io.Dir.cwd().openDir(io, path_or_url, .{ .iterate = true })) |dir| {
568 var resource: Resource = .{ .dir = dir };
569 return f.runResource(path_or_url, &resource, null, false);
570 } else |dir_err| {
571 var server_header_buffer: [init_resource_buffer_size]u8 = undefined;
572
573 const file_err = if (dir_err == error.NotDir) e: {
574 if (Io.Dir.cwd().openFile(io, path_or_url, .{})) |file| {
575 var resource: Resource = .{ .file = file.reader(io, &server_header_buffer) };
576 return f.runResource(path_or_url, &resource, null, false);
577 } else |err| break :e err;
578 } else dir_err;
579
580 const uri = std.Uri.parse(path_or_url) catch |uri_err| {
581 return f.fail(0, try eb.printString(
582 "'{s}' could not be recognized as a file path ({t}) or an URL ({t})",
583 .{ path_or_url, file_err, uri_err },
584 ));
585 };
586 var resource: Resource = undefined;
587 try f.initResource(uri, &resource, &server_header_buffer);
588 return f.runResource(try uri.path.toRawMaybeAlloc(arena), &resource, null, false);
589 }
590 },
591 };
592
593 var resource_buffer: [init_resource_buffer_size]u8 = undefined;
594
595 if (remote.hash) |expected_hash| {
596 const expected_project_id: Package.ProjectId = expected_hash.projectId();
597 if (job_queue.fork_set.getKeyPtrAdapted(expected_project_id, @as(JobQueue.Fork.Adapter, .{}))) |fork| {
598 log.debug("using fork {f} for {s}", .{ fork.path, fork.manifest.name });
599 fork.uses += 1;
600 f.package_root = fork.path;
601 f.remote_package_root = f.package_root;
602 f.manifest_ast = fork.manifest_ast;
603 f.manifest = fork.manifest;
604 f.have_manifest = true;
605 try checkBuildFileExistence(f);
606 if (!job_queue.recursive) return;
607 return queueJobsForDeps(f);
608 }
609
610 if (job_queue.local_storage) |ls| {
611 const package_root = try ls.pkg_root.join(arena, expected_hash.toSlice());
612 if (package_root.root_dir.handle.access(io, package_root.sub_path, .{})) |_| {
613 assert(f.lazy_status != .unavailable);
614 f.package_root = package_root;
615 f.remote_package_root = f.package_root;
616 try loadManifest(f, f.package_root);
617 try checkBuildFileExistence(f);
618 if (!job_queue.recursive) return;
619 return queueJobsForDeps(f);
620 } else |err| switch (err) {
621 error.FileNotFound => {
622 log.debug("FileNotFound: {f}", .{package_root});
623 if (job_queue.read_only and f.lazy_status == .eager) return f.fail(
624 f.name_tok,
625 try eb.printString("package not found at '{f}'", .{package_root}),
626 );
627 },
628 error.Canceled => |e| return e,
629 else => |e| {
630 try eb.addRootErrorMessage(.{
631 .msg = try eb.printString("unable to open package cache directory {f}: {t}", .{
632 package_root, e,
633 }),
634 });
635 return error.FetchFailed;
636 },
637 }
638 }
639
640 // Check global cache before remote fetch.
641 const cached_tarball_sub_path = try std.fmt.allocPrint(arena, "p/{s}.tar.gz", .{expected_hash.toSlice()});
642 const cached_tarball_path: Path = .{
643 .root_dir = job_queue.global_cache,
644 .sub_path = cached_tarball_sub_path,
645 };
646 if (cached_tarball_path.root_dir.handle.openFile(io, cached_tarball_path.sub_path, .{})) |file| {
647 log.debug("found global cached tarball {f}", .{cached_tarball_path});
648 var resource: Resource = .{ .file = file.reader(io, &resource_buffer) };
649 return f.runResource(cached_tarball_sub_path, &resource, remote.hash, true);
650 } else |err| switch (err) {
651 error.FileNotFound => log.debug("FileNotFound: {f}", .{cached_tarball_path}),
652 error.Canceled => |e| return e,
653 else => |e| {
654 try eb.addRootErrorMessage(.{
655 .msg = try eb.printString("unable to open globally cached package {f}: {t}", .{
656 cached_tarball_path, e,
657 }),
658 });
659 return error.FetchFailed;
660 },
661 }
662
663 switch (f.lazy_status) {
664 .eager => {},
665 .available => if (!job_queue.unlazy_set.contains(expected_hash)) {
666 f.lazy_status = .unavailable;
667 return;
668 },
669 .unavailable => unreachable,
670 }
671 } else if (job_queue.read_only) {
672 try eb.addRootErrorMessage(.{
673 .msg = try eb.addString("dependency is missing hash field"),
674 .src_loc = try f.srcLoc(f.location_tok),
675 });
676 return error.FetchFailed;
677 }
678
679 // Fetch and unpack the remote into a temporary directory.
680 const uri = std.Uri.parse(remote.url) catch |err| return f.fail(
681 f.location_tok,
682 try eb.printString("invalid URI: {t}", .{err}),
683 );
684 var resource: Resource = undefined;
685 try f.initResource(uri, &resource, &resource_buffer);
686 return f.runResource(try uri.path.toRawMaybeAlloc(arena), &resource, remote.hash, false);
687}
688
689pub fn deinit(f: *Fetch) void {
690 f.error_bundle.deinit();
691 f.arena.deinit();
692}
693
694/// Consumes `resource`, even if an error is returned.
695fn runResource(
696 f: *Fetch,
697 uri_path: []const u8,
698 resource: *Resource,
699 remote_hash: ?Package.Hash,
700 disable_recompress: bool,
701) RunError!void {
702 const job_queue = f.job_queue;
703 assert(!job_queue.read_only);
704
705 const io = job_queue.io;
706 defer resource.deinit(io);
707
708 const arena = f.arena.allocator();
709 const eb = &f.error_bundle;
710 const rand_int = r: {
711 var x: u64 = undefined;
712 io.random(@ptrCast(&x));
713 break :r x;
714 };
715 const tmp_dir_sub_path = ".tmp-" ++ std.fmt.hex(rand_int);
716 const tmp_tmp_dir_sub_path = "tmp/" ++ tmp_dir_sub_path;
717 const tmp_directory_path: Path = if (job_queue.local_storage) |ls|
718 try ls.pkg_root.join(arena, tmp_dir_sub_path)
719 else
720 .{
721 .root_dir = job_queue.global_cache,
722 .sub_path = tmp_tmp_dir_sub_path,
723 };
724
725 const package_sub_path = blk: {
726 var tmp_directory: Directory = .{
727 .path = tmp_directory_path.sub_path,
728 .handle = handle: {
729 const dir = tmp_directory_path.root_dir.handle.createDirPathOpen(io, tmp_directory_path.sub_path, .{
730 .open_options = .{ .iterate = true },
731 }) catch |err| {
732 try eb.addRootErrorMessage(.{
733 .msg = try eb.printString("unable to create temporary directory '{f}': {t}", .{
734 tmp_directory_path, err,
735 }),
736 });
737 return error.FetchFailed;
738 };
739 break :handle dir;
740 },
741 };
742 defer tmp_directory.handle.close(io);
743
744 // Fetch and unpack a resource into a temporary directory.
745 var unpack_result = try unpackResource(f, resource, uri_path, tmp_directory);
746
747 const pkg_path: Path = .{ .root_dir = tmp_directory, .sub_path = unpack_result.root_dir };
748
749 // Load, parse, and validate the unpacked build.zig.zon file. It is allowed
750 // for the file to be missing, in which case this fetched package is
751 // considered to be a "naked" package.
752 try loadManifest(f, pkg_path);
753
754 const filter: Filter = .{
755 .include_paths = if (f.have_manifest) f.manifest.paths else .{},
756 };
757
758 // Ignore errors that were excluded by manifest, such as failure to
759 // create symlinks that weren't supposed to be included anyway.
760 try unpack_result.validate(f, filter);
761
762 // Apply the manifest's inclusion rules to the temporary directory by
763 // deleting excluded files.
764 // Empty directories have already been omitted by `unpackResource`.
765 // Compute the package hash based on the remaining files in the temporary
766 // directory.
767 f.computed_hash = try computeHash(f, pkg_path, filter);
768
769 if (unpack_result.root_dir.len > 0)
770 break :blk try tmp_directory_path.join(arena, unpack_result.root_dir);
771
772 break :blk tmp_directory_path;
773 };
774
775 const computed_package_hash = computedPackageHash(f);
776
777 // Rename the temporary directory into the local zig package directory. If
778 // the hash already exists, delete the temporary directory and leave the
779 // zig package directory untouched as it may be in use. This is done even
780 // if the hash is invalid, in case the package with the different hash is
781 // used in the future.
782 if (job_queue.local_storage) |ls| {
783 f.package_root = try ls.pkg_root.join(arena, computed_package_hash.toSlice());
784 renameTmpIntoCache(io, package_sub_path, f.package_root) catch |err| {
785 try eb.addRootErrorMessage(.{ .msg = try eb.printString(
786 "failed to rename temporary directory {f} into package cache directory {f}: {t}",
787 .{ package_sub_path, f.package_root, err },
788 ) });
789 return error.FetchFailed;
790 };
791 } else {
792 f.package_root = tmp_directory_path;
793 }
794 f.remote_package_root = f.package_root;
795
796 if (!disable_recompress) {
797 // Spin off a task to recompress the tarball, with filtered files deleted, into
798 // the global cache.
799 job_queue.group.async(io, JobQueue.recompress, .{ job_queue, computed_package_hash, f.package_root });
800 }
801
802 // Remove temporary directory root if not already renamed to global cache.
803 if (!package_sub_path.eql(tmp_directory_path)) {
804 tmp_directory_path.root_dir.handle.deleteDir(io, tmp_directory_path.sub_path) catch |err| switch (err) {
805 error.Canceled => |e| return e,
806 else => |e| log.warn("failed to delete temporary directory {f}: {t}", .{ tmp_directory_path, e }),
807 };
808 }
809
810 // Validate the computed hash against the expected hash. If invalid, this
811 // job is done.
812
813 if (remote_hash) |declared_hash| {
814 const hash_tok = f.hash_tok.unwrap().?;
815 if (!computed_package_hash.eql(&declared_hash)) {
816 return f.fail(hash_tok, try eb.printString(
817 "hash mismatch: manifest declares {s} but the fetched package has {s}",
818 .{ declared_hash.toSlice(), computed_package_hash.toSlice() },
819 ));
820 }
821 } else if (!f.omit_missing_hash_error) {
822 const notes_len = 1;
823 try eb.addRootErrorMessage(.{
824 .msg = try eb.addString("dependency is missing hash field"),
825 .src_loc = try f.srcLoc(f.location_tok),
826 .notes_len = notes_len,
827 });
828 const notes_start = try eb.reserveNotes(notes_len);
829 eb.extra.items[notes_start] = @backingInt(try eb.addErrorMessage(.{
830 .msg = try eb.printString("expected .hash = {q},", .{computed_package_hash.toSlice()}),
831 }));
832 return error.FetchFailed;
833 }
834
835 // Spawn a new fetch job for each dependency in the manifest file. Use
836 // a mutex and a hash map so that redundant jobs do not get queued up.
837 if (!job_queue.recursive) return;
838 return queueJobsForDeps(f);
839}
840
841pub fn computedPackageHash(f: *const Fetch) Package.Hash {
842 const saturated_size = std.math.cast(u32, f.computed_hash.total_size) orelse std.math.maxInt(u32);
843 if (f.have_manifest) {
844 const man = &f.manifest;
845 var version_buffer: [32]u8 = undefined;
846 const version: []const u8 = std.mem.print(&version_buffer, "{f}", .{man.version}) catch &version_buffer;
847 return .init(f.computed_hash.digest, man.name, version, man.id, saturated_size);
848 }
849 // In the future build.zig.zon fields will be added to allow overriding these values
850 // for naked tarballs.
851 return .init(f.computed_hash.digest, "N", "V", 0xffff, saturated_size);
852}
853
854/// `computeHash` gets a free check for the existence of `build.zig`, but when
855/// not computing a hash, we need to do a syscall to check for it.
856fn checkBuildFileExistence(f: *Fetch) RunError!void {
857 const io = f.job_queue.io;
858 const eb = &f.error_bundle;
859 if (f.package_root.access(io, std.zig.build_zig_basename, .{})) |_| {
860 f.has_build_zig = true;
861 } else |err| switch (err) {
862 error.FileNotFound => {},
863 else => |e| {
864 try eb.addRootErrorMessage(.{
865 .msg = try eb.printString("unable to access {f}/{s}: {t}", .{
866 f.package_root, std.zig.build_zig_basename, e,
867 }),
868 });
869 return error.FetchFailed;
870 },
871 }
872}
873
874/// This function populates `f.manifest` or leaves it `null`.
875fn loadManifest(f: *Fetch, pkg_root: Path) RunError!void {
876 const io = f.job_queue.io;
877 const eb = &f.error_bundle;
878 const arena = f.arena.allocator();
879 const manifest_path = try pkg_root.join(arena, Manifest.basename);
880
881 Manifest.load(
882 io,
883 arena,
884 manifest_path,
885 &f.manifest_ast,
886 eb,
887 &f.manifest,
888 f.allow_missing_paths_field,
889 ) catch |err| switch (err) {
890 error.FileNotFound => return,
891 error.Canceled => |e| return e,
892 error.ErrorsBundled => return error.FetchFailed,
893 else => |e| {
894 try eb.addRootErrorMessage(.{
895 .msg = try eb.printString("unable to load package manifest '{f}': {t}", .{ manifest_path, e }),
896 });
897 return error.FetchFailed;
898 },
899 };
900 f.have_manifest = true;
901}
902
903fn queueJobsForDeps(f: *Fetch) RunError!void {
904 const io = f.job_queue.io;
905
906 assert(f.job_queue.recursive);
907
908 // If the package does not have a build.zig.zon file then there are no dependencies.
909 if (!f.have_manifest) return;
910 const manifest = &f.manifest;
911
912 const new_fetches, const prog_names = nf: {
913 const parent_arena = f.arena.allocator();
914 const gpa = f.arena.child_allocator;
915 const cache_root = f.job_queue.global_cache;
916 const dep_names = manifest.dependencies.keys();
917 const deps = manifest.dependencies.values();
918 // Grab the new tasks into a temporary buffer so we can unlock that mutex
919 // as fast as possible.
920 // This overallocates any fetches that get skipped by the `continue` in the
921 // loop below.
922 const new_fetches = try parent_arena.alloc(Fetch, deps.len);
923 const prog_names = try parent_arena.alloc([]const u8, deps.len);
924 var new_fetch_index: usize = 0;
925
926 try f.job_queue.mutex.lock(io);
927 defer f.job_queue.mutex.unlock(io);
928
929 try f.job_queue.all_fetches.ensureUnusedCapacity(gpa, new_fetches.len);
930 try f.job_queue.table.ensureUnusedCapacity(gpa, @intCast(new_fetches.len));
931
932 // There are four cases here:
933 // * Correct hash is provided by manifest.
934 // - Hash map already has the entry, no need to add it again.
935 // * Incorrect hash is provided by manifest.
936 // - Hash mismatch error emitted; `queueJobsForDeps` is not called.
937 // * Hash is not provided by manifest.
938 // - Hash missing error emitted; `queueJobsForDeps` is not called.
939 // * path-based location is used without a hash.
940 // - Hash is added to the table based on the path alone before
941 // calling run(); no need to add it again.
942 //
943 // If we add a dep as lazy and then later try to add the same dep as eager,
944 // eagerness takes precedence and the existing entry is updated and re-scheduled
945 // for fetching.
946
947 for (dep_names, deps) |dep_name, dep| {
948 var promoted_existing_to_eager = false;
949 const new_fetch = &new_fetches[new_fetch_index];
950 const location: Location = switch (dep.location) {
951 .url => |url| .{
952 .remote = .{
953 .url = url,
954 .hash = h: {
955 const h = dep.hash orelse break :h null;
956 const pkg_hash: Package.Hash = .fromSlice(h);
957 if (h.len == 0) break :h pkg_hash;
958 const gop = f.job_queue.table.getOrPutAssumeCapacity(pkg_hash);
959 if (gop.found_existing) {
960 if (!dep.lazy and gop.value_ptr.*.lazy_status != .eager) {
961 gop.value_ptr.*.lazy_status = .eager;
962 promoted_existing_to_eager = true;
963 } else {
964 continue;
965 }
966 }
967 gop.value_ptr.* = new_fetch;
968 break :h pkg_hash;
969 },
970 },
971 },
972 .path => |rel_path| l: {
973 // This might produce an invalid path, which is checked for
974 // at the beginning of run().
975 const new_root = try f.package_root.resolvePosix(parent_arena, rel_path);
976 const pkg_hash = relativePathDigest(new_root, cache_root);
977 const gop = f.job_queue.table.getOrPutAssumeCapacity(pkg_hash);
978 if (gop.found_existing) {
979 if (!dep.lazy and gop.value_ptr.*.lazy_status != .eager) {
980 gop.value_ptr.*.lazy_status = .eager;
981 promoted_existing_to_eager = true;
982 } else {
983 continue;
984 }
985 }
986 gop.value_ptr.* = new_fetch;
987 break :l .{ .relative_path = new_root };
988 },
989 };
990 prog_names[new_fetch_index] = dep_name;
991 new_fetch_index += 1;
992 if (!promoted_existing_to_eager) {
993 f.job_queue.all_fetches.appendAssumeCapacity(new_fetch);
994 }
995 new_fetch.* = .{
996 .arena = std.heap.ArenaAllocator.init(gpa),
997 .location = location,
998 .location_tok = dep.location_tok,
999 .hash_tok = dep.hash_tok,
1000 .name_tok = dep.name_tok,
1001 .lazy_status = switch (f.job_queue.mode) {
1002 .needed => if (dep.lazy) .available else .eager,
1003 .all => .eager,
1004 },
1005 .parent_package_root = f.package_root,
1006 .remote_package_root = f.remote_package_root,
1007 .parent_manifest_ast = &f.manifest_ast,
1008 .prog_node = f.prog_node,
1009 .job_queue = f.job_queue,
1010 .omit_missing_hash_error = false,
1011 .allow_missing_paths_field = true,
1012 .use_latest_commit = false,
1013
1014 .package_root = undefined,
1015 .error_bundle = undefined,
1016 .manifest = undefined,
1017 .manifest_ast = undefined,
1018 .have_manifest = false,
1019 .computed_hash = undefined,
1020 .has_build_zig = false,
1021 .oom_flag = false,
1022 .latest_commit = null,
1023
1024 .cli_module = null,
1025 };
1026 }
1027
1028 f.prog_node.increaseEstimatedTotalItems(new_fetch_index);
1029
1030 break :nf .{ new_fetches[0..new_fetch_index], prog_names[0..new_fetch_index] };
1031 };
1032
1033 // Now it's time to dispatch tasks.
1034 for (new_fetches, prog_names) |*new_fetch, prog_name| {
1035 f.job_queue.group.async(io, workerRun, .{ new_fetch, prog_name });
1036 }
1037}
1038
1039pub fn relativePathDigest(pkg_root: Path, cache_root: Directory) Package.Hash {
1040 return .initPath(pkg_root.sub_path, pkg_root.root_dir.eql(cache_root));
1041}
1042
1043pub fn workerRun(f: *Fetch, prog_name: []const u8) Io.Cancelable!void {
1044 const prog_node = f.prog_node.start(prog_name, 0);
1045 defer prog_node.end();
1046
1047 run(f) catch |err| switch (err) {
1048 error.OutOfMemory => f.oom_flag = true,
1049 error.Canceled => |e| return e,
1050 error.FetchFailed => {
1051 // Nothing to do because the errors are already reported in `error_bundle`,
1052 // and a reference is kept to the `Fetch` task inside `all_fetches`.
1053 },
1054 };
1055}
1056
1057fn srcLoc(
1058 f: *Fetch,
1059 tok: std.zig.Ast.TokenIndex,
1060) Allocator.Error!ErrorBundle.SourceLocationIndex {
1061 const ast = f.parent_manifest_ast orelse return .none;
1062 const eb = &f.error_bundle;
1063 const start_loc = ast.tokenLocation(0, tok);
1064 const src_path = try eb.printString("{f}" ++ fs.path.sep_str ++ Manifest.basename, .{f.parent_package_root});
1065 const msg_off = 0;
1066 return eb.addSourceLocation(.{
1067 .src_path = src_path,
1068 .span_start = ast.tokenStart(tok),
1069 .span_end = @intCast(ast.tokenStart(tok) + ast.tokenSlice(tok).len),
1070 .span_main = ast.tokenStart(tok) + msg_off,
1071 .line = @intCast(start_loc.line),
1072 .column = @intCast(start_loc.column),
1073 .source_line = try eb.addString(ast.source[start_loc.line_start..start_loc.line_end]),
1074 });
1075}
1076
1077fn fail(f: *Fetch, msg_tok: std.zig.Ast.TokenIndex, msg_str: u32) RunError {
1078 const eb = &f.error_bundle;
1079 try eb.addRootErrorMessage(.{
1080 .msg = msg_str,
1081 .src_loc = try f.srcLoc(msg_tok),
1082 });
1083 return error.FetchFailed;
1084}
1085
1086const Resource = union(enum) {
1087 file: Io.File.Reader,
1088 http_request: HttpRequest,
1089 git: Git,
1090 dir: Io.Dir,
1091
1092 const Git = struct {
1093 session: git.Session,
1094 fetch_stream: git.Session.FetchStream,
1095 want_oid: git.Oid,
1096 };
1097
1098 const HttpRequest = struct {
1099 request: std.http.Client.Request,
1100 response: std.http.Client.Response,
1101 transfer_buffer: []u8,
1102 decompress: std.http.Decompress,
1103 decompress_buffer: []u8,
1104 };
1105
1106 fn deinit(resource: *Resource, io: Io) void {
1107 switch (resource.*) {
1108 .file => |*file_reader| file_reader.file.close(io),
1109 .http_request => |*http_request| http_request.request.deinit(),
1110 .git => |*git_resource| {
1111 git_resource.fetch_stream.deinit();
1112 },
1113 .dir => |*dir| dir.close(io),
1114 }
1115 resource.* = undefined;
1116 }
1117
1118 fn reader(resource: *Resource) *Io.Reader {
1119 return switch (resource.*) {
1120 .file => |*file_reader| return &file_reader.interface,
1121 .http_request => |*http_request| return http_request.response.readerDecompressing(
1122 http_request.transfer_buffer,
1123 &http_request.decompress,
1124 http_request.decompress_buffer,
1125 ),
1126 .git => |*g| return &g.fetch_stream.reader,
1127 .dir => unreachable,
1128 };
1129 }
1130};
1131
1132const FileType = enum {
1133 tar,
1134 @"tar.gz",
1135 @"tar.xz",
1136 @"tar.zst",
1137 git_pack,
1138 zip,
1139
1140 fn fromPath(file_path: []const u8) ?FileType {
1141 if (ascii.endsWithIgnoreCase(file_path, ".tar")) return .tar;
1142 if (ascii.endsWithIgnoreCase(file_path, ".tgz")) return .@"tar.gz";
1143 if (ascii.endsWithIgnoreCase(file_path, ".tar.gz")) return .@"tar.gz";
1144 if (ascii.endsWithIgnoreCase(file_path, ".txz")) return .@"tar.xz";
1145 if (ascii.endsWithIgnoreCase(file_path, ".tar.xz")) return .@"tar.xz";
1146 if (ascii.endsWithIgnoreCase(file_path, ".tzst")) return .@"tar.zst";
1147 if (ascii.endsWithIgnoreCase(file_path, ".tar.zst")) return .@"tar.zst";
1148 if (ascii.endsWithIgnoreCase(file_path, ".zip")) return .zip;
1149 if (ascii.endsWithIgnoreCase(file_path, ".jar")) return .zip;
1150 return null;
1151 }
1152
1153 /// Parameter is a content-disposition header value.
1154 fn fromContentDisposition(cd_header: []const u8) ?FileType {
1155 const attach_end = ascii.findIgnoreCase(cd_header, "attachment;") orelse
1156 return null;
1157
1158 var value_start = ascii.findIgnoreCasePos(cd_header, attach_end + 1, "filename") orelse
1159 return null;
1160 value_start += "filename".len;
1161 if (cd_header[value_start] == '*') {
1162 value_start += 1;
1163 }
1164 if (cd_header[value_start] != '=') return null;
1165 value_start += 1;
1166
1167 var value_end = std.mem.findPos(u8, cd_header, value_start, ";") orelse cd_header.len;
1168 if (cd_header[value_end - 1] == '\"') {
1169 value_end -= 1;
1170 }
1171 return fromPath(cd_header[value_start..value_end]);
1172 }
1173
1174 test fromContentDisposition {
1175 try std.testing.expectEqual(@as(?FileType, .@"tar.gz"), fromContentDisposition("attaChment; FILENAME=\"stuff.tar.gz\"; size=42"));
1176 try std.testing.expectEqual(@as(?FileType, .@"tar.gz"), fromContentDisposition("attachment; filename*=\"stuff.tar.gz\""));
1177 try std.testing.expectEqual(@as(?FileType, .@"tar.xz"), fromContentDisposition("ATTACHMENT; filename=\"stuff.tar.xz\""));
1178 try std.testing.expectEqual(@as(?FileType, .@"tar.xz"), fromContentDisposition("attachment; FileName=\"stuff.tar.xz\""));
1179 try std.testing.expectEqual(@as(?FileType, .@"tar.gz"), fromContentDisposition("attachment; FileName*=UTF-8\'\'xyz%2Fstuff.tar.gz"));
1180 try std.testing.expectEqual(@as(?FileType, .tar), fromContentDisposition("attachment; FileName=\"stuff.tar\""));
1181
1182 try std.testing.expect(fromContentDisposition("attachment FileName=\"stuff.tar.gz\"") == null);
1183 try std.testing.expect(fromContentDisposition("attachment; FileName\"stuff.gz\"") == null);
1184 try std.testing.expect(fromContentDisposition("attachment; size=42") == null);
1185 try std.testing.expect(fromContentDisposition("inline; size=42") == null);
1186 try std.testing.expect(fromContentDisposition("FileName=\"stuff.tar.gz\"; attachment;") == null);
1187 try std.testing.expect(fromContentDisposition("FileName=\"stuff.tar.gz\";") == null);
1188 }
1189};
1190
1191const init_resource_buffer_size = git.Packet.max_data_length;
1192
1193fn initResource(f: *Fetch, uri: std.Uri, resource: *Resource, reader_buffer: []u8) RunError!void {
1194 const io = f.job_queue.io;
1195 const arena = f.arena.allocator();
1196 const eb = &f.error_bundle;
1197
1198 if (ascii.eqlIgnoreCase(uri.scheme, "file")) {
1199 const path = try uri.path.toRawMaybeAlloc(arena);
1200 const file = f.parent_package_root.openFile(io, path, .{}) catch |err| {
1201 return f.fail(f.location_tok, try eb.printString("unable to open {f}/{s}: {t}", .{
1202 f.parent_package_root, path, err,
1203 }));
1204 };
1205 resource.* = .{ .file = file.reader(io, reader_buffer) };
1206 return;
1207 }
1208
1209 const http_client = f.job_queue.http_client;
1210
1211 if (ascii.eqlIgnoreCase(uri.scheme, "http") or
1212 ascii.eqlIgnoreCase(uri.scheme, "https"))
1213 {
1214 resource.* = .{ .http_request = .{
1215 .request = http_client.request(.GET, uri, .{}) catch |err|
1216 return f.fail(f.location_tok, try eb.printString("server connection failed: {t}", .{err})),
1217 .response = undefined,
1218 .transfer_buffer = reader_buffer,
1219 .decompress_buffer = &.{},
1220 .decompress = undefined,
1221 } };
1222 const request = &resource.http_request.request;
1223 errdefer request.deinit();
1224
1225 request.sendBodiless() catch |err|
1226 return f.fail(f.location_tok, try eb.printString("HTTP request failed: {t}", .{err}));
1227
1228 var redirect_buffer: [8000]u8 = undefined;
1229 const response = &resource.http_request.response;
1230 response.* = request.receiveHead(&redirect_buffer) catch |err| switch (err) {
1231 error.ReadFailed => {
1232 return f.fail(f.location_tok, try eb.printString("HTTP response read failure: {t}", .{
1233 request.connection.?.getReadError().?,
1234 }));
1235 },
1236 else => |e| return f.fail(f.location_tok, try eb.printString("invalid HTTP response: {t}", .{e})),
1237 };
1238
1239 if (response.head.status != .ok) return f.fail(f.location_tok, try eb.printString(
1240 "bad HTTP response code: '{d} {s}'",
1241 .{ response.head.status, response.head.status.phrase() orelse "" },
1242 ));
1243
1244 resource.http_request.decompress_buffer = try arena.alloc(u8, response.head.content_encoding.minBufferCapacity());
1245 return;
1246 }
1247
1248 if (ascii.eqlIgnoreCase(uri.scheme, "git+http") or
1249 ascii.eqlIgnoreCase(uri.scheme, "git+https"))
1250 {
1251 var transport_uri = uri;
1252 transport_uri.scheme = uri.scheme["git+".len..];
1253 var session = git.Session.init(arena, http_client, transport_uri, reader_buffer) catch |err| {
1254 return f.fail(
1255 f.location_tok,
1256 try eb.printString("unable to discover remote git server capabilities: {t}", .{err}),
1257 );
1258 };
1259
1260 const want_oid = want_oid: {
1261 const want_ref =
1262 if (uri.fragment) |fragment| try fragment.toRawMaybeAlloc(arena) else "HEAD";
1263 if (git.Oid.parseAny(want_ref)) |oid| break :want_oid oid else |_| {}
1264
1265 const want_ref_head = try std.fmt.allocPrint(arena, "refs/heads/{s}", .{want_ref});
1266 const want_ref_tag = try std.fmt.allocPrint(arena, "refs/tags/{s}", .{want_ref});
1267
1268 var ref_iterator: git.Session.RefIterator = undefined;
1269 session.listRefs(&ref_iterator, .{
1270 .ref_prefixes = &.{ want_ref, want_ref_head, want_ref_tag },
1271 .include_peeled = true,
1272 .buffer = reader_buffer,
1273 }) catch |err| return f.fail(f.location_tok, try eb.printString("unable to list refs: {t}", .{err}));
1274 defer ref_iterator.deinit();
1275 while (ref_iterator.next() catch |err| {
1276 return f.fail(f.location_tok, try eb.printString(
1277 "unable to iterate refs: {s}",
1278 .{@errorName(err)},
1279 ));
1280 }) |ref| {
1281 if (std.mem.eql(u8, ref.name, want_ref) or
1282 std.mem.eql(u8, ref.name, want_ref_head) or
1283 std.mem.eql(u8, ref.name, want_ref_tag))
1284 {
1285 break :want_oid ref.peeled orelse ref.oid;
1286 }
1287 }
1288 return f.fail(f.location_tok, try eb.printString("ref not found: {s}", .{want_ref}));
1289 };
1290 if (f.use_latest_commit) {
1291 f.latest_commit = want_oid;
1292 } else if (uri.fragment == null) {
1293 const notes_len = 1;
1294 try eb.addRootErrorMessage(.{
1295 .msg = try eb.addString("url field is missing an explicit ref"),
1296 .src_loc = try f.srcLoc(f.location_tok),
1297 .notes_len = notes_len,
1298 });
1299 const notes_start = try eb.reserveNotes(notes_len);
1300 eb.extra.items[notes_start] = @backingInt(try eb.addErrorMessage(.{
1301 .msg = try eb.printString("try .url = \"{f}#{f}\",", .{
1302 uri.fmt(.{ .scheme = true, .authority = true, .path = true }),
1303 want_oid,
1304 }),
1305 }));
1306 return error.FetchFailed;
1307 }
1308
1309 var want_oid_hex_buf: [git.Oid.max_formatted_length]u8 = undefined;
1310 const want_oid_hex = std.mem.print(&want_oid_hex_buf, "{f}", .{want_oid}) catch unreachable;
1311 resource.* = .{ .git = .{
1312 .session = session,
1313 .fetch_stream = undefined,
1314 .want_oid = want_oid,
1315 } };
1316 const fetch_stream = &resource.git.fetch_stream;
1317 session.fetch(fetch_stream, &.{want_oid_hex}, reader_buffer) catch |err| {
1318 return f.fail(f.location_tok, try eb.printString("unable to create fetch stream: {t}", .{err}));
1319 };
1320 errdefer fetch_stream.deinit(fetch_stream);
1321
1322 return;
1323 }
1324
1325 return f.fail(f.location_tok, try eb.printString("unsupported URL scheme: {s}", .{uri.scheme}));
1326}
1327
1328fn unpackResource(
1329 f: *Fetch,
1330 resource: *Resource,
1331 uri_path: []const u8,
1332 tmp_directory: Directory,
1333) RunError!UnpackResult {
1334 const eb = &f.error_bundle;
1335 const file_type = switch (resource.*) {
1336 .file => FileType.fromPath(uri_path) orelse
1337 return f.fail(f.location_tok, try eb.printString("unknown file type: '{s}'", .{uri_path})),
1338
1339 .http_request => |*http_request| ft: {
1340 const head = &http_request.response.head;
1341
1342 // Content-Type takes first precedence.
1343 const content_type = head.content_type orelse
1344 return f.fail(f.location_tok, try eb.addString("missing 'Content-Type' header"));
1345
1346 // Extract the MIME type, ignoring charset and boundary directives
1347 const mime_type_end = std.mem.find(u8, content_type, ";") orelse content_type.len;
1348 const mime_type = content_type[0..mime_type_end];
1349
1350 if (ascii.eqlIgnoreCase(mime_type, "application/x-tar"))
1351 break :ft .tar;
1352
1353 if (ascii.eqlIgnoreCase(mime_type, "application/gzip") or
1354 ascii.eqlIgnoreCase(mime_type, "application/x-gzip") or
1355 ascii.eqlIgnoreCase(mime_type, "application/tar+gzip") or
1356 ascii.eqlIgnoreCase(mime_type, "application/x-tar-gz") or
1357 ascii.eqlIgnoreCase(mime_type, "application/x-gtar-compressed"))
1358 {
1359 break :ft .@"tar.gz";
1360 }
1361
1362 if (ascii.eqlIgnoreCase(mime_type, "application/x-xz"))
1363 break :ft .@"tar.xz";
1364
1365 if (ascii.eqlIgnoreCase(mime_type, "application/zstd"))
1366 break :ft .@"tar.zst";
1367
1368 if (ascii.eqlIgnoreCase(mime_type, "application/zip") or
1369 ascii.eqlIgnoreCase(mime_type, "application/x-zip-compressed") or
1370 ascii.eqlIgnoreCase(mime_type, "application/java-archive"))
1371 {
1372 break :ft .zip;
1373 }
1374
1375 if (!ascii.eqlIgnoreCase(mime_type, "application/octet-stream") and
1376 !ascii.eqlIgnoreCase(mime_type, "application/x-compressed"))
1377 {
1378 return f.fail(f.location_tok, try eb.printString(
1379 "unrecognized 'Content-Type' header: '{s}'",
1380 .{content_type},
1381 ));
1382 }
1383
1384 // Next, the filename from 'content-disposition: attachment' takes precedence.
1385 if (head.content_disposition) |cd_header| {
1386 break :ft FileType.fromContentDisposition(cd_header) orelse {
1387 return f.fail(f.location_tok, try eb.printString(
1388 "unsupported Content-Disposition header value: '{s}' for Content-Type=application/octet-stream",
1389 .{cd_header},
1390 ));
1391 };
1392 }
1393
1394 // Finally, the path from the URI is used.
1395 break :ft FileType.fromPath(uri_path) orelse {
1396 return f.fail(f.location_tok, try eb.printString("unknown file type: '{s}'", .{uri_path}));
1397 };
1398 },
1399
1400 .git => .git_pack,
1401
1402 .dir => |dir| {
1403 f.recursiveDirectoryCopy(dir, tmp_directory.handle) catch |err| {
1404 return f.fail(f.location_tok, try eb.printString("unable to copy directory '{s}': {t}", .{
1405 uri_path, err,
1406 }));
1407 };
1408 return .{};
1409 },
1410 };
1411
1412 switch (file_type) {
1413 .tar => {
1414 return unpackTarball(f, tmp_directory.handle, resource.reader());
1415 },
1416 .@"tar.gz" => {
1417 var flate_buffer: [std.compress.flate.max_window_len]u8 = undefined;
1418 var decompress: std.compress.flate.Decompress = .init(resource.reader(), .gzip, &flate_buffer);
1419 return try unpackTarball(f, tmp_directory.handle, &decompress.reader);
1420 },
1421 .@"tar.xz" => {
1422 const gpa = f.arena.child_allocator;
1423 var decompress = std.compress.xz.Decompress.init(resource.reader(), gpa, &.{}) catch |err|
1424 return f.fail(f.location_tok, try eb.printString("unable to decompress tarball: {t}", .{err}));
1425 defer decompress.deinit();
1426 return try unpackTarball(f, tmp_directory.handle, &decompress.reader);
1427 },
1428 .@"tar.zst" => {
1429 const window_len = std.compress.zstd.default_window_len;
1430 const window_buffer = try f.arena.allocator().alloc(u8, window_len + std.compress.zstd.block_size_max);
1431 var decompress: std.compress.zstd.Decompress = .init(resource.reader(), window_buffer, .{
1432 .verify_checksum = false,
1433 .window_len = window_len,
1434 });
1435 return try unpackTarball(f, tmp_directory.handle, &decompress.reader);
1436 },
1437 .git_pack => return unpackGitPack(f, tmp_directory.handle, &resource.git) catch |err| switch (err) {
1438 error.FetchFailed, error.OutOfMemory => |e| return e,
1439 else => |e| return f.fail(f.location_tok, try eb.printString("unable to unpack git files: {t}", .{e})),
1440 },
1441 .zip => return unzip(f, tmp_directory.handle, resource.reader()) catch |err| switch (err) {
1442 error.ReadFailed => return f.fail(f.location_tok, try eb.printString(
1443 "failed reading resource: {t}",
1444 .{err},
1445 )),
1446 else => |e| return e,
1447 },
1448 }
1449}
1450
1451fn unpackTarball(f: *Fetch, out_dir: Io.Dir, reader: *Io.Reader) RunError!UnpackResult {
1452 const eb = &f.error_bundle;
1453 const arena = f.arena.allocator();
1454 const io = f.job_queue.io;
1455
1456 var diagnostics: std.tar.Diagnostics = .{ .allocator = arena };
1457
1458 std.tar.extract(io, out_dir, reader, .{
1459 .diagnostics = &diagnostics,
1460 .strip_components = 0,
1461 .mode_mode = .ignore,
1462 .exclude_empty_directories = true,
1463 }) catch |err| return f.fail(
1464 f.location_tok,
1465 try eb.printString("unable to unpack tarball to temporary directory: {t}", .{err}),
1466 );
1467
1468 var res: UnpackResult = .{ .root_dir = diagnostics.root_dir };
1469 if (diagnostics.errors.items.len > 0) {
1470 try res.allocErrors(arena, diagnostics.errors.items.len, "unable to unpack tarball");
1471 for (diagnostics.errors.items) |item| {
1472 switch (item) {
1473 .unable_to_create_file => |i| res.unableToCreateFile(stripRoot(i.file_name, res.root_dir), i.code),
1474 .unable_to_create_sym_link => |i| res.unableToCreateSymLink(stripRoot(i.file_name, res.root_dir), i.link_name, i.code),
1475 .unsupported_file_type => |i| res.unsupportedFileType(stripRoot(i.file_name, res.root_dir), @backingInt(i.file_type)),
1476 .components_outside_stripped_prefix => unreachable, // unreachable with strip_components = 0
1477 }
1478 }
1479 }
1480 return res;
1481}
1482
1483fn unzip(
1484 f: *Fetch,
1485 out_dir: Io.Dir,
1486 reader: *Io.Reader,
1487) error{ ReadFailed, OutOfMemory, Canceled, FetchFailed }!UnpackResult {
1488 // We write the entire contents to a file first because zip files
1489 // must be processed back to front and they could be too large to
1490 // load into memory.
1491
1492 const io = f.job_queue.io;
1493 const cache_root = f.job_queue.global_cache;
1494 const prefix = "tmp/";
1495 const suffix = ".zip";
1496 const eb = &f.error_bundle;
1497 const random_len = @sizeOf(u64) * 2;
1498
1499 var zip_path: [prefix.len + random_len + suffix.len]u8 = undefined;
1500 zip_path[0..prefix.len].* = prefix.*;
1501 zip_path[prefix.len + random_len ..].* = suffix.*;
1502
1503 var zip_file = while (true) {
1504 const random_integer = r: {
1505 var x: u64 = undefined;
1506 io.random(@ptrCast(&x));
1507 break :r x;
1508 };
1509 zip_path[prefix.len..][0..random_len].* = std.fmt.hex(random_integer);
1510
1511 break cache_root.handle.createFile(io, &zip_path, .{
1512 .exclusive = true,
1513 .read = true,
1514 }) catch |err| switch (err) {
1515 error.PathAlreadyExists => continue,
1516 error.FileNotFound => {
1517 cache_root.handle.createDir(io, prefix, .default_dir) catch |dir_err| switch (dir_err) {
1518 error.Canceled => |e| return e,
1519 // error.PathAlreadyExists is considered a failure here because
1520 // it implies that the prefix is not a directory.
1521 else => |e| return f.fail(
1522 f.location_tok,
1523 try eb.printString("failed to create temporary directory: {t}", .{e}),
1524 ),
1525 };
1526 continue;
1527 },
1528 error.Canceled => |e| return e,
1529 else => |e| return f.fail(
1530 f.location_tok,
1531 try eb.printString("failed to create temporary zip file: {t}", .{e}),
1532 ),
1533 };
1534 };
1535 defer zip_file.close(io);
1536 var zip_file_buffer: [4096]u8 = undefined;
1537 var zip_file_reader = b: {
1538 var zip_file_writer = zip_file.writer(io, &zip_file_buffer);
1539
1540 _ = reader.streamRemaining(&zip_file_writer.interface) catch |err| switch (err) {
1541 error.ReadFailed => |e| return e,
1542 error.WriteFailed => return f.fail(
1543 f.location_tok,
1544 try eb.printString("failed writing temporary zip file: {t}", .{err}),
1545 ),
1546 };
1547 zip_file_writer.interface.flush() catch |err| return f.fail(
1548 f.location_tok,
1549 try eb.printString("failed writing temporary zip file: {t}", .{err}),
1550 );
1551 break :b zip_file_writer.moveToReader();
1552 };
1553
1554 var diagnostics: std.zip.Diagnostics = .{ .allocator = f.arena.allocator() };
1555 // no need to deinit since we are using an arena allocator
1556
1557 zip_file_reader.seekTo(0) catch |err|
1558 return f.fail(f.location_tok, try eb.printString("failed to seek temporary zip file: {t}", .{err}));
1559 std.zip.extract(out_dir, &zip_file_reader, .{
1560 .allow_backslashes = true,
1561 .diagnostics = &diagnostics,
1562 }) catch |err| return f.fail(f.location_tok, try eb.printString("zip extract failed: {t}", .{err}));
1563
1564 cache_root.handle.deleteFile(io, &zip_path) catch |err|
1565 return f.fail(f.location_tok, try eb.printString("delete temporary zip failed: {t}", .{err}));
1566
1567 return .{ .root_dir = diagnostics.root_dir };
1568}
1569
1570fn unpackGitPack(f: *Fetch, out_dir: Io.Dir, resource: *Resource.Git) anyerror!UnpackResult {
1571 const io = f.job_queue.io;
1572 const arena = f.arena.allocator();
1573 // TODO don't try to get a gpa from an arena. expose this dependency higher up
1574 // because the backing of arena could be page allocator
1575 const gpa = f.arena.child_allocator;
1576 const object_format: git.Oid.Format = resource.want_oid;
1577
1578 var res: UnpackResult = .{};
1579 // The .git directory is used to store the packfile and associated index, but
1580 // we do not attempt to replicate the exact structure of a real .git
1581 // directory, since that isn't relevant for fetching a package.
1582 {
1583 var pack_dir = try out_dir.createDirPathOpen(io, ".git", .{});
1584 defer pack_dir.close(io);
1585 var pack_file = try pack_dir.createFile(io, "pkg.pack", .{ .read = true });
1586 defer pack_file.close(io);
1587 var pack_file_buffer: [4096]u8 = undefined;
1588 var pack_file_reader = b: {
1589 var pack_file_writer = pack_file.writer(io, &pack_file_buffer);
1590 const fetch_reader = &resource.fetch_stream.reader;
1591 _ = try fetch_reader.streamRemaining(&pack_file_writer.interface);
1592 try pack_file_writer.interface.flush();
1593 break :b pack_file_writer.moveToReader();
1594 };
1595
1596 var index_file = try pack_dir.createFile(io, "pkg.idx", .{ .read = true });
1597 defer index_file.close(io);
1598 var index_file_buffer: [2000]u8 = undefined;
1599 var index_file_writer = index_file.writer(io, &index_file_buffer);
1600 {
1601 const index_prog_node = f.prog_node.start("Index pack", 0);
1602 defer index_prog_node.end();
1603 try git.indexPack(gpa, object_format, &pack_file_reader, &index_file_writer);
1604 }
1605
1606 {
1607 var index_file_reader = index_file.reader(io, &index_file_buffer);
1608 const checkout_prog_node = f.prog_node.start("Checkout", 0);
1609 defer checkout_prog_node.end();
1610 var repository: git.Repository = undefined;
1611 try repository.init(gpa, object_format, &pack_file_reader, &index_file_reader);
1612 defer repository.deinit();
1613 var diagnostics: git.Diagnostics = .{ .allocator = arena };
1614 try repository.checkout(io, out_dir, resource.want_oid, &diagnostics);
1615
1616 if (diagnostics.errors.items.len > 0) {
1617 try res.allocErrors(arena, diagnostics.errors.items.len, "unable to unpack packfile");
1618 for (diagnostics.errors.items) |item| {
1619 switch (item) {
1620 .unable_to_create_file => |i| res.unableToCreateFile(i.file_name, i.code),
1621 .unable_to_create_sym_link => |i| res.unableToCreateSymLink(i.file_name, i.link_name, i.code),
1622 }
1623 }
1624 }
1625 }
1626 }
1627
1628 try out_dir.deleteTree(io, ".git");
1629 return res;
1630}
1631
1632fn recursiveDirectoryCopy(f: *Fetch, dir: Io.Dir, tmp_dir: Io.Dir) anyerror!void {
1633 const gpa = f.arena.child_allocator;
1634 const io = f.job_queue.io;
1635 // Recursive directory copy.
1636 var it = try dir.walk(gpa);
1637 defer it.deinit();
1638 while (try it.next(io)) |entry| {
1639 switch (entry.kind) {
1640 .directory => {}, // omit empty directories
1641 .file => {
1642 dir.copyFile(entry.path, tmp_dir, entry.path, io, .{}) catch |err| switch (err) {
1643 error.FileNotFound => {
1644 if (fs.path.dirname(entry.path)) |dirname| try tmp_dir.createDirPath(io, dirname);
1645 try dir.copyFile(entry.path, tmp_dir, entry.path, io, .{});
1646 },
1647 else => |e| return e,
1648 };
1649 },
1650 .sym_link => {
1651 var buf: [fs.max_path_bytes]u8 = undefined;
1652 const link_name = buf[0..try dir.readLink(io, entry.path, &buf)];
1653 // TODO: if this would create a symlink to outside
1654 // the destination directory, fail with an error instead.
1655 tmp_dir.symLink(io, link_name, entry.path, .{}) catch |err| switch (err) {
1656 error.FileNotFound => {
1657 if (fs.path.dirname(entry.path)) |dirname| try tmp_dir.createDirPath(io, dirname);
1658 try tmp_dir.symLink(io, link_name, entry.path, .{});
1659 },
1660 else => |e| return e,
1661 };
1662 },
1663 else => return error.IllegalFileTypeInPackage,
1664 }
1665 }
1666}
1667
1668pub fn renameTmpIntoCache(io: Io, tmp_path: Path, dest_path: Path) !void {
1669 var handled_missing_dir = false;
1670 while (true) {
1671 Io.Dir.rename(
1672 tmp_path.root_dir.handle,
1673 tmp_path.sub_path,
1674 dest_path.root_dir.handle,
1675 dest_path.sub_path,
1676 io,
1677 ) catch |err| switch (err) {
1678 error.FileNotFound => {
1679 if (handled_missing_dir) return err;
1680 const parent_sub_path = Io.Dir.path.dirname(dest_path.sub_path).?;
1681 dest_path.root_dir.handle.createDir(io, parent_sub_path, .default_dir) catch |er| switch (er) {
1682 error.PathAlreadyExists => handled_missing_dir = true,
1683 else => |e| return e,
1684 };
1685 continue;
1686 },
1687 error.DirNotEmpty, error.AccessDenied => {
1688 // Package has been already downloaded and may already be in use on the system.
1689 tmp_path.root_dir.handle.deleteTree(io, tmp_path.sub_path) catch |er| switch (er) {
1690 error.Canceled => |e| return e,
1691 // Garbage files leftover in zig-cache/tmp/ is, as they say
1692 // on Star Trek, "operating within normal parameters".
1693 else => |e| log.warn("failed to delete temporary directory {f}: {t}", .{ tmp_path, e }),
1694 };
1695 },
1696 else => |e| return e,
1697 };
1698 break;
1699 }
1700}
1701
1702const ComputedHash = struct {
1703 digest: Package.Hash.Digest,
1704 total_size: u64,
1705};
1706
1707/// Assumes that files not included in the package have already been filtered
1708/// prior to calling this function. This ensures that files not protected by
1709/// the hash are not present on the file system. Empty directories are *not
1710/// hashed* and must not be present on the file system when calling this
1711/// function.
1712fn computeHash(f: *Fetch, pkg_path: Path, filter: Filter) RunError!ComputedHash {
1713 const io = f.job_queue.io;
1714 // All the path name strings need to be in memory for sorting.
1715 const arena = f.arena.allocator();
1716 const gpa = f.arena.child_allocator;
1717 const eb = &f.error_bundle;
1718 const root_dir = pkg_path.root_dir.handle;
1719
1720 // Collect all files, recursively, then sort.
1721 var all_files = std.array_list.Managed(*HashedFile).init(gpa);
1722 defer all_files.deinit();
1723
1724 var deleted_files = std.array_list.Managed(*DeletedFile).init(gpa);
1725 defer deleted_files.deinit();
1726
1727 // Track directories which had any files deleted from them so that empty directories
1728 // can be deleted.
1729 var sus_dirs: std.array_hash_map.String(void) = .empty;
1730 defer sus_dirs.deinit(gpa);
1731
1732 var walker = try root_dir.walk(gpa);
1733 defer walker.deinit();
1734
1735 // Total number of bytes of file contents included in the package.
1736 var total_size: u64 = 0;
1737
1738 {
1739 // The final hash will be a hash of each file hashed independently. This
1740 // allows hashing in parallel.
1741 var group: Io.Group = .init;
1742 defer group.cancel(io);
1743
1744 while (walker.next(io) catch |err| {
1745 try eb.addRootErrorMessage(.{ .msg = try eb.printString(
1746 "unable to walk temporary directory '{f}': {t}",
1747 .{ pkg_path, err },
1748 ) });
1749 return error.FetchFailed;
1750 }) |entry| {
1751 if (entry.kind == .directory) continue;
1752
1753 const entry_pkg_path = stripRoot(entry.path, pkg_path.sub_path);
1754 if (!filter.includePath(entry_pkg_path)) {
1755 // Delete instead of including in hash calculation.
1756 const fs_path = try arena.dupe(u8, entry.path);
1757
1758 // Also track the parent directory in case it becomes empty.
1759 if (fs.path.dirname(fs_path)) |parent|
1760 try sus_dirs.put(gpa, parent, {});
1761
1762 const deleted_file = try arena.create(DeletedFile);
1763 deleted_file.* = .{
1764 .fs_path = fs_path,
1765 .failure = undefined, // to be populated by the worker
1766 };
1767 group.async(io, workerDeleteFile, .{ io, root_dir, deleted_file });
1768 try deleted_files.append(deleted_file);
1769 continue;
1770 }
1771
1772 const kind: HashedFile.Kind = switch (entry.kind) {
1773 .directory => unreachable,
1774 .file => .file,
1775 .sym_link => .link,
1776 else => return f.fail(f.location_tok, try eb.printString(
1777 "package contains '{s}' which has illegal file type '{t}'",
1778 .{ entry.path, entry.kind },
1779 )),
1780 };
1781
1782 if (std.mem.eql(u8, entry_pkg_path, std.zig.build_zig_basename))
1783 f.has_build_zig = true;
1784
1785 const fs_path = try arena.dupe(u8, entry.path);
1786 const hashed_file = try arena.create(HashedFile);
1787 hashed_file.* = .{
1788 .fs_path = fs_path,
1789 .normalized_path = try normalizePathAlloc(arena, entry_pkg_path),
1790 .kind = kind,
1791 .hash = undefined, // to be populated by the worker
1792 .failure = undefined, // to be populated by the worker
1793 .size = undefined, // to be populated by the worker
1794 };
1795 group.async(io, workerHashFile, .{ io, root_dir, hashed_file });
1796 try all_files.append(hashed_file);
1797 }
1798
1799 try group.await(io);
1800 }
1801
1802 {
1803 // Sort by length, descending, so that child directories get removed first.
1804 sus_dirs.sortUnstable(@as(struct {
1805 keys: []const []const u8,
1806 pub fn lessThan(ctx: @This(), a_index: usize, b_index: usize) bool {
1807 return ctx.keys[b_index].len < ctx.keys[a_index].len;
1808 }
1809 }, .{ .keys = sus_dirs.keys() }));
1810
1811 // During this loop, more entries will be added, so we must loop by index.
1812 var i: usize = 0;
1813 while (i < sus_dirs.count()) : (i += 1) {
1814 const sus_dir = sus_dirs.keys()[i];
1815 root_dir.deleteDir(io, sus_dir) catch |err| switch (err) {
1816 error.DirNotEmpty => continue,
1817 error.FileNotFound => continue,
1818 else => |e| {
1819 try eb.addRootErrorMessage(.{ .msg = try eb.printString(
1820 "unable to delete empty directory '{s}': {s}",
1821 .{ sus_dir, @errorName(e) },
1822 ) });
1823 return error.FetchFailed;
1824 },
1825 };
1826 if (fs.path.dirname(sus_dir)) |parent| {
1827 try sus_dirs.put(gpa, parent, {});
1828 }
1829 }
1830 }
1831
1832 std.mem.sortUnstable(*HashedFile, all_files.items, {}, HashedFile.lessThan);
1833
1834 var hasher = Package.Hash.Algo.init(.{});
1835 var any_failures = false;
1836 for (all_files.items) |hashed_file| {
1837 hashed_file.failure catch |err| {
1838 any_failures = true;
1839 try eb.addRootErrorMessage(.{
1840 .msg = try eb.printString("unable to hash '{s}': {s}", .{
1841 hashed_file.fs_path, @errorName(err),
1842 }),
1843 });
1844 };
1845 hasher.update(&hashed_file.hash);
1846 total_size += hashed_file.size;
1847 }
1848 for (deleted_files.items) |deleted_file| {
1849 deleted_file.failure catch |err| {
1850 any_failures = true;
1851 try eb.addRootErrorMessage(.{
1852 .msg = try eb.printString("failed to delete excluded path '{s}' from package: {s}", .{
1853 deleted_file.fs_path, @errorName(err),
1854 }),
1855 });
1856 };
1857 }
1858
1859 if (any_failures) return error.FetchFailed;
1860
1861 if (f.job_queue.debug_hash) {
1862 assert(!f.job_queue.recursive);
1863 // Print something to stdout that can be text diffed to figure out why
1864 // the package hash is different.
1865 dumpHashInfo(io, all_files.items) catch |err|
1866 std.process.fatal("unable to write to stdout: {t}", .{err});
1867 }
1868
1869 return .{
1870 .digest = hasher.finalResult(),
1871 .total_size = total_size,
1872 };
1873}
1874
1875fn dumpHashInfo(io: Io, all_files: []const *const HashedFile) !void {
1876 var stdout_buffer: [1024]u8 = undefined;
1877 var stdout_writer: Io.File.Writer = .initStreaming(.stdout(), io, &stdout_buffer);
1878 dumpHashInfoWriter(&stdout_writer.interface, all_files) catch |err| switch (err) {
1879 error.WriteFailed => return stdout_writer.err.?,
1880 };
1881 try stdout_writer.flush();
1882}
1883
1884fn dumpHashInfoWriter(w: *Io.Writer, all_files: []const *const HashedFile) Io.Writer.Error!void {
1885 for (all_files) |hashed_file| {
1886 try w.print("{t}: {x}: {s}\n", .{ hashed_file.kind, &hashed_file.hash, hashed_file.normalized_path });
1887 }
1888}
1889
1890fn workerHashFile(io: Io, dir: Io.Dir, hashed_file: *HashedFile) void {
1891 hashed_file.failure = hashFileFallible(io, dir, hashed_file);
1892}
1893
1894fn workerDeleteFile(io: Io, dir: Io.Dir, deleted_file: *DeletedFile) void {
1895 deleted_file.failure = deleteFileFallible(io, dir, deleted_file);
1896}
1897
1898fn hashFileFallible(io: Io, dir: Io.Dir, hashed_file: *HashedFile) HashedFile.Error!void {
1899 var buf: [8000]u8 = undefined;
1900 var hasher = Package.Hash.Algo.init(.{});
1901 hasher.update(hashed_file.normalized_path);
1902 var file_size: u64 = 0;
1903
1904 switch (hashed_file.kind) {
1905 .file => {
1906 var file = try dir.openFile(io, hashed_file.fs_path, .{});
1907 defer file.close(io);
1908 // Hard-coded false executable bit: https://github.com/ziglang/zig/issues/17463
1909 hasher.update(&.{ 0, 0 });
1910 var file_header: FileHeader = .{};
1911 while (true) {
1912 const bytes_read = try file.readPositional(io, &.{&buf}, file_size);
1913 if (bytes_read == 0) break;
1914 file_size += bytes_read;
1915 hasher.update(buf[0..bytes_read]);
1916 file_header.update(buf[0..bytes_read]);
1917 }
1918 if (file_header.isExecutable()) {
1919 try setExecutable(io, file);
1920 }
1921 },
1922 .link => {
1923 const link_name = buf[0..try dir.readLink(io, hashed_file.fs_path, &buf)];
1924 if (fs.path.sep != canonical_sep) {
1925 // Package hashes are intended to be consistent across
1926 // platforms which means we must normalize path separators
1927 // inside symlinks.
1928 normalizePath(link_name);
1929 }
1930 hasher.update(link_name);
1931 },
1932 }
1933 hasher.final(&hashed_file.hash);
1934 hashed_file.size = file_size;
1935}
1936
1937fn deleteFileFallible(io: Io, dir: Io.Dir, deleted_file: *DeletedFile) DeletedFile.Error!void {
1938 try dir.deleteFile(io, deleted_file.fs_path);
1939}
1940
1941fn setExecutable(io: Io, file: Io.File) !void {
1942 if (!Io.File.Permissions.has_executable_bit) return;
1943 try file.setPermissions(io, .executable_file);
1944}
1945
1946const DeletedFile = struct {
1947 fs_path: []const u8,
1948 failure: Error!void,
1949
1950 const Error =
1951 Io.Dir.DeleteFileError ||
1952 Io.Dir.DeleteDirError;
1953};
1954
1955const HashedFile = struct {
1956 fs_path: []const u8,
1957 normalized_path: []const u8,
1958 hash: Package.Hash.Digest,
1959 failure: Error!void,
1960 kind: Kind,
1961 size: u64,
1962
1963 const Error =
1964 Io.File.OpenError ||
1965 Io.File.ReadPositionalError ||
1966 Io.File.StatError ||
1967 Io.File.SetPermissionsError ||
1968 Io.Dir.ReadLinkError;
1969
1970 const Kind = enum { file, link };
1971
1972 fn lessThan(context: void, lhs: *const HashedFile, rhs: *const HashedFile) bool {
1973 _ = context;
1974 return std.mem.lessThan(u8, lhs.normalized_path, rhs.normalized_path);
1975 }
1976};
1977
1978/// Strips root directory name from file system path.
1979fn stripRoot(fs_path: []const u8, root_dir: []const u8) []const u8 {
1980 if (root_dir.len == 0 or fs_path.len <= root_dir.len) return fs_path;
1981
1982 if (std.mem.eql(u8, fs_path[0..root_dir.len], root_dir) and fs.path.isSep(fs_path[root_dir.len])) {
1983 return fs_path[root_dir.len + 1 ..];
1984 }
1985
1986 return fs_path;
1987}
1988
1989/// Make a file system path identical independently of operating system path inconsistencies.
1990/// This converts backslashes into forward slashes.
1991fn normalizePathAlloc(arena: Allocator, pkg_path: []const u8) ![]const u8 {
1992 const normalized = try arena.dupe(u8, pkg_path);
1993 if (fs.path.sep == canonical_sep) return normalized;
1994 normalizePath(normalized);
1995 return normalized;
1996}
1997
1998const canonical_sep = fs.path.sep_posix;
1999
2000fn normalizePath(bytes: []u8) void {
2001 assert(fs.path.sep != canonical_sep);
2002 std.mem.replaceScalar(u8, bytes, fs.path.sep, canonical_sep);
2003}
2004
2005const Filter = struct {
2006 include_paths: std.array_hash_map.String(void) = .empty,
2007
2008 /// sub_path is relative to the package root.
2009 pub fn includePath(self: *const Filter, sub_path: []const u8) bool {
2010 if (self.include_paths.count() == 0) return true;
2011 if (self.include_paths.contains("")) return true;
2012 if (self.include_paths.contains(".")) return true;
2013 if (self.include_paths.contains(sub_path)) return true;
2014
2015 // Check if any included paths are parent directories of sub_path.
2016 var dirname = sub_path;
2017 while (std.fs.path.dirname(dirname)) |next_dirname| {
2018 if (self.include_paths.contains(next_dirname)) return true;
2019 dirname = next_dirname;
2020 }
2021
2022 return false;
2023 }
2024
2025 test includePath {
2026 const gpa = std.testing.allocator;
2027 var filter: Filter = .{};
2028 defer filter.include_paths.deinit(gpa);
2029
2030 try filter.include_paths.put(gpa, "src", {});
2031 try std.testing.expect(filter.includePath("src/core/unix/SDL_poll.c"));
2032 try std.testing.expect(!filter.includePath(".gitignore"));
2033 }
2034};
2035
2036pub fn depDigest(pkg_root: Path, cache_root: Directory, dep: Manifest.Dependency) ?Package.Hash {
2037 if (dep.hash) |h| return .fromSlice(h);
2038
2039 switch (dep.location) {
2040 .url => return null,
2041 .path => |rel_path| {
2042 var buf: [fs.max_path_bytes]u8 = undefined;
2043 var fba = std.heap.FixedBufferAllocator.init(&buf);
2044 const new_root = pkg_root.resolvePosix(fba.allocator(), rel_path) catch
2045 return null;
2046 return relativePathDigest(new_root, cache_root);
2047 },
2048 }
2049}
2050
2051// Detects executable header: ELF or Macho-O magic header or shebang line.
2052const FileHeader = struct {
2053 header: [4]u8 = undefined,
2054 bytes_read: usize = 0,
2055
2056 pub fn update(self: *FileHeader, buf: []const u8) void {
2057 if (self.bytes_read >= self.header.len) return;
2058 const n = @min(self.header.len - self.bytes_read, buf.len);
2059 @memcpy(self.header[self.bytes_read..][0..n], buf[0..n]);
2060 self.bytes_read += n;
2061 }
2062
2063 fn isScript(self: *FileHeader) bool {
2064 const shebang = "#!";
2065 return std.mem.eql(u8, self.header[0..@min(self.bytes_read, shebang.len)], shebang);
2066 }
2067
2068 fn isElf(self: *FileHeader) bool {
2069 const elf_magic = std.elf.MAGIC;
2070 return std.mem.eql(u8, self.header[0..@min(self.bytes_read, elf_magic.len)], elf_magic);
2071 }
2072
2073 fn isMachO(self: *FileHeader) bool {
2074 if (self.bytes_read < 4) return false;
2075 const magic_number = std.mem.readInt(u32, &self.header, builtin.cpu.arch.endian());
2076 return magic_number == std.macho.MH_MAGIC or
2077 magic_number == std.macho.MH_MAGIC_64 or
2078 magic_number == std.macho.FAT_MAGIC or
2079 magic_number == std.macho.FAT_MAGIC_64 or
2080 magic_number == std.macho.MH_CIGAM or
2081 magic_number == std.macho.MH_CIGAM_64 or
2082 magic_number == std.macho.FAT_CIGAM or
2083 magic_number == std.macho.FAT_CIGAM_64;
2084 }
2085
2086 pub fn isExecutable(self: *FileHeader) bool {
2087 return self.isScript() or self.isElf() or self.isMachO();
2088 }
2089};
2090
2091test FileHeader {
2092 var h: FileHeader = .{};
2093 try std.testing.expect(!h.isExecutable());
2094
2095 const elf_magic = std.elf.MAGIC;
2096 h.update(elf_magic[0..2]);
2097 try std.testing.expect(!h.isExecutable());
2098 h.update(elf_magic[2..4]);
2099 try std.testing.expect(h.isExecutable());
2100
2101 h.update(elf_magic[2..4]);
2102 try std.testing.expect(h.isExecutable());
2103
2104 const macho64_magic_bytes = [_]u8{ 0xCF, 0xFA, 0xED, 0xFE };
2105 h.bytes_read = 0;
2106 h.update(&macho64_magic_bytes);
2107 try std.testing.expect(h.isExecutable());
2108
2109 const macho64_cigam_bytes = [_]u8{ 0xFE, 0xED, 0xFA, 0xCF };
2110 h.bytes_read = 0;
2111 h.update(&macho64_cigam_bytes);
2112 try std.testing.expect(h.isExecutable());
2113}
2114
2115// Result of the `unpackResource` operation. Enables collecting errors from
2116// tar/git diagnostic, filtering that errors by manifest inclusion rules and
2117// emitting remaining errors to an `ErrorBundle`.
2118const UnpackResult = struct {
2119 errors: []Error = undefined,
2120 errors_count: usize = 0,
2121 root_error_message: []const u8 = "",
2122
2123 // A non empty value means that the package contents are inside a
2124 // sub-directory indicated by the named path.
2125 root_dir: []const u8 = "",
2126
2127 const Error = union(enum) {
2128 unable_to_create_sym_link: struct {
2129 code: anyerror,
2130 file_name: []const u8,
2131 link_name: []const u8,
2132 },
2133 unable_to_create_file: struct {
2134 code: anyerror,
2135 file_name: []const u8,
2136 },
2137 unsupported_file_type: struct {
2138 file_name: []const u8,
2139 file_type: u8,
2140 },
2141
2142 fn excluded(self: Error, filter: Filter) bool {
2143 const file_name = switch (self) {
2144 .unable_to_create_file => |info| info.file_name,
2145 .unable_to_create_sym_link => |info| info.file_name,
2146 .unsupported_file_type => |info| info.file_name,
2147 };
2148 return !filter.includePath(file_name);
2149 }
2150 };
2151
2152 fn allocErrors(self: *UnpackResult, arena: std.mem.Allocator, n: usize, root_error_message: []const u8) !void {
2153 self.root_error_message = try arena.dupe(u8, root_error_message);
2154 self.errors = try arena.alloc(UnpackResult.Error, n);
2155 }
2156
2157 fn hasErrors(self: *UnpackResult) bool {
2158 return self.errors_count > 0;
2159 }
2160
2161 fn unableToCreateFile(self: *UnpackResult, file_name: []const u8, err: anyerror) void {
2162 self.errors[self.errors_count] = .{ .unable_to_create_file = .{
2163 .code = err,
2164 .file_name = file_name,
2165 } };
2166 self.errors_count += 1;
2167 }
2168
2169 fn unableToCreateSymLink(self: *UnpackResult, file_name: []const u8, link_name: []const u8, err: anyerror) void {
2170 self.errors[self.errors_count] = .{ .unable_to_create_sym_link = .{
2171 .code = err,
2172 .file_name = file_name,
2173 .link_name = link_name,
2174 } };
2175 self.errors_count += 1;
2176 }
2177
2178 fn unsupportedFileType(self: *UnpackResult, file_name: []const u8, file_type: u8) void {
2179 self.errors[self.errors_count] = .{ .unsupported_file_type = .{
2180 .file_name = file_name,
2181 .file_type = file_type,
2182 } };
2183 self.errors_count += 1;
2184 }
2185
2186 fn validate(self: *UnpackResult, f: *Fetch, filter: Filter) !void {
2187 if (self.errors_count == 0) return;
2188
2189 var unfiltered_errors: u32 = 0;
2190 for (self.errors) |item| {
2191 if (item.excluded(filter)) continue;
2192 unfiltered_errors += 1;
2193 }
2194 if (unfiltered_errors == 0) return;
2195
2196 // Emmit errors to an `ErrorBundle`.
2197 const eb = &f.error_bundle;
2198 try eb.addRootErrorMessage(.{
2199 .msg = try eb.addString(self.root_error_message),
2200 .src_loc = try f.srcLoc(f.location_tok),
2201 .notes_len = unfiltered_errors,
2202 });
2203 var note_i: u32 = try eb.reserveNotes(unfiltered_errors);
2204 for (self.errors) |item| {
2205 if (item.excluded(filter)) continue;
2206 switch (item) {
2207 .unable_to_create_sym_link => |info| {
2208 eb.extra.items[note_i] = @backingInt(try eb.addErrorMessage(.{
2209 .msg = try eb.printString("unable to create symlink from '{s}' to '{s}': {s}", .{
2210 info.file_name, info.link_name, @errorName(info.code),
2211 }),
2212 }));
2213 },
2214 .unable_to_create_file => |info| {
2215 eb.extra.items[note_i] = @backingInt(try eb.addErrorMessage(.{
2216 .msg = try eb.printString("unable to create file '{s}': {s}", .{
2217 info.file_name, @errorName(info.code),
2218 }),
2219 }));
2220 },
2221 .unsupported_file_type => |info| {
2222 eb.extra.items[note_i] = @backingInt(try eb.addErrorMessage(.{
2223 .msg = try eb.printString("file '{s}' has unsupported type '{c}'", .{
2224 info.file_name, info.file_type,
2225 }),
2226 }));
2227 },
2228 }
2229 note_i += 1;
2230 }
2231
2232 return error.FetchFailed;
2233 }
2234
2235 test validate {
2236 const gpa = std.testing.allocator;
2237 var arena_instance = std.heap.ArenaAllocator.init(gpa);
2238 defer arena_instance.deinit();
2239 const arena = arena_instance.allocator();
2240
2241 // fill UnpackResult with errors
2242 var res: UnpackResult = .{};
2243 try res.allocErrors(arena, 4, "unable to unpack");
2244 try std.testing.expectEqual(0, res.errors_count);
2245 res.unableToCreateFile("dir1/file1", error.File1);
2246 res.unableToCreateSymLink("dir2/file2", "filename", error.SymlinkError);
2247 res.unableToCreateFile("dir1/file3", error.File3);
2248 res.unsupportedFileType("dir2/file4", 'x');
2249 try std.testing.expectEqual(4, res.errors_count);
2250
2251 // create filter, includes dir2, excludes dir1
2252 var filter: Filter = .{};
2253 try filter.include_paths.put(arena, "dir2", {});
2254
2255 // init Fetch
2256 var fetch: Fetch = undefined;
2257 fetch.parent_manifest_ast = null;
2258 fetch.location_tok = 0;
2259 try fetch.error_bundle.init(gpa);
2260 defer fetch.error_bundle.deinit();
2261
2262 // validate errors with filter
2263 try std.testing.expectError(error.FetchFailed, res.validate(&fetch, filter));
2264
2265 // output errors to string
2266 var errors = try fetch.error_bundle.toOwnedBundle("");
2267 defer errors.deinit(gpa);
2268 var aw: Io.Writer.Allocating = .init(gpa);
2269 defer aw.deinit();
2270 try errors.renderToWriter(.{}, &aw.writer);
2271 try std.testing.expectEqualStrings(
2272 \\error: unable to unpack
2273 \\ note: unable to create symlink from 'dir2/file2' to 'filename': SymlinkError
2274 \\ note: file 'dir2/file4' has unsupported type 'x'
2275 \\
2276 , aw.written());
2277 }
2278};
2279
2280test {
2281 _ = Filter;
2282 _ = FileType;
2283 _ = UnpackResult;
2284}