authorgravatar for andrew@ziglang.orgAndrew Kelley <andrew@ziglang.org> 2023-09-29 00:34:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2023-09-29 00:34:07-07:00
logc07d6e4c17ab555250e75bd053d27fd7df20f928
treef211d8e037e448d5111a9e94d2a256e51d3529a6
parented19ebc3605ec7e50166adf45f162dcf5540c42e
parente07e182fc1ef34a894e81fd360b47646c7c1a023
signaturebadge-question-mark Signed by PGP key 4AEE18F83AFDEB23

Merge pull request #14603 from AdamGoertz/file-uris

Support relative paths in package manager

3 files changed, 553 insertions(+), 272 deletions(-)

lib/std/Uri.zig+39-4
......@@ -134,6 +134,7 @@ pub const ParseError = error{ UnexpectedCharacter, InvalidFormat, InvalidPort };
134134/// original `text`. Each component that is provided, will be non-`null`.
135135pub fn parseWithoutScheme(text: []const u8) ParseError!Uri {
136136 var reader = SliceReader{ .slice = text };
137
137138 var uri = Uri{
138139 .scheme = "",
139140 .user = null,
......@@ -145,13 +146,14 @@ pub fn parseWithoutScheme(text: []const u8) ParseError!Uri {
145146 .fragment = null,
146147 };
147148
148 if (reader.peekPrefix("//")) { // authority part
149 if (reader.peekPrefix("//")) a: { // authority part
149150 std.debug.assert(reader.get().? == '/');
150151 std.debug.assert(reader.get().? == '/');
151152
152153 const authority = reader.readUntil(isAuthoritySeparator);
153 if (authority.len == 0)
154 return error.InvalidFormat;
154 if (authority.len == 0) {
155 if (reader.peekPrefix("/")) break :a else return error.InvalidFormat;
156 }
155157
156158 var start_of_host: usize = 0;
157159 if (std.mem.indexOf(u8, authority, "@")) |index| {
......@@ -224,7 +226,6 @@ pub fn format(
224226 try writer.writeAll(":");
225227 if (uri.host) |host| {
226228 try writer.writeAll("//");
227
228229 if (uri.user) |user| {
229230 try writer.writeAll(user);
230231 if (uri.password) |password| {
......@@ -486,6 +487,23 @@ test "should fail gracefully" {
486487 try std.testing.expectEqual(@as(ParseError!Uri, error.InvalidFormat), parse("foobar://"));
487488}
488489
490test "file" {
491 const parsed = try parse("file:///");
492 try std.testing.expectEqualSlices(u8, "file", parsed.scheme);
493 try std.testing.expectEqual(@as(?[]const u8, null), parsed.host);
494 try std.testing.expectEqualSlices(u8, "/", parsed.path);
495
496 const parsed2 = try parse("file:///an/absolute/path/to/something");
497 try std.testing.expectEqualSlices(u8, "file", parsed2.scheme);
498 try std.testing.expectEqual(@as(?[]const u8, null), parsed2.host);
499 try std.testing.expectEqualSlices(u8, "/an/absolute/path/to/something", parsed2.path);
500
501 const parsed3 = try parse("file://localhost/an/absolute/path/to/another/thing/");
502 try std.testing.expectEqualSlices(u8, "file", parsed3.scheme);
503 try std.testing.expectEqualSlices(u8, "localhost", parsed3.host.?);
504 try std.testing.expectEqualSlices(u8, "/an/absolute/path/to/another/thing/", parsed3.path);
505}
506
489507test "scheme" {
490508 try std.testing.expectEqualSlices(u8, "http", (try parse("http:_")).scheme);
491509 try std.testing.expectEqualSlices(u8, "scheme-mee", (try parse("scheme-mee:_")).scheme);
......@@ -695,3 +713,20 @@ test "URI query escaping" {
695713 defer std.testing.allocator.free(formatted_uri);
696714 try std.testing.expectEqualStrings("/?response-content-type=application%2Foctet-stream", formatted_uri);
697715}
716
717test "format" {
718 const uri = Uri{
719 .scheme = "file",
720 .user = null,
721 .password = null,
722 .host = null,
723 .port = null,
724 .path = "/foo/bar/baz",
725 .query = null,
726 .fragment = null,
727 };
728 var buf = std.ArrayList(u8).init(std.testing.allocator);
729 defer buf.deinit();
730 try uri.format("+/", .{}, buf.writer());
731 try std.testing.expectEqualSlices(u8, "file:/foo/bar/baz", buf.items);
732}
src/Manifest.zig+32-12
......@@ -2,8 +2,11 @@ pub const basename = "build.zig.zon";
22pub const Hash = std.crypto.hash.sha2.Sha256;
33
44pub const Dependency = struct {
5 url: []const u8,
6 url_tok: Ast.TokenIndex,
5 location: union(enum) {
6 url: []const u8,
7 path: []const u8,
8 },
9 location_tok: Ast.TokenIndex,
710 hash: ?[]const u8,
811 hash_tok: Ast.TokenIndex,
912};
......@@ -218,12 +221,12 @@ const Parse = struct {
218221 };
219222
220223 var dep: Dependency = .{
221 .url = undefined,
222 .url_tok = undefined,
224 .location = undefined,
225 .location_tok = undefined,
223226 .hash = null,
224227 .hash_tok = undefined,
225228 };
226 var have_url = false;
229 var has_location = false;
227230
228231 for (struct_init.ast.fields) |field_init| {
229232 const name_token = ast.firstToken(field_init) - 2;
......@@ -232,12 +235,29 @@ const Parse = struct {
232235 // things manually provides an opportunity to do any additional verification
233236 // that is desirable on a per-field basis.
234237 if (mem.eql(u8, field_name, "url")) {
235 dep.url = parseString(p, field_init) catch |err| switch (err) {
236 error.ParseFailure => continue,
237 else => |e| return e,
238 if (has_location) {
239 return fail(p, main_tokens[field_init], "dependency should specify only one of 'url' and 'path' fields.", .{});
240 }
241 dep.location = .{
242 .url = parseString(p, field_init) catch |err| switch (err) {
243 error.ParseFailure => continue,
244 else => |e| return e,
245 },
246 };
247 has_location = true;
248 dep.location_tok = main_tokens[field_init];
249 } else if (mem.eql(u8, field_name, "path")) {
250 if (has_location) {
251 return fail(p, main_tokens[field_init], "dependency should specify only one of 'url' and 'path' fields.", .{});
252 }
253 dep.location = .{
254 .path = parseString(p, field_init) catch |err| switch (err) {
255 error.ParseFailure => continue,
256 else => |e| return e,
257 },
238258 };
239 dep.url_tok = main_tokens[field_init];
240 have_url = true;
259 has_location = true;
260 dep.location_tok = main_tokens[field_init];
241261 } else if (mem.eql(u8, field_name, "hash")) {
242262 dep.hash = parseHash(p, field_init) catch |err| switch (err) {
243263 error.ParseFailure => continue,
......@@ -250,8 +270,8 @@ const Parse = struct {
250270 }
251271 }
252272
253 if (!have_url) {
254 try appendError(p, main_tokens[node], "dependency is missing 'url' field", .{});
273 if (!has_location) {
274 try appendError(p, main_tokens[node], "dependency requires location field, one of 'url' or 'path'.", .{});
255275 }
256276
257277 return dep;
src/Package.zig+482-256
......@@ -245,8 +245,6 @@ pub fn fetchAndAddDependencies(
245245 error.FileNotFound => {
246246 // Handle the same as no dependencies.
247247 if (this_hash) |hash| {
248 const pkg_dir_sub_path = "p" ++ fs.path.sep_str ++ hash[0..hex_multihash_len];
249 const build_root = try global_cache_directory.join(arena, &.{pkg_dir_sub_path});
250248 try dependencies_source.writer().print(
251249 \\ pub const {} = struct {{
252250 \\ pub const build_root = "{}";
......@@ -256,7 +254,7 @@ pub fn fetchAndAddDependencies(
256254 \\
257255 , .{
258256 std.zig.fmtId(hash),
259 std.zig.fmtEscapes(build_root),
257 std.zig.fmtEscapes(pkg.root_src_directory.path.?),
260258 std.zig.fmtEscapes(hash),
261259 });
262260 } else {
......@@ -312,66 +310,85 @@ pub fn fetchAndAddDependencies(
312310 try dependencies_source.writer().writeAll("pub const packages = struct {\n");
313311 }
314312
315 const deps_list = manifest.dependencies.values();
316 for (manifest.dependencies.keys(), 0..) |name, i| {
317 const dep = deps_list[i];
318
319 const sub = try fetchAndUnpack(
320 thread_pool,
321 http_client,
322 global_cache_directory,
323 dep,
324 report,
325 all_modules,
326 root_prog_node,
327 name,
328 );
329
330 if (sub.mod) |mod| {
331 if (!sub.found_existing) {
332 try mod.fetchAndAddDependencies(
333 deps_pkg,
334 arena,
313 for (manifest.dependencies.keys(), manifest.dependencies.values()) |name, *dep| {
314 var fetch_location = try FetchLocation.init(gpa, dep.*, directory, report);
315 defer fetch_location.deinit(gpa);
316
317 // Directories do not provide a hash in build.zig.zon.
318 // Hash the path to the module rather than its contents.
319 const sub_mod, const found_existing = if (fetch_location == .directory)
320 try getDirectoryModule(gpa, fetch_location, directory, all_modules, dep, report)
321 else
322 try getCachedPackage(
323 gpa,
324 global_cache_directory,
325 dep.*,
326 all_modules,
327 root_prog_node,
328 ) orelse .{
329 try fetchAndUnpack(
330 fetch_location,
335331 thread_pool,
336332 http_client,
337 mod.root_src_directory,
333 directory,
338334 global_cache_directory,
339 local_cache_directory,
340 dependencies_source,
341 error_bundle,
335 dep.*,
336 report,
342337 all_modules,
343338 root_prog_node,
344 dep.hash.?,
345 );
346 }
339 name,
340 ),
341 false,
342 };
347343
348 try pkg.add(gpa, name, mod);
349 if (deps_pkg.table.get(dep.hash.?)) |other_sub| {
350 // This should be the same package (and hence module) since it's the same hash
351 // TODO: dedup multiple versions of the same package
352 assert(other_sub == mod);
353 } else {
354 try deps_pkg.add(gpa, dep.hash.?, mod);
355 }
356 } else if (!sub.found_existing) {
357 const pkg_dir_sub_path = "p" ++ fs.path.sep_str ++ (dep.hash.?)[0..hex_multihash_len];
358 const build_root = try global_cache_directory.join(arena, &.{pkg_dir_sub_path});
359 try dependencies_source.writer().print(
360 \\ pub const {} = struct {{
361 \\ pub const build_root = "{}";
362 \\ pub const deps: []const struct {{ []const u8, []const u8 }} = &.{{}};
363 \\ }};
364 \\
365 , .{
366 std.zig.fmtId(dep.hash.?),
367 std.zig.fmtEscapes(build_root),
368 });
344 assert(dep.hash != null);
345
346 switch (sub_mod) {
347 .zig_pkg => |sub_pkg| {
348 if (!found_existing) {
349 try sub_pkg.fetchAndAddDependencies(
350 deps_pkg,
351 arena,
352 thread_pool,
353 http_client,
354 sub_pkg.root_src_directory,
355 global_cache_directory,
356 local_cache_directory,
357 dependencies_source,
358 error_bundle,
359 all_modules,
360 root_prog_node,
361 dep.hash.?,
362 );
363 }
364
365 try pkg.add(gpa, name, sub_pkg);
366 if (deps_pkg.table.get(dep.hash.?)) |other_sub| {
367 // This should be the same package (and hence module) since it's the same hash
368 // TODO: dedup multiple versions of the same package
369 assert(other_sub == sub_pkg);
370 } else {
371 try deps_pkg.add(gpa, dep.hash.?, sub_pkg);
372 }
373 },
374 .non_zig_pkg => |sub_pkg| {
375 if (!found_existing) {
376 try dependencies_source.writer().print(
377 \\ pub const {} = struct {{
378 \\ pub const build_root = "{}";
379 \\ pub const deps: []const struct {{ []const u8, []const u8 }} = &.{{}};
380 \\ }};
381 \\
382 , .{
383 std.zig.fmtId(dep.hash.?),
384 std.zig.fmtEscapes(sub_pkg.root_src_directory.path.?),
385 });
386 }
387 },
369388 }
370389 }
371390
372391 if (this_hash) |hash| {
373 const pkg_dir_sub_path = "p" ++ fs.path.sep_str ++ hash[0..hex_multihash_len];
374 const build_root = try global_cache_directory.join(arena, &.{pkg_dir_sub_path});
375392 try dependencies_source.writer().print(
376393 \\ pub const {} = struct {{
377394 \\ pub const build_root = "{}";
......@@ -380,7 +397,7 @@ pub fn fetchAndAddDependencies(
380397 \\
381398 , .{
382399 std.zig.fmtId(hash),
383 std.zig.fmtEscapes(build_root),
400 std.zig.fmtEscapes(pkg.root_src_directory.path.?),
384401 std.zig.fmtEscapes(hash),
385402 });
386403 for (manifest.dependencies.keys(), manifest.dependencies.values()) |name, dep| {
......@@ -490,15 +507,296 @@ const Report = struct {
490507 }
491508};
492509
510const FetchLocation = union(enum) {
511 /// The relative path to a file or directory.
512 /// This may be a file that requires unpacking (such as a .tar.gz),
513 /// or the path to the root directory of a package.
514 file: []const u8,
515 directory: []const u8,
516 http_request: std.Uri,
517
518 pub fn init(gpa: Allocator, dep: Manifest.Dependency, root_dir: Compilation.Directory, report: Report) !FetchLocation {
519 switch (dep.location) {
520 .url => |url| {
521 const uri = std.Uri.parse(url) catch |err| switch (err) {
522 error.UnexpectedCharacter => return report.fail(dep.location_tok, "failed to parse dependency location as URI", .{}),
523 else => return err,
524 };
525 if (ascii.eqlIgnoreCase(uri.scheme, "file")) {
526 return report.fail(dep.location_tok, "'file' scheme is not allowed for URLs. Use '.path' instead", .{});
527 }
528 return .{ .http_request = uri };
529 },
530 .path => |path| {
531 if (fs.path.isAbsolute(path)) {
532 return report.fail(dep.location_tok, "Absolute paths are not allowed. Use a relative path instead", .{});
533 }
534
535 const is_dir = isDirectory(root_dir, path) catch |err| switch (err) {
536 error.FileNotFound => return report.fail(dep.location_tok, "File not found: {s}", .{path}),
537 else => return err,
538 };
539
540 return if (is_dir)
541 .{ .directory = try gpa.dupe(u8, path) }
542 else
543 .{ .file = try gpa.dupe(u8, path) };
544 },
545 }
546 }
547
548 pub fn deinit(f: *FetchLocation, gpa: Allocator) void {
549 switch (f.*) {
550 inline .file, .directory => |path| gpa.free(path),
551 .http_request => {},
552 }
553 f.* = undefined;
554 }
555
556 pub fn fetch(
557 f: FetchLocation,
558 gpa: Allocator,
559 root_dir: Compilation.Directory,
560 http_client: *std.http.Client,
561 dep: Manifest.Dependency,
562 report: Report,
563 ) !ReadableResource {
564 switch (f) {
565 .file => |file| {
566 const owned_path = try gpa.dupe(u8, file);
567 errdefer gpa.free(owned_path);
568 return .{
569 .path = owned_path,
570 .resource = .{ .file = try root_dir.handle.openFile(file, .{}) },
571 };
572 },
573 .http_request => |uri| {
574 var h = std.http.Headers{ .allocator = gpa };
575 defer h.deinit();
576
577 var req = try http_client.request(.GET, uri, h, .{});
578 errdefer req.deinit();
579
580 try req.start(.{});
581 try req.wait();
582
583 if (req.response.status != .ok) {
584 return report.fail(dep.location_tok, "Expected response status '200 OK' got '{} {s}'", .{
585 @intFromEnum(req.response.status),
586 req.response.status.phrase() orelse "",
587 });
588 }
589
590 return .{
591 .path = try gpa.dupe(u8, uri.path),
592 .resource = .{ .http_request = req },
593 };
594 },
595 .directory => unreachable, // Directories do not require fetching
596 }
597 }
598};
599
600const ReadableResource = struct {
601 path: []const u8,
602 resource: union(enum) {
603 file: fs.File,
604 http_request: std.http.Client.Request,
605 },
606
607 /// Unpack the package into the global cache directory.
608 /// If `ps` does not require unpacking (for example, if it is a directory), then no caching is performed.
609 /// In either case, the hash is computed and returned along with the path to the package.
610 pub fn unpack(
611 rr: *ReadableResource,
612 allocator: Allocator,
613 thread_pool: *ThreadPool,
614 global_cache_directory: Compilation.Directory,
615 dep: Manifest.Dependency,
616 report: Report,
617 pkg_prog_node: *std.Progress.Node,
618 ) !PackageLocation {
619 switch (rr.resource) {
620 inline .file, .http_request => |*r| {
621 const s = fs.path.sep_str;
622 const rand_int = std.crypto.random.int(u64);
623 const tmp_dir_sub_path = "tmp" ++ s ++ Manifest.hex64(rand_int);
624
625 const actual_hash = h: {
626 var tmp_directory: Compilation.Directory = d: {
627 const path = try global_cache_directory.join(allocator, &.{tmp_dir_sub_path});
628 errdefer allocator.free(path);
629
630 const iterable_dir = try global_cache_directory.handle.makeOpenPathIterable(tmp_dir_sub_path, .{});
631 errdefer iterable_dir.close();
632
633 break :d .{
634 .path = path,
635 .handle = iterable_dir.dir,
636 };
637 };
638 defer tmp_directory.closeAndFree(allocator);
639
640 const opt_content_length = try rr.getSize();
641
642 var prog_reader: ProgressReader(@TypeOf(r.reader())) = .{
643 .child_reader = r.reader(),
644 .prog_node = pkg_prog_node,
645 .unit = if (opt_content_length) |content_length| unit: {
646 const kib = content_length / 1024;
647 const mib = kib / 1024;
648 if (mib > 0) {
649 pkg_prog_node.setEstimatedTotalItems(@intCast(mib));
650 pkg_prog_node.setUnit("MiB");
651 break :unit .mib;
652 } else {
653 pkg_prog_node.setEstimatedTotalItems(@intCast(@max(1, kib)));
654 pkg_prog_node.setUnit("KiB");
655 break :unit .kib;
656 }
657 } else .any,
658 };
659 pkg_prog_node.context.refresh();
660
661 switch (try rr.getFileType(dep, report)) {
662 .@"tar.gz" => try unpackTarball(allocator, prog_reader, tmp_directory.handle, std.compress.gzip),
663 // I have not checked what buffer sizes the xz decompression implementation uses
664 // by default, so the same logic applies for buffering the reader as for gzip.
665 .@"tar.xz" => try unpackTarball(allocator, prog_reader, tmp_directory.handle, std.compress.xz),
666 }
667
668 // Unpack completed - stop showing amount as progress
669 pkg_prog_node.setEstimatedTotalItems(0);
670 pkg_prog_node.setCompletedItems(0);
671 pkg_prog_node.context.refresh();
672
673 // TODO: delete files not included in the package prior to computing the package hash.
674 // for example, if the ini file has directives to include/not include certain files,
675 // apply those rules directly to the filesystem right here. This ensures that files
676 // not protected by the hash are not present on the file system.
677
678 break :h try computePackageHash(thread_pool, .{ .dir = tmp_directory.handle });
679 };
680
681 const pkg_dir_sub_path = "p" ++ s ++ Manifest.hexDigest(actual_hash);
682 const unpacked_path = try global_cache_directory.join(allocator, &.{pkg_dir_sub_path});
683 defer allocator.free(unpacked_path);
684
685 const relative_unpacked_path = try fs.path.relative(allocator, global_cache_directory.path.?, unpacked_path);
686 errdefer allocator.free(relative_unpacked_path);
687 try renameTmpIntoCache(global_cache_directory.handle, tmp_dir_sub_path, relative_unpacked_path);
688
689 return .{
690 .hash = actual_hash,
691 .relative_unpacked_path = relative_unpacked_path,
692 };
693 },
694 }
695 }
696
697 const FileType = enum {
698 @"tar.gz",
699 @"tar.xz",
700 };
701
702 pub fn getSize(rr: ReadableResource) !?u64 {
703 switch (rr.resource) {
704 // TODO: Handle case of chunked content-length
705 .http_request => |req| return req.response.content_length,
706 .file => |f| return (try f.metadata()).size(),
707 }
708 }
709
710 pub fn getFileType(rr: ReadableResource, dep: Manifest.Dependency, report: Report) !FileType {
711 switch (rr.resource) {
712 .file => {
713 return fileTypeFromPath(rr.path) orelse
714 return report.fail(dep.location_tok, "Unknown file type", .{});
715 },
716 .http_request => |req| {
717 const content_type = req.response.headers.getFirstValue("Content-Type") orelse
718 return report.fail(dep.location_tok, "Missing 'Content-Type' header", .{});
719
720 // If the response has a different content type than the URI indicates, override
721 // the previously assumed file type.
722 return if (ascii.eqlIgnoreCase(content_type, "application/gzip") or
723 ascii.eqlIgnoreCase(content_type, "application/x-gzip") or
724 ascii.eqlIgnoreCase(content_type, "application/tar+gzip"))
725 .@"tar.gz"
726 else if (ascii.eqlIgnoreCase(content_type, "application/x-xz"))
727 .@"tar.xz"
728 else if (ascii.eqlIgnoreCase(content_type, "application/octet-stream")) ty: {
729 // support gitlab tarball urls such as https://gitlab.com/<namespace>/<project>/-/archive/<sha>/<project>-<sha>.tar.gz
730 // whose content-disposition header is: 'attachment; filename="<project>-<sha>.tar.gz"'
731 const content_disposition = req.response.headers.getFirstValue("Content-Disposition") orelse
732 return report.fail(dep.location_tok, "Missing 'Content-Disposition' header for Content-Type=application/octet-stream", .{});
733 break :ty getAttachmentType(content_disposition) orelse
734 return report.fail(dep.location_tok, "Unsupported 'Content-Disposition' header value: '{s}' for Content-Type=application/octet-stream", .{content_disposition});
735 } else return report.fail(dep.location_tok, "Unrecognized value for 'Content-Type' header: {s}", .{content_type});
736 },
737 }
738 }
739
740 fn fileTypeFromPath(file_path: []const u8) ?FileType {
741 return if (ascii.endsWithIgnoreCase(file_path, ".tar.gz"))
742 .@"tar.gz"
743 else if (ascii.endsWithIgnoreCase(file_path, ".tar.xz"))
744 .@"tar.xz"
745 else
746 null;
747 }
748
749 fn getAttachmentType(content_disposition: []const u8) ?FileType {
750 const disposition_type_end = ascii.indexOfIgnoreCase(content_disposition, "attachment;") orelse return null;
751
752 var value_start = ascii.indexOfIgnoreCasePos(content_disposition, disposition_type_end + 1, "filename") orelse return null;
753 value_start += "filename".len;
754 if (content_disposition[value_start] == '*') {
755 value_start += 1;
756 }
757 if (content_disposition[value_start] != '=') return null;
758 value_start += 1;
759
760 var value_end = mem.indexOfPos(u8, content_disposition, value_start, ";") orelse content_disposition.len;
761 if (content_disposition[value_end - 1] == '\"') {
762 value_end -= 1;
763 }
764 return fileTypeFromPath(content_disposition[value_start..value_end]);
765 }
766
767 pub fn deinit(rr: *ReadableResource, gpa: Allocator) void {
768 gpa.free(rr.path);
769 switch (rr.resource) {
770 .file => |file| file.close(),
771 .http_request => |*req| req.deinit(),
772 }
773 rr.* = undefined;
774 }
775};
776
777pub const PackageLocation = struct {
778 /// For packages that require unpacking, this is the hash of the package contents.
779 /// For directories, this is the hash of the absolute file path.
780 hash: [Manifest.Hash.digest_length]u8,
781 relative_unpacked_path: []const u8,
782
783 pub fn deinit(pl: *PackageLocation, allocator: Allocator) void {
784 allocator.free(pl.relative_unpacked_path);
785 pl.* = undefined;
786 }
787};
788
493789const hex_multihash_len = 2 * Manifest.multihash_len;
494790const MultiHashHexDigest = [hex_multihash_len]u8;
791
792const DependencyModule = union(enum) {
793 zig_pkg: *Package,
794 non_zig_pkg: *Package,
795};
495796/// This is to avoid creating multiple modules for the same build.zig file.
496797/// If the value is `null`, the package is a known dependency, but has not yet
497798/// been fetched.
498pub const AllModules = std.AutoHashMapUnmanaged(MultiHashHexDigest, ?union(enum) {
499 zig_pkg: *Package,
500 non_zig_pkg: void,
501});
799pub const AllModules = std.AutoHashMapUnmanaged(MultiHashHexDigest, ?DependencyModule);
502800
503801fn ProgressReader(comptime ReaderType: type) type {
504802 return struct {
......@@ -542,29 +840,27 @@ fn ProgressReader(comptime ReaderType: type) type {
542840 };
543841}
544842
545fn fetchAndUnpack(
546 thread_pool: *ThreadPool,
547 http_client: *std.http.Client,
843/// Get a cached package if it exists.
844/// Returns `null` if the package has not been cached
845/// If the package exists in the cache, returns a pointer to the package and a
846/// boolean indicating whether this package has already been seen in the build
847/// (i.e. whether or not its transitive dependencies have been fetched).
848fn getCachedPackage(
849 gpa: Allocator,
548850 global_cache_directory: Compilation.Directory,
549851 dep: Manifest.Dependency,
550 report: Report,
551852 all_modules: *AllModules,
552853 root_prog_node: *std.Progress.Node,
553 /// This does not have to be any form of canonical or fully-qualified name: it
554 /// is only intended to be human-readable for progress reporting.
555 name_for_prog: []const u8,
556) !struct { mod: ?*Package, found_existing: bool } {
557 const gpa = http_client.allocator;
854) !?struct { DependencyModule, bool } {
558855 const s = fs.path.sep_str;
559
560856 // Check if the expected_hash is already present in the global package
561857 // cache, and thereby avoid both fetching and unpacking.
562 if (dep.hash) |h| cached: {
858 if (dep.hash) |h| {
563859 const hex_digest = h[0..hex_multihash_len];
564860 const pkg_dir_sub_path = "p" ++ s ++ hex_digest;
565861
566862 var pkg_dir = global_cache_directory.handle.openDir(pkg_dir_sub_path, .{}) catch |err| switch (err) {
567 error.FileNotFound => break :cached,
863 error.FileNotFound => return null,
568864 else => |e| return e,
569865 };
570866 errdefer pkg_dir.close();
......@@ -574,162 +870,99 @@ fn fetchAndUnpack(
574870 const gop = try all_modules.getOrPut(gpa, hex_digest.*);
575871 if (gop.found_existing) {
576872 if (gop.value_ptr.*) |mod| {
577 return switch (mod) {
578 .zig_pkg => |pkg| .{
579 .mod = pkg,
580 .found_existing = true,
581 },
582 .non_zig_pkg => .{
583 .mod = null,
584 .found_existing = true,
585 },
586 };
873 return .{ mod, true };
587874 }
588875 }
589876
590 pkg_dir.access(build_zig_basename, .{}) catch {
591 gop.value_ptr.* = .non_zig_pkg;
592 return .{
593 .mod = null,
594 .found_existing = false,
595 };
596 };
597
598 const build_root = try global_cache_directory.join(gpa, &.{pkg_dir_sub_path});
599 errdefer gpa.free(build_root);
600
601877 root_prog_node.completeOne();
602878
603 const ptr = try gpa.create(Package);
604 errdefer gpa.destroy(ptr);
879 const is_zig_mod = if (pkg_dir.access(build_zig_basename, .{})) |_| true else |_| false;
880 const basename = if (is_zig_mod) build_zig_basename else "";
881 const pkg = try createWithDir(gpa, global_cache_directory, pkg_dir_sub_path, basename);
605882
606 const owned_src_path = try gpa.dupe(u8, build_zig_basename);
607 errdefer gpa.free(owned_src_path);
883 const module: DependencyModule = if (is_zig_mod)
884 .{ .zig_pkg = pkg }
885 else
886 .{ .non_zig_pkg = pkg };
608887
609 ptr.* = .{
610 .root_src_directory = .{
611 .path = build_root,
612 .handle = pkg_dir,
613 },
614 .root_src_directory_owned = true,
615 .root_src_path = owned_src_path,
616 };
617
618 gop.value_ptr.* = .{ .zig_pkg = ptr };
619 return .{
620 .mod = ptr,
621 .found_existing = false,
622 };
888 try all_modules.put(gpa, hex_digest.*, module);
889 return .{ module, false };
623890 }
624891
625 var pkg_prog_node = root_prog_node.start(name_for_prog, 0);
626 defer pkg_prog_node.end();
627 pkg_prog_node.activate();
628 pkg_prog_node.context.refresh();
629
630 const uri = try std.Uri.parse(dep.url);
631
632 const rand_int = std.crypto.random.int(u64);
633 const tmp_dir_sub_path = "tmp" ++ s ++ Manifest.hex64(rand_int);
892 return null;
893}
634894
635 const actual_hash = a: {
636 var tmp_directory: Compilation.Directory = d: {
637 const path = try global_cache_directory.join(gpa, &.{tmp_dir_sub_path});
638 errdefer gpa.free(path);
895fn getDirectoryModule(
896 gpa: Allocator,
897 fetch_location: FetchLocation,
898 directory: Compilation.Directory,
899 all_modules: *AllModules,
900 dep: *Manifest.Dependency,
901 report: Report,
902) !struct { DependencyModule, bool } {
903 assert(fetch_location == .directory);
639904
640 const iterable_dir = try global_cache_directory.handle.makeOpenPathIterable(tmp_dir_sub_path, .{});
641 errdefer iterable_dir.close();
905 if (dep.hash != null) {
906 return report.fail(dep.hash_tok, "hash not allowed for directory package", .{});
907 }
642908
643 break :d .{
644 .path = path,
645 .handle = iterable_dir.dir,
646 };
647 };
648 defer tmp_directory.closeAndFree(gpa);
909 const hash = try computePathHash(gpa, directory, fetch_location.directory);
910 const hex_digest = Manifest.hexDigest(hash);
911 dep.hash = try gpa.dupe(u8, &hex_digest);
649912
650 var h = std.http.Headers{ .allocator = gpa };
651 defer h.deinit();
913 // There is no fixed location to check for directory modules.
914 // Instead, check whether it is already listed in all_modules.
915 if (all_modules.get(hex_digest)) |mod| return .{ mod.?, true };
652916
653 var req = try http_client.request(.GET, uri, h, .{});
654 defer req.deinit();
917 var pkg_dir = directory.handle.openDir(fetch_location.directory, .{}) catch |err| switch (err) {
918 error.FileNotFound => return report.fail(dep.location_tok, "File not found: {s}", .{fetch_location.directory}),
919 else => |e| return e,
920 };
921 defer pkg_dir.close();
655922
656 try req.start(.{});
657 try req.wait();
923 const is_zig_mod = if (pkg_dir.access(build_zig_basename, .{})) |_| true else |_| false;
924 const basename = if (is_zig_mod) build_zig_basename else "";
658925
659 if (req.response.status != .ok) {
660 return report.fail(dep.url_tok, "Expected response status '200 OK' got '{} {s}'", .{
661 @intFromEnum(req.response.status),
662 req.response.status.phrase() orelse "",
663 });
664 }
926 const pkg = try createWithDir(gpa, directory, fetch_location.directory, basename);
927 const module: DependencyModule = if (is_zig_mod)
928 .{ .zig_pkg = pkg }
929 else
930 .{ .non_zig_pkg = pkg };
665931
666 const content_type = req.response.headers.getFirstValue("Content-Type") orelse
667 return report.fail(dep.url_tok, "Missing 'Content-Type' header", .{});
668
669 var prog_reader: ProgressReader(std.http.Client.Request.Reader) = .{
670 .child_reader = req.reader(),
671 .prog_node = &pkg_prog_node,
672 .unit = if (req.response.content_length) |content_length| unit: {
673 const kib = content_length / 1024;
674 const mib = kib / 1024;
675 if (mib > 0) {
676 pkg_prog_node.setEstimatedTotalItems(@intCast(mib));
677 pkg_prog_node.setUnit("MiB");
678 break :unit .mib;
679 } else {
680 pkg_prog_node.setEstimatedTotalItems(@intCast(@max(1, kib)));
681 pkg_prog_node.setUnit("KiB");
682 break :unit .kib;
683 }
684 } else .any,
685 };
686 pkg_prog_node.context.refresh();
687
688 if (ascii.eqlIgnoreCase(content_type, "application/gzip") or
689 ascii.eqlIgnoreCase(content_type, "application/x-gzip") or
690 ascii.eqlIgnoreCase(content_type, "application/tar+gzip"))
691 {
692 // I observed the gzip stream to read 1 byte at a time, so I am using a
693 // buffered reader on the front of it.
694 try unpackTarball(gpa, prog_reader.reader(), tmp_directory.handle, std.compress.gzip);
695 } else if (ascii.eqlIgnoreCase(content_type, "application/x-xz")) {
696 // I have not checked what buffer sizes the xz decompression implementation uses
697 // by default, so the same logic applies for buffering the reader as for gzip.
698 try unpackTarball(gpa, prog_reader.reader(), tmp_directory.handle, std.compress.xz);
699 } else if (ascii.eqlIgnoreCase(content_type, "application/octet-stream")) {
700 // support gitlab tarball urls such as https://gitlab.com/<namespace>/<project>/-/archive/<sha>/<project>-<sha>.tar.gz
701 // whose content-disposition header is: 'attachment; filename="<project>-<sha>.tar.gz"'
702 const content_disposition = req.response.headers.getFirstValue("Content-Disposition") orelse
703 return report.fail(dep.url_tok, "Missing 'Content-Disposition' header for Content-Type=application/octet-stream", .{});
704 if (isTarAttachment(content_disposition)) {
705 try unpackTarball(gpa, prog_reader.reader(), tmp_directory.handle, std.compress.gzip);
706 } else return report.fail(dep.url_tok, "Unsupported 'Content-Disposition' header value: '{s}' for Content-Type=application/octet-stream", .{content_disposition});
707 } else {
708 return report.fail(dep.url_tok, "Unsupported 'Content-Type' header value: '{s}'", .{content_type});
709 }
932 try all_modules.put(gpa, hex_digest, module);
933 return .{ module, false };
934}
710935
711 // Download completed - stop showing downloaded amount as progress
712 pkg_prog_node.setEstimatedTotalItems(0);
713 pkg_prog_node.setCompletedItems(0);
714 pkg_prog_node.context.refresh();
936fn fetchAndUnpack(
937 fetch_location: FetchLocation,
938 thread_pool: *ThreadPool,
939 http_client: *std.http.Client,
940 directory: Compilation.Directory,
941 global_cache_directory: Compilation.Directory,
942 dep: Manifest.Dependency,
943 report: Report,
944 all_modules: *AllModules,
945 root_prog_node: *std.Progress.Node,
946 /// This does not have to be any form of canonical or fully-qualified name: it
947 /// is only intended to be human-readable for progress reporting.
948 name_for_prog: []const u8,
949) !DependencyModule {
950 assert(fetch_location == .file or fetch_location == .http_request);
715951
716 // TODO: delete files not included in the package prior to computing the package hash.
717 // for example, if the ini file has directives to include/not include certain files,
718 // apply those rules directly to the filesystem right here. This ensures that files
719 // not protected by the hash are not present on the file system.
952 const gpa = http_client.allocator;
720953
721 // TODO: raise an error for files that have illegal paths on some operating systems.
722 // For example, on Linux a path with a backslash should raise an error here.
723 // Of course, if the ignore rules above omit the file from the package, then everything
724 // is fine and no error should be raised.
954 var pkg_prog_node = root_prog_node.start(name_for_prog, 0);
955 defer pkg_prog_node.end();
956 pkg_prog_node.activate();
957 pkg_prog_node.context.refresh();
725958
726 break :a try computePackageHash(thread_pool, .{ .dir = tmp_directory.handle });
727 };
959 var readable_resource = try fetch_location.fetch(gpa, directory, http_client, dep, report);
960 defer readable_resource.deinit(gpa);
728961
729 const pkg_dir_sub_path = "p" ++ s ++ Manifest.hexDigest(actual_hash);
730 try renameTmpIntoCache(global_cache_directory.handle, tmp_dir_sub_path, pkg_dir_sub_path);
962 var package_location = try readable_resource.unpack(gpa, thread_pool, global_cache_directory, dep, report, &pkg_prog_node);
963 defer package_location.deinit(gpa);
731964
732 const actual_hex = Manifest.hexDigest(actual_hash);
965 const actual_hex = Manifest.hexDigest(package_location.hash);
733966 if (dep.hash) |h| {
734967 if (!mem.eql(u8, h, &actual_hex)) {
735968 return report.fail(dep.hash_tok, "hash mismatch: expected: {s}, found: {s}", .{
......@@ -743,9 +976,9 @@ fn fetchAndUnpack(
743976 const eb = report.error_bundle;
744977 const notes_len = 1;
745978 try Report.addErrorMessage(report.ast.*, file_path, eb, notes_len, .{
746 .tok = dep.url_tok,
979 .tok = dep.location_tok,
747980 .off = 0,
748 .msg = "url field is missing corresponding hash field",
981 .msg = "dependency is missing hash field",
749982 });
750983 const notes_start = try eb.reserveNotes(notes_len);
751984 eb.extra.items[notes_start] = @intFromEnum(try eb.addErrorMessage(.{
......@@ -754,35 +987,28 @@ fn fetchAndUnpack(
754987 return error.PackageFetchFailed;
755988 }
756989
757 const build_zig_path = try std.fs.path.join(gpa, &.{ pkg_dir_sub_path, build_zig_basename });
990 const build_zig_path = try fs.path.join(gpa, &.{ package_location.relative_unpacked_path, build_zig_basename });
758991 defer gpa.free(build_zig_path);
759992
760 global_cache_directory.handle.access(build_zig_path, .{}) catch |err| switch (err) {
761 error.FileNotFound => {
762 try all_modules.put(gpa, actual_hex, .non_zig_pkg);
763 return .{
764 .mod = null,
765 .found_existing = false,
766 };
767 },
768 else => return err,
769 };
993 const is_zig_mod = if (global_cache_directory.handle.access(build_zig_path, .{})) |_| true else |_| false;
994 const basename = if (is_zig_mod) build_zig_basename else "";
995 const pkg = try createWithDir(gpa, global_cache_directory, package_location.relative_unpacked_path, basename);
996 const module: DependencyModule = if (is_zig_mod)
997 .{ .zig_pkg = pkg }
998 else
999 .{ .non_zig_pkg = pkg };
7701000
771 const mod = try createWithDir(gpa, global_cache_directory, pkg_dir_sub_path, build_zig_basename);
772 try all_modules.put(gpa, actual_hex, .{ .zig_pkg = mod });
773 return .{
774 .mod = mod,
775 .found_existing = false,
776 };
1001 try all_modules.put(gpa, actual_hex, module);
1002 return module;
7771003}
7781004
7791005fn unpackTarball(
7801006 gpa: Allocator,
781 req_reader: anytype,
1007 reader: anytype,
7821008 out_dir: fs.Dir,
7831009 comptime compression: type,
7841010) !void {
785 var br = std.io.bufferedReaderSize(std.crypto.tls.max_ciphertext_record_len, req_reader);
1011 var br = std.io.bufferedReaderSize(std.crypto.tls.max_ciphertext_record_len, reader);
7861012
7871013 var decompress = try compression.decompress(gpa, br.reader());
7881014 defer decompress.deinit();
......@@ -873,6 +1099,24 @@ fn computePackageHash(
8731099 return hasher.finalResult();
8741100}
8751101
1102/// Compute the hash of a file path.
1103fn computePathHash(gpa: Allocator, dir: Compilation.Directory, path: []const u8) ![Manifest.Hash.digest_length]u8 {
1104 const resolved_path = try std.fs.path.resolve(gpa, &.{ dir.path.?, path });
1105 defer gpa.free(resolved_path);
1106 var hasher = Manifest.Hash.init(.{});
1107 hasher.update(resolved_path);
1108 return hasher.finalResult();
1109}
1110
1111fn isDirectory(root_dir: Compilation.Directory, path: []const u8) !bool {
1112 var dir = root_dir.handle.openDir(path, .{}) catch |err| switch (err) {
1113 error.NotDir => return false,
1114 else => return err,
1115 };
1116 defer dir.close();
1117 return true;
1118}
1119
8761120/// Make a file system path identical independently of operating system path inconsistencies.
8771121/// This converts backslashes into forward slashes.
8781122fn normalizePath(arena: Allocator, fs_path: []const u8) ![]const u8 {
......@@ -953,36 +1197,18 @@ fn renameTmpIntoCache(
9531197 }
9541198}
9551199
956fn isTarAttachment(content_disposition: []const u8) bool {
957 const disposition_type_end = ascii.indexOfIgnoreCase(content_disposition, "attachment;") orelse return false;
958
959 var value_start = ascii.indexOfIgnoreCasePos(content_disposition, disposition_type_end + 1, "filename") orelse return false;
960 value_start += "filename".len;
961 if (content_disposition[value_start] == '*') {
962 value_start += 1;
963 }
964 if (content_disposition[value_start] != '=') return false;
965 value_start += 1;
966
967 var value_end = mem.indexOfPos(u8, content_disposition, value_start, ";") orelse content_disposition.len;
968 if (content_disposition[value_end - 1] == '\"') {
969 value_end -= 1;
970 }
971 return ascii.endsWithIgnoreCase(content_disposition[value_start..value_end], ".tar.gz");
972}
973
974test "isTarAttachment" {
975 try std.testing.expect(isTarAttachment("attaChment; FILENAME=\"stuff.tar.gz\"; size=42"));
976 try std.testing.expect(isTarAttachment("attachment; filename*=\"stuff.tar.gz\""));
977 try std.testing.expect(isTarAttachment("ATTACHMENT; filename=\"stuff.tar.gz\""));
978 try std.testing.expect(isTarAttachment("attachment; FileName=\"stuff.tar.gz\""));
979 try std.testing.expect(isTarAttachment("attachment; FileName*=UTF-8\'\'xyz%2Fstuff.tar.gz"));
980
981 try std.testing.expect(!isTarAttachment("attachment FileName=\"stuff.tar.gz\""));
982 try std.testing.expect(!isTarAttachment("attachment; FileName=\"stuff.tar\""));
983 try std.testing.expect(!isTarAttachment("attachment; FileName\"stuff.gz\""));
984 try std.testing.expect(!isTarAttachment("attachment; size=42"));
985 try std.testing.expect(!isTarAttachment("inline; size=42"));
986 try std.testing.expect(!isTarAttachment("FileName=\"stuff.tar.gz\"; attachment;"));
987 try std.testing.expect(!isTarAttachment("FileName=\"stuff.tar.gz\";"));
1200test "getAttachmentType" {
1201 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.gz"), ReadableResource.getAttachmentType("attaChment; FILENAME=\"stuff.tar.gz\"; size=42"));
1202 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.gz"), ReadableResource.getAttachmentType("attachment; filename*=\"stuff.tar.gz\""));
1203 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.xz"), ReadableResource.getAttachmentType("ATTACHMENT; filename=\"stuff.tar.xz\""));
1204 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.xz"), ReadableResource.getAttachmentType("attachment; FileName=\"stuff.tar.xz\""));
1205 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.gz"), ReadableResource.getAttachmentType("attachment; FileName*=UTF-8\'\'xyz%2Fstuff.tar.gz"));
1206
1207 try std.testing.expect(ReadableResource.getAttachmentType("attachment FileName=\"stuff.tar.gz\"") == null);
1208 try std.testing.expect(ReadableResource.getAttachmentType("attachment; FileName=\"stuff.tar\"") == null);
1209 try std.testing.expect(ReadableResource.getAttachmentType("attachment; FileName\"stuff.gz\"") == null);
1210 try std.testing.expect(ReadableResource.getAttachmentType("attachment; size=42") == null);
1211 try std.testing.expect(ReadableResource.getAttachmentType("inline; size=42") == null);
1212 try std.testing.expect(ReadableResource.getAttachmentType("FileName=\"stuff.tar.gz\"; attachment;") == null);
1213 try std.testing.expect(ReadableResource.getAttachmentType("FileName=\"stuff.tar.gz\";") == null);
9881214}