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 };...@@ -134,6 +134,7 @@ pub const ParseError = error{ UnexpectedCharacter, InvalidFormat, InvalidPort };
134/// original `text`. Each component that is provided, will be non-`null`.134/// original `text`. Each component that is provided, will be non-`null`.
135pub fn parseWithoutScheme(text: []const u8) ParseError!Uri {135pub fn parseWithoutScheme(text: []const u8) ParseError!Uri {
136 var reader = SliceReader{ .slice = text };136 var reader = SliceReader{ .slice = text };
137
137 var uri = Uri{138 var uri = Uri{
138 .scheme = "",139 .scheme = "",
139 .user = null,140 .user = null,
...@@ -145,13 +146,14 @@ pub fn parseWithoutScheme(text: []const u8) ParseError!Uri {...@@ -145,13 +146,14 @@ pub fn parseWithoutScheme(text: []const u8) ParseError!Uri {
145 .fragment = null,146 .fragment = null,
146 };147 };
147148
148 if (reader.peekPrefix("//")) { // authority part149 if (reader.peekPrefix("//")) a: { // authority part
149 std.debug.assert(reader.get().? == '/');150 std.debug.assert(reader.get().? == '/');
150 std.debug.assert(reader.get().? == '/');151 std.debug.assert(reader.get().? == '/');
151152
152 const authority = reader.readUntil(isAuthoritySeparator);153 const authority = reader.readUntil(isAuthoritySeparator);
153 if (authority.len == 0)154 if (authority.len == 0) {
154 return error.InvalidFormat;155 if (reader.peekPrefix("/")) break :a else return error.InvalidFormat;
156 }
155157
156 var start_of_host: usize = 0;158 var start_of_host: usize = 0;
157 if (std.mem.indexOf(u8, authority, "@")) |index| {159 if (std.mem.indexOf(u8, authority, "@")) |index| {
...@@ -224,7 +226,6 @@ pub fn format(...@@ -224,7 +226,6 @@ pub fn format(
224 try writer.writeAll(":");226 try writer.writeAll(":");
225 if (uri.host) |host| {227 if (uri.host) |host| {
226 try writer.writeAll("//");228 try writer.writeAll("//");
227
228 if (uri.user) |user| {229 if (uri.user) |user| {
229 try writer.writeAll(user);230 try writer.writeAll(user);
230 if (uri.password) |password| {231 if (uri.password) |password| {
...@@ -486,6 +487,23 @@ test "should fail gracefully" {...@@ -486,6 +487,23 @@ test "should fail gracefully" {
486 try std.testing.expectEqual(@as(ParseError!Uri, error.InvalidFormat), parse("foobar://"));487 try std.testing.expectEqual(@as(ParseError!Uri, error.InvalidFormat), parse("foobar://"));
487}488}
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
489test "scheme" {507test "scheme" {
490 try std.testing.expectEqualSlices(u8, "http", (try parse("http:_")).scheme);508 try std.testing.expectEqualSlices(u8, "http", (try parse("http:_")).scheme);
491 try std.testing.expectEqualSlices(u8, "scheme-mee", (try parse("scheme-mee:_")).scheme);509 try std.testing.expectEqualSlices(u8, "scheme-mee", (try parse("scheme-mee:_")).scheme);
...@@ -695,3 +713,20 @@ test "URI query escaping" {...@@ -695,3 +713,20 @@ test "URI query escaping" {
695 defer std.testing.allocator.free(formatted_uri);713 defer std.testing.allocator.free(formatted_uri);
696 try std.testing.expectEqualStrings("/?response-content-type=application%2Foctet-stream", formatted_uri);714 try std.testing.expectEqualStrings("/?response-content-type=application%2Foctet-stream", formatted_uri);
697}715}
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";...@@ -2,8 +2,11 @@ pub const basename = "build.zig.zon";
2pub const Hash = std.crypto.hash.sha2.Sha256;2pub const Hash = std.crypto.hash.sha2.Sha256;
33
4pub const Dependency = struct {4pub const Dependency = struct {
5 url: []const u8,5 location: union(enum) {
6 url_tok: Ast.TokenIndex,6 url: []const u8,
7 path: []const u8,
8 },
9 location_tok: Ast.TokenIndex,
7 hash: ?[]const u8,10 hash: ?[]const u8,
8 hash_tok: Ast.TokenIndex,11 hash_tok: Ast.TokenIndex,
9};12};
...@@ -218,12 +221,12 @@ const Parse = struct {...@@ -218,12 +221,12 @@ const Parse = struct {
218 };221 };
219222
220 var dep: Dependency = .{223 var dep: Dependency = .{
221 .url = undefined,224 .location = undefined,
222 .url_tok = undefined,225 .location_tok = undefined,
223 .hash = null,226 .hash = null,
224 .hash_tok = undefined,227 .hash_tok = undefined,
225 };228 };
226 var have_url = false;229 var has_location = false;
227230
228 for (struct_init.ast.fields) |field_init| {231 for (struct_init.ast.fields) |field_init| {
229 const name_token = ast.firstToken(field_init) - 2;232 const name_token = ast.firstToken(field_init) - 2;
...@@ -232,12 +235,29 @@ const Parse = struct {...@@ -232,12 +235,29 @@ const Parse = struct {
232 // things manually provides an opportunity to do any additional verification235 // things manually provides an opportunity to do any additional verification
233 // that is desirable on a per-field basis.236 // that is desirable on a per-field basis.
234 if (mem.eql(u8, field_name, "url")) {237 if (mem.eql(u8, field_name, "url")) {
235 dep.url = parseString(p, field_init) catch |err| switch (err) {238 if (has_location) {
236 error.ParseFailure => continue,239 return fail(p, main_tokens[field_init], "dependency should specify only one of 'url' and 'path' fields.", .{});
237 else => |e| return e,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 },
238 };258 };
239 dep.url_tok = main_tokens[field_init];259 has_location = true;
240 have_url = true;260 dep.location_tok = main_tokens[field_init];
241 } else if (mem.eql(u8, field_name, "hash")) {261 } else if (mem.eql(u8, field_name, "hash")) {
242 dep.hash = parseHash(p, field_init) catch |err| switch (err) {262 dep.hash = parseHash(p, field_init) catch |err| switch (err) {
243 error.ParseFailure => continue,263 error.ParseFailure => continue,
...@@ -250,8 +270,8 @@ const Parse = struct {...@@ -250,8 +270,8 @@ const Parse = struct {
250 }270 }
251 }271 }
252272
253 if (!have_url) {273 if (!has_location) {
254 try appendError(p, main_tokens[node], "dependency is missing 'url' field", .{});274 try appendError(p, main_tokens[node], "dependency requires location field, one of 'url' or 'path'.", .{});
255 }275 }
256276
257 return dep;277 return dep;
src/Package.zig+482-256
...@@ -245,8 +245,6 @@ pub fn fetchAndAddDependencies(...@@ -245,8 +245,6 @@ pub fn fetchAndAddDependencies(
245 error.FileNotFound => {245 error.FileNotFound => {
246 // Handle the same as no dependencies.246 // Handle the same as no dependencies.
247 if (this_hash) |hash| {247 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});
250 try dependencies_source.writer().print(248 try dependencies_source.writer().print(
251 \\ pub const {} = struct {{249 \\ pub const {} = struct {{
252 \\ pub const build_root = "{}";250 \\ pub const build_root = "{}";
...@@ -256,7 +254,7 @@ pub fn fetchAndAddDependencies(...@@ -256,7 +254,7 @@ pub fn fetchAndAddDependencies(
256 \\254 \\
257 , .{255 , .{
258 std.zig.fmtId(hash),256 std.zig.fmtId(hash),
259 std.zig.fmtEscapes(build_root),257 std.zig.fmtEscapes(pkg.root_src_directory.path.?),
260 std.zig.fmtEscapes(hash),258 std.zig.fmtEscapes(hash),
261 });259 });
262 } else {260 } else {
...@@ -312,66 +310,85 @@ pub fn fetchAndAddDependencies(...@@ -312,66 +310,85 @@ pub fn fetchAndAddDependencies(
312 try dependencies_source.writer().writeAll("pub const packages = struct {\n");310 try dependencies_source.writer().writeAll("pub const packages = struct {\n");
313 }311 }
314312
315 const deps_list = manifest.dependencies.values();313 for (manifest.dependencies.keys(), manifest.dependencies.values()) |name, *dep| {
316 for (manifest.dependencies.keys(), 0..) |name, i| {314 var fetch_location = try FetchLocation.init(gpa, dep.*, directory, report);
317 const dep = deps_list[i];315 defer fetch_location.deinit(gpa);
318316
319 const sub = try fetchAndUnpack(317 // Directories do not provide a hash in build.zig.zon.
320 thread_pool,318 // Hash the path to the module rather than its contents.
321 http_client,319 const sub_mod, const found_existing = if (fetch_location == .directory)
322 global_cache_directory,320 try getDirectoryModule(gpa, fetch_location, directory, all_modules, dep, report)
323 dep,321 else
324 report,322 try getCachedPackage(
325 all_modules,323 gpa,
326 root_prog_node,324 global_cache_directory,
327 name,325 dep.*,
328 );326 all_modules,
329327 root_prog_node,
330 if (sub.mod) |mod| {328 ) orelse .{
331 if (!sub.found_existing) {329 try fetchAndUnpack(
332 try mod.fetchAndAddDependencies(330 fetch_location,
333 deps_pkg,
334 arena,
335 thread_pool,331 thread_pool,
336 http_client,332 http_client,
337 mod.root_src_directory,333 directory,
338 global_cache_directory,334 global_cache_directory,
339 local_cache_directory,335 dep.*,
340 dependencies_source,336 report,
341 error_bundle,
342 all_modules,337 all_modules,
343 root_prog_node,338 root_prog_node,
344 dep.hash.?,339 name,
345 );340 ),
346 }341 false,
342 };
347343
348 try pkg.add(gpa, name, mod);344 assert(dep.hash != null);
349 if (deps_pkg.table.get(dep.hash.?)) |other_sub| {345
350 // This should be the same package (and hence module) since it's the same hash346 switch (sub_mod) {
351 // TODO: dedup multiple versions of the same package347 .zig_pkg => |sub_pkg| {
352 assert(other_sub == mod);348 if (!found_existing) {
353 } else {349 try sub_pkg.fetchAndAddDependencies(
354 try deps_pkg.add(gpa, dep.hash.?, mod);350 deps_pkg,
355 }351 arena,
356 } else if (!sub.found_existing) {352 thread_pool,
357 const pkg_dir_sub_path = "p" ++ fs.path.sep_str ++ (dep.hash.?)[0..hex_multihash_len];353 http_client,
358 const build_root = try global_cache_directory.join(arena, &.{pkg_dir_sub_path});354 sub_pkg.root_src_directory,
359 try dependencies_source.writer().print(355 global_cache_directory,
360 \\ pub const {} = struct {{356 local_cache_directory,
361 \\ pub const build_root = "{}";357 dependencies_source,
362 \\ pub const deps: []const struct {{ []const u8, []const u8 }} = &.{{}};358 error_bundle,
363 \\ }};359 all_modules,
364 \\360 root_prog_node,
365 , .{361 dep.hash.?,
366 std.zig.fmtId(dep.hash.?),362 );
367 std.zig.fmtEscapes(build_root),363 }
368 });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 },
369 }388 }
370 }389 }
371390
372 if (this_hash) |hash| {391 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});
375 try dependencies_source.writer().print(392 try dependencies_source.writer().print(
376 \\ pub const {} = struct {{393 \\ pub const {} = struct {{
377 \\ pub const build_root = "{}";394 \\ pub const build_root = "{}";
...@@ -380,7 +397,7 @@ pub fn fetchAndAddDependencies(...@@ -380,7 +397,7 @@ pub fn fetchAndAddDependencies(
380 \\397 \\
381 , .{398 , .{
382 std.zig.fmtId(hash),399 std.zig.fmtId(hash),
383 std.zig.fmtEscapes(build_root),400 std.zig.fmtEscapes(pkg.root_src_directory.path.?),
384 std.zig.fmtEscapes(hash),401 std.zig.fmtEscapes(hash),
385 });402 });
386 for (manifest.dependencies.keys(), manifest.dependencies.values()) |name, dep| {403 for (manifest.dependencies.keys(), manifest.dependencies.values()) |name, dep| {
...@@ -490,15 +507,296 @@ const Report = struct {...@@ -490,15 +507,296 @@ const Report = struct {
490 }507 }
491};508};
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
493const hex_multihash_len = 2 * Manifest.multihash_len;789const hex_multihash_len = 2 * Manifest.multihash_len;
494const MultiHashHexDigest = [hex_multihash_len]u8;790const MultiHashHexDigest = [hex_multihash_len]u8;
791
792const DependencyModule = union(enum) {
793 zig_pkg: *Package,
794 non_zig_pkg: *Package,
795};
495/// This is to avoid creating multiple modules for the same build.zig file.796/// This is to avoid creating multiple modules for the same build.zig file.
496/// If the value is `null`, the package is a known dependency, but has not yet797/// If the value is `null`, the package is a known dependency, but has not yet
497/// been fetched.798/// been fetched.
498pub const AllModules = std.AutoHashMapUnmanaged(MultiHashHexDigest, ?union(enum) {799pub const AllModules = std.AutoHashMapUnmanaged(MultiHashHexDigest, ?DependencyModule);
499 zig_pkg: *Package,
500 non_zig_pkg: void,
501});
502800
503fn ProgressReader(comptime ReaderType: type) type {801fn ProgressReader(comptime ReaderType: type) type {
504 return struct {802 return struct {
...@@ -542,29 +840,27 @@ fn ProgressReader(comptime ReaderType: type) type {...@@ -542,29 +840,27 @@ fn ProgressReader(comptime ReaderType: type) type {
542 };840 };
543}841}
544842
545fn fetchAndUnpack(843/// Get a cached package if it exists.
546 thread_pool: *ThreadPool,844/// Returns `null` if the package has not been cached
547 http_client: *std.http.Client,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,
548 global_cache_directory: Compilation.Directory,850 global_cache_directory: Compilation.Directory,
549 dep: Manifest.Dependency,851 dep: Manifest.Dependency,
550 report: Report,
551 all_modules: *AllModules,852 all_modules: *AllModules,
552 root_prog_node: *std.Progress.Node,853 root_prog_node: *std.Progress.Node,
553 /// This does not have to be any form of canonical or fully-qualified name: it854) !?struct { DependencyModule, bool } {
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;
558 const s = fs.path.sep_str;855 const s = fs.path.sep_str;
559
560 // Check if the expected_hash is already present in the global package856 // Check if the expected_hash is already present in the global package
561 // cache, and thereby avoid both fetching and unpacking.857 // cache, and thereby avoid both fetching and unpacking.
562 if (dep.hash) |h| cached: {858 if (dep.hash) |h| {
563 const hex_digest = h[0..hex_multihash_len];859 const hex_digest = h[0..hex_multihash_len];
564 const pkg_dir_sub_path = "p" ++ s ++ hex_digest;860 const pkg_dir_sub_path = "p" ++ s ++ hex_digest;
565861
566 var pkg_dir = global_cache_directory.handle.openDir(pkg_dir_sub_path, .{}) catch |err| switch (err) {862 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,
568 else => |e| return e,864 else => |e| return e,
569 };865 };
570 errdefer pkg_dir.close();866 errdefer pkg_dir.close();
...@@ -574,162 +870,99 @@ fn fetchAndUnpack(...@@ -574,162 +870,99 @@ fn fetchAndUnpack(
574 const gop = try all_modules.getOrPut(gpa, hex_digest.*);870 const gop = try all_modules.getOrPut(gpa, hex_digest.*);
575 if (gop.found_existing) {871 if (gop.found_existing) {
576 if (gop.value_ptr.*) |mod| {872 if (gop.value_ptr.*) |mod| {
577 return switch (mod) {873 return .{ mod, true };
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 };
587 }874 }
588 }875 }
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
601 root_prog_node.completeOne();877 root_prog_node.completeOne();
602878
603 const ptr = try gpa.create(Package);879 const is_zig_mod = if (pkg_dir.access(build_zig_basename, .{})) |_| true else |_| false;
604 errdefer gpa.destroy(ptr);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);883 const module: DependencyModule = if (is_zig_mod)
607 errdefer gpa.free(owned_src_path);884 .{ .zig_pkg = pkg }
885 else
886 .{ .non_zig_pkg = pkg };
608887
609 ptr.* = .{888 try all_modules.put(gpa, hex_digest.*, module);
610 .root_src_directory = .{889 return .{ module, false };
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 };
623 }890 }
624891
625 var pkg_prog_node = root_prog_node.start(name_for_prog, 0);892 return null;
626 defer pkg_prog_node.end();893}
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);
634894
635 const actual_hash = a: {895fn getDirectoryModule(
636 var tmp_directory: Compilation.Directory = d: {896 gpa: Allocator,
637 const path = try global_cache_directory.join(gpa, &.{tmp_dir_sub_path});897 fetch_location: FetchLocation,
638 errdefer gpa.free(path);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, .{});905 if (dep.hash != null) {
641 errdefer iterable_dir.close();906 return report.fail(dep.hash_tok, "hash not allowed for directory package", .{});
907 }
642908
643 break :d .{909 const hash = try computePathHash(gpa, directory, fetch_location.directory);
644 .path = path,910 const hex_digest = Manifest.hexDigest(hash);
645 .handle = iterable_dir.dir,911 dep.hash = try gpa.dupe(u8, &hex_digest);
646 };
647 };
648 defer tmp_directory.closeAndFree(gpa);
649912
650 var h = std.http.Headers{ .allocator = gpa };913 // There is no fixed location to check for directory modules.
651 defer h.deinit();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, .{});917 var pkg_dir = directory.handle.openDir(fetch_location.directory, .{}) catch |err| switch (err) {
654 defer req.deinit();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(.{});923 const is_zig_mod = if (pkg_dir.access(build_zig_basename, .{})) |_| true else |_| false;
657 try req.wait();924 const basename = if (is_zig_mod) build_zig_basename else "";
658925
659 if (req.response.status != .ok) {926 const pkg = try createWithDir(gpa, directory, fetch_location.directory, basename);
660 return report.fail(dep.url_tok, "Expected response status '200 OK' got '{} {s}'", .{927 const module: DependencyModule = if (is_zig_mod)
661 @intFromEnum(req.response.status),928 .{ .zig_pkg = pkg }
662 req.response.status.phrase() orelse "",929 else
663 });930 .{ .non_zig_pkg = pkg };
664 }
665931
666 const content_type = req.response.headers.getFirstValue("Content-Type") orelse932 try all_modules.put(gpa, hex_digest, module);
667 return report.fail(dep.url_tok, "Missing 'Content-Type' header", .{});933 return .{ module, false };
668934}
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 }
710935
711 // Download completed - stop showing downloaded amount as progress936fn fetchAndUnpack(
712 pkg_prog_node.setEstimatedTotalItems(0);937 fetch_location: FetchLocation,
713 pkg_prog_node.setCompletedItems(0);938 thread_pool: *ThreadPool,
714 pkg_prog_node.context.refresh();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.952 const gpa = http_client.allocator;
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.
720953
721 // TODO: raise an error for files that have illegal paths on some operating systems.954 var pkg_prog_node = root_prog_node.start(name_for_prog, 0);
722 // For example, on Linux a path with a backslash should raise an error here.955 defer pkg_prog_node.end();
723 // Of course, if the ignore rules above omit the file from the package, then everything956 pkg_prog_node.activate();
724 // is fine and no error should be raised.957 pkg_prog_node.context.refresh();
725958
726 break :a try computePackageHash(thread_pool, .{ .dir = tmp_directory.handle });959 var readable_resource = try fetch_location.fetch(gpa, directory, http_client, dep, report);
727 };960 defer readable_resource.deinit(gpa);
728961
729 const pkg_dir_sub_path = "p" ++ s ++ Manifest.hexDigest(actual_hash);962 var package_location = try readable_resource.unpack(gpa, thread_pool, global_cache_directory, dep, report, &pkg_prog_node);
730 try renameTmpIntoCache(global_cache_directory.handle, tmp_dir_sub_path, pkg_dir_sub_path);963 defer package_location.deinit(gpa);
731964
732 const actual_hex = Manifest.hexDigest(actual_hash);965 const actual_hex = Manifest.hexDigest(package_location.hash);
733 if (dep.hash) |h| {966 if (dep.hash) |h| {
734 if (!mem.eql(u8, h, &actual_hex)) {967 if (!mem.eql(u8, h, &actual_hex)) {
735 return report.fail(dep.hash_tok, "hash mismatch: expected: {s}, found: {s}", .{968 return report.fail(dep.hash_tok, "hash mismatch: expected: {s}, found: {s}", .{
...@@ -743,9 +976,9 @@ fn fetchAndUnpack(...@@ -743,9 +976,9 @@ fn fetchAndUnpack(
743 const eb = report.error_bundle;976 const eb = report.error_bundle;
744 const notes_len = 1;977 const notes_len = 1;
745 try Report.addErrorMessage(report.ast.*, file_path, eb, notes_len, .{978 try Report.addErrorMessage(report.ast.*, file_path, eb, notes_len, .{
746 .tok = dep.url_tok,979 .tok = dep.location_tok,
747 .off = 0,980 .off = 0,
748 .msg = "url field is missing corresponding hash field",981 .msg = "dependency is missing hash field",
749 });982 });
750 const notes_start = try eb.reserveNotes(notes_len);983 const notes_start = try eb.reserveNotes(notes_len);
751 eb.extra.items[notes_start] = @intFromEnum(try eb.addErrorMessage(.{984 eb.extra.items[notes_start] = @intFromEnum(try eb.addErrorMessage(.{
...@@ -754,35 +987,28 @@ fn fetchAndUnpack(...@@ -754,35 +987,28 @@ fn fetchAndUnpack(
754 return error.PackageFetchFailed;987 return error.PackageFetchFailed;
755 }988 }
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 });
758 defer gpa.free(build_zig_path);991 defer gpa.free(build_zig_path);
759992
760 global_cache_directory.handle.access(build_zig_path, .{}) catch |err| switch (err) {993 const is_zig_mod = if (global_cache_directory.handle.access(build_zig_path, .{})) |_| true else |_| false;
761 error.FileNotFound => {994 const basename = if (is_zig_mod) build_zig_basename else "";
762 try all_modules.put(gpa, actual_hex, .non_zig_pkg);995 const pkg = try createWithDir(gpa, global_cache_directory, package_location.relative_unpacked_path, basename);
763 return .{996 const module: DependencyModule = if (is_zig_mod)
764 .mod = null,997 .{ .zig_pkg = pkg }
765 .found_existing = false,998 else
766 };999 .{ .non_zig_pkg = pkg };
767 },
768 else => return err,
769 };
7701000
771 const mod = try createWithDir(gpa, global_cache_directory, pkg_dir_sub_path, build_zig_basename);1001 try all_modules.put(gpa, actual_hex, module);
772 try all_modules.put(gpa, actual_hex, .{ .zig_pkg = mod });1002 return module;
773 return .{
774 .mod = mod,
775 .found_existing = false,
776 };
777}1003}
7781004
779fn unpackTarball(1005fn unpackTarball(
780 gpa: Allocator,1006 gpa: Allocator,
781 req_reader: anytype,1007 reader: anytype,
782 out_dir: fs.Dir,1008 out_dir: fs.Dir,
783 comptime compression: type,1009 comptime compression: type,
784) !void {1010) !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
787 var decompress = try compression.decompress(gpa, br.reader());1013 var decompress = try compression.decompress(gpa, br.reader());
788 defer decompress.deinit();1014 defer decompress.deinit();
...@@ -873,6 +1099,24 @@ fn computePackageHash(...@@ -873,6 +1099,24 @@ fn computePackageHash(
873 return hasher.finalResult();1099 return hasher.finalResult();
874}1100}
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
876/// Make a file system path identical independently of operating system path inconsistencies.1120/// Make a file system path identical independently of operating system path inconsistencies.
877/// This converts backslashes into forward slashes.1121/// This converts backslashes into forward slashes.
878fn normalizePath(arena: Allocator, fs_path: []const u8) ![]const u8 {1122fn normalizePath(arena: Allocator, fs_path: []const u8) ![]const u8 {
...@@ -953,36 +1197,18 @@ fn renameTmpIntoCache(...@@ -953,36 +1197,18 @@ fn renameTmpIntoCache(
953 }1197 }
954}1198}
9551199
956fn isTarAttachment(content_disposition: []const u8) bool {1200test "getAttachmentType" {
957 const disposition_type_end = ascii.indexOfIgnoreCase(content_disposition, "attachment;") orelse return false;1201 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.gz"), ReadableResource.getAttachmentType("attaChment; FILENAME=\"stuff.tar.gz\"; size=42"));
9581202 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.gz"), ReadableResource.getAttachmentType("attachment; filename*=\"stuff.tar.gz\""));
959 var value_start = ascii.indexOfIgnoreCasePos(content_disposition, disposition_type_end + 1, "filename") orelse return false;1203 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.xz"), ReadableResource.getAttachmentType("ATTACHMENT; filename=\"stuff.tar.xz\""));
960 value_start += "filename".len;1204 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.xz"), ReadableResource.getAttachmentType("attachment; FileName=\"stuff.tar.xz\""));
961 if (content_disposition[value_start] == '*') {1205 try std.testing.expectEqual(@as(?ReadableResource.FileType, .@"tar.gz"), ReadableResource.getAttachmentType("attachment; FileName*=UTF-8\'\'xyz%2Fstuff.tar.gz"));
962 value_start += 1;1206
963 }1207 try std.testing.expect(ReadableResource.getAttachmentType("attachment FileName=\"stuff.tar.gz\"") == null);
964 if (content_disposition[value_start] != '=') return false;1208 try std.testing.expect(ReadableResource.getAttachmentType("attachment; FileName=\"stuff.tar\"") == null);
965 value_start += 1;1209 try std.testing.expect(ReadableResource.getAttachmentType("attachment; FileName\"stuff.gz\"") == null);
9661210 try std.testing.expect(ReadableResource.getAttachmentType("attachment; size=42") == null);
967 var value_end = mem.indexOfPos(u8, content_disposition, value_start, ";") orelse content_disposition.len;1211 try std.testing.expect(ReadableResource.getAttachmentType("inline; size=42") == null);
968 if (content_disposition[value_end - 1] == '\"') {1212 try std.testing.expect(ReadableResource.getAttachmentType("FileName=\"stuff.tar.gz\"; attachment;") == null);
969 value_end -= 1;1213 try std.testing.expect(ReadableResource.getAttachmentType("FileName=\"stuff.tar.gz\";") == null);
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\";"));
988}1214}