authorgravatar for andrew@ziglang.orgAndrew Kelley <andrew@ziglang.org> 2026-03-27 15:00:29+01:00
committergravatar for andrew@ziglang.orgAndrew Kelley <andrew@ziglang.org> 2026-03-27 15:00:29+01:00
logb4e21ccb4994d051ecf086491e42f57ce183d9b9
treebabd2eb26d19897ddfce46b6e9e597764276793c
parent053b5e3bddc086c43bc44e11a0106a2f15a0f3af
parent9ad6843b20edd864047ff7d8a9af935dd9dc3d48

Merge pull request 'std.tar.extract: sanitize path traversal' (#31685) from tar into master

Reviewed-on: https://codeberg.org/ziglang/zig/pulls/31685

1 files changed, 76 insertions(+), 36 deletions(-)

lib/std/tar.zig+76-36
......@@ -120,8 +120,10 @@ pub const Diagnostics = struct {
120120 }
121121};
122122
123/// pipeToFileSystem options
124pub const PipeOptions = struct {
123/// Deprecated, renamed to `ExtractOptions`.
124pub const PipeOptions = ExtractOptions;
125
126pub const ExtractOptions = struct {
125127 /// Number of directory levels to skip when extracting files.
126128 strip_components: u32 = 0,
127129 /// How to handle the "mode" property of files from within the tar file.
......@@ -580,10 +582,15 @@ pub const PaxIterator = struct {
580582 }
581583};
582584
583/// Saves tar file content to the file systems.
584pub fn pipeToFileSystem(io: Io, dir: Io.Dir, reader: *Io.Reader, options: PipeOptions) !void {
585 var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
586 var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined;
585/// Deprecated, renamed to `extract`.
586pub const pipeToFileSystem = extract;
587
588/// Ingests tar file from `reader`, populating file contents within `dir`. If
589/// any file would be extracted outside of `dir`, an error is return instead.
590pub fn extract(io: Io, dir: Io.Dir, reader: *Io.Reader, options: ExtractOptions) !void {
591 var file_name_buffer: [Io.Dir.max_path_bytes]u8 = undefined;
592 var link_name_buffer: [Io.Dir.max_path_bytes]u8 = undefined;
593 var sanitize_buffer: [Io.Dir.max_path_bytes]u8 = undefined;
587594 var file_contents_buffer: [1024]u8 = undefined;
588595 var it: Iterator = .init(reader, .{
589596 .file_name_buffer = &file_name_buffer,
......@@ -592,14 +599,15 @@ pub fn pipeToFileSystem(io: Io, dir: Io.Dir, reader: *Io.Reader, options: PipeOp
592599 });
593600
594601 while (try it.next()) |file| {
595 const file_name = stripComponents(file.name, options.strip_components);
596 if (file_name.len == 0 and file.kind != .directory) {
602 const n = sanitizePath(&sanitize_buffer, file.name, options.strip_components) catch 0;
603 if (n == 0 and file.kind != .directory) {
597604 const d = options.diagnostics orelse return error.TarComponentsOutsideStrippedPrefix;
598605 try d.errors.append(d.allocator, .{ .components_outside_stripped_prefix = .{
599606 .file_name = try d.allocator.dupe(u8, file.name),
600607 } });
601608 continue;
602609 }
610 const file_name = sanitize_buffer[0..n];
603611 if (options.diagnostics) |d| {
604612 try d.findRoot(file.kind, file_name);
605613 }
......@@ -665,27 +673,59 @@ fn createDirAndSymlink(io: Io, dir: Io.Dir, link_name: []const u8, file_name: []
665673 };
666674}
667675
668fn stripComponents(path: []const u8, count: u32) []const u8 {
676fn sanitizePath(buffer: []u8, path: []const u8, strip_components: u32) error{Invalid}!usize {
677 if (path.len == 0 or path[0] == '/') return error.Invalid;
669678 var i: usize = 0;
670 var c = count;
671 while (c > 0) : (c -= 1) {
672 if (std.mem.findScalarPos(u8, path, i, '/')) |pos| {
673 i = pos + 1;
674 } else {
675 i = path.len;
676 break;
679 var c = strip_components;
680 var it = std.mem.tokenizeScalar(u8, path, '/');
681 while (it.next()) |component| {
682 if (std.mem.eql(u8, component, ".")) continue;
683 if (std.mem.eql(u8, component, "..")) {
684 if (i == 0) return error.Invalid;
685 while (true) {
686 const ends_with_slash = buffer[i - 1] == '/';
687 i -= 1;
688 if (ends_with_slash or i == 0) break;
689 }
690 continue;
677691 }
692 if (c > 0) {
693 c -= 1;
694 continue;
695 }
696 if (i > 0) {
697 buffer[i] = '/';
698 i += 1;
699 }
700 @memcpy(buffer[i..][0..component.len], component);
701 i += component.len;
678702 }
679 return path[i..];
703 if (c > 0) return error.Invalid;
704 return i;
705}
706
707fn testSanitizePath(expected: []const u8, input: []const u8, strip: u32) !void {
708 var buffer: [Io.Dir.max_path_bytes]u8 = undefined;
709 const result = buffer[0..try sanitizePath(&buffer, input, strip)];
710 try testing.expectEqualStrings(expected, result);
711}
712
713fn testSanitizePathError(expected: anyerror, input: []const u8, strip: u32) !void {
714 var buffer: [Io.Dir.max_path_bytes]u8 = undefined;
715 try testing.expectError(expected, sanitizePath(&buffer, input, strip));
680716}
681717
682test stripComponents {
683 const expectEqualStrings = testing.expectEqualStrings;
684 try expectEqualStrings("a/b/c", stripComponents("a/b/c", 0));
685 try expectEqualStrings("b/c", stripComponents("a/b/c", 1));
686 try expectEqualStrings("c", stripComponents("a/b/c", 2));
687 try expectEqualStrings("", stripComponents("a/b/c", 3));
688 try expectEqualStrings("", stripComponents("a/b/c", 4));
718test sanitizePath {
719 try testSanitizePath("a/b/c", "a/b/c", 0);
720 try testSanitizePath("a/b/c", "a/x/y/../../b/c", 0);
721 try testSanitizePath("b/c", "a/b/c", 1);
722 try testSanitizePath("c", "a/b/c", 2);
723 try testSanitizePath("", "a/b/c", 3);
724 try testSanitizePath("", "a/b/c/../../..", 0);
725 try testSanitizePathError(error.Invalid, "a/b/c", 4);
726 try testSanitizePathError(error.Invalid, "..", 0);
727 try testSanitizePathError(error.Invalid, "a/b/../../..", 0);
728 try testSanitizePathError(error.Invalid, "a/b/../..", 1);
689729}
690730
691731test PaxIterator {
......@@ -958,7 +998,7 @@ test Iterator {
958998 }
959999}
9601000
961test pipeToFileSystem {
1001test extract {
9621002 const io = testing.io;
9631003 // Example tar file is created from this tree structure:
9641004 // $ tree example
......@@ -987,7 +1027,7 @@ test pipeToFileSystem {
9871027 const dir = tmp.dir;
9881028
9891029 // Save tar from reader to the file system `dir`
990 pipeToFileSystem(io, dir, &reader, .{
1030 extract(io, dir, &reader, .{
9911031 .mode_mode = .ignore,
9921032 .strip_components = 1,
9931033 .exclude_empty_directories = true,
......@@ -1009,7 +1049,7 @@ test pipeToFileSystem {
10091049 );
10101050}
10111051
1012test "pipeToFileSystem root_dir" {
1052test "extract root_dir" {
10131053 const io = testing.io;
10141054 const data = @embedFile("tar/testdata/example.tar");
10151055 var reader: Io.Reader = .fixed(data);
......@@ -1021,7 +1061,7 @@ test "pipeToFileSystem root_dir" {
10211061 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
10221062 defer diagnostics.deinit();
10231063
1024 pipeToFileSystem(io, tmp.dir, &reader, .{
1064 extract(io, tmp.dir, &reader, .{
10251065 .strip_components = 1,
10261066 .diagnostics = &diagnostics,
10271067 }) catch |err| {
......@@ -1043,7 +1083,7 @@ test "pipeToFileSystem root_dir" {
10431083 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
10441084 defer diagnostics.deinit();
10451085
1046 pipeToFileSystem(io, tmp.dir, &reader, .{
1086 extract(io, tmp.dir, &reader, .{
10471087 .strip_components = 0,
10481088 .diagnostics = &diagnostics,
10491089 }) catch |err| {
......@@ -1068,7 +1108,7 @@ test "findRoot with single file archive" {
10681108
10691109 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
10701110 defer diagnostics.deinit();
1071 try pipeToFileSystem(io, tmp.dir, &reader, .{ .diagnostics = &diagnostics });
1111 try extract(io, tmp.dir, &reader, .{ .diagnostics = &diagnostics });
10721112
10731113 try testing.expectEqualStrings("", diagnostics.root_dir);
10741114}
......@@ -1083,12 +1123,12 @@ test "findRoot without explicit root dir" {
10831123
10841124 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
10851125 defer diagnostics.deinit();
1086 try pipeToFileSystem(io, tmp.dir, &reader, .{ .diagnostics = &diagnostics });
1126 try extract(io, tmp.dir, &reader, .{ .diagnostics = &diagnostics });
10871127
10881128 try testing.expectEqualStrings("root", diagnostics.root_dir);
10891129}
10901130
1091test "pipeToFileSystem strip_components" {
1131test "extract strip_components" {
10921132 const io = testing.io;
10931133 const data = @embedFile("tar/testdata/example.tar");
10941134 var reader: Io.Reader = .fixed(data);
......@@ -1098,7 +1138,7 @@ test "pipeToFileSystem strip_components" {
10981138 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
10991139 defer diagnostics.deinit();
11001140
1101 pipeToFileSystem(io, tmp.dir, &reader, .{
1141 extract(io, tmp.dir, &reader, .{
11021142 .strip_components = 3,
11031143 .diagnostics = &diagnostics,
11041144 }) catch |err| {
......@@ -1120,7 +1160,7 @@ fn normalizePath(bytes: []u8) []u8 {
11201160}
11211161
11221162// File system mode based on tar header mode and mode_mode options.
1123fn filePermissions(mode: u32, options: PipeOptions) Io.File.Permissions {
1163fn filePermissions(mode: u32, options: ExtractOptions) Io.File.Permissions {
11241164 return if (!Io.File.Permissions.has_executable_bit or options.mode_mode == .ignore or (mode & 0o100) == 0)
11251165 .default_file
11261166 else
......@@ -1142,13 +1182,13 @@ test "executable bit" {
11421182 const S = std.posix.S;
11431183 const data = @embedFile("tar/testdata/example.tar");
11441184
1145 for ([_]PipeOptions.ModeMode{ .ignore, .executable_bit_only }) |opt| {
1185 for ([_]ExtractOptions.ModeMode{ .ignore, .executable_bit_only }) |opt| {
11461186 var reader: Io.Reader = .fixed(data);
11471187
11481188 var tmp = testing.tmpDir(.{ .follow_symlinks = false });
11491189 //defer tmp.cleanup();
11501190
1151 pipeToFileSystem(io, tmp.dir, &reader, .{
1191 extract(io, tmp.dir, &reader, .{
11521192 .strip_components = 1,
11531193 .exclude_empty_directories = true,
11541194 .mode_mode = opt,