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 {...@@ -120,8 +120,10 @@ pub const Diagnostics = struct {
120 }120 }
121};121};
122122
123/// pipeToFileSystem options123/// Deprecated, renamed to `ExtractOptions`.
124pub const PipeOptions = struct {124pub const PipeOptions = ExtractOptions;
125
126pub const ExtractOptions = struct {
125 /// Number of directory levels to skip when extracting files.127 /// Number of directory levels to skip when extracting files.
126 strip_components: u32 = 0,128 strip_components: u32 = 0,
127 /// How to handle the "mode" property of files from within the tar file.129 /// How to handle the "mode" property of files from within the tar file.
...@@ -580,10 +582,15 @@ pub const PaxIterator = struct {...@@ -580,10 +582,15 @@ pub const PaxIterator = struct {
580 }582 }
581};583};
582584
583/// Saves tar file content to the file systems.585/// Deprecated, renamed to `extract`.
584pub fn pipeToFileSystem(io: Io, dir: Io.Dir, reader: *Io.Reader, options: PipeOptions) !void {586pub const pipeToFileSystem = extract;
585 var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined;587
586 var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined;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;
587 var file_contents_buffer: [1024]u8 = undefined;594 var file_contents_buffer: [1024]u8 = undefined;
588 var it: Iterator = .init(reader, .{595 var it: Iterator = .init(reader, .{
589 .file_name_buffer = &file_name_buffer,596 .file_name_buffer = &file_name_buffer,
...@@ -592,14 +599,15 @@ pub fn pipeToFileSystem(io: Io, dir: Io.Dir, reader: *Io.Reader, options: PipeOp...@@ -592,14 +599,15 @@ pub fn pipeToFileSystem(io: Io, dir: Io.Dir, reader: *Io.Reader, options: PipeOp
592 });599 });
593600
594 while (try it.next()) |file| {601 while (try it.next()) |file| {
595 const file_name = stripComponents(file.name, options.strip_components);602 const n = sanitizePath(&sanitize_buffer, file.name, options.strip_components) catch 0;
596 if (file_name.len == 0 and file.kind != .directory) {603 if (n == 0 and file.kind != .directory) {
597 const d = options.diagnostics orelse return error.TarComponentsOutsideStrippedPrefix;604 const d = options.diagnostics orelse return error.TarComponentsOutsideStrippedPrefix;
598 try d.errors.append(d.allocator, .{ .components_outside_stripped_prefix = .{605 try d.errors.append(d.allocator, .{ .components_outside_stripped_prefix = .{
599 .file_name = try d.allocator.dupe(u8, file.name),606 .file_name = try d.allocator.dupe(u8, file.name),
600 } });607 } });
601 continue;608 continue;
602 }609 }
610 const file_name = sanitize_buffer[0..n];
603 if (options.diagnostics) |d| {611 if (options.diagnostics) |d| {
604 try d.findRoot(file.kind, file_name);612 try d.findRoot(file.kind, file_name);
605 }613 }
...@@ -665,27 +673,59 @@ fn createDirAndSymlink(io: Io, dir: Io.Dir, link_name: []const u8, file_name: []...@@ -665,27 +673,59 @@ fn createDirAndSymlink(io: Io, dir: Io.Dir, link_name: []const u8, file_name: []
665 };673 };
666}674}
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;
669 var i: usize = 0;678 var i: usize = 0;
670 var c = count;679 var c = strip_components;
671 while (c > 0) : (c -= 1) {680 var it = std.mem.tokenizeScalar(u8, path, '/');
672 if (std.mem.findScalarPos(u8, path, i, '/')) |pos| {681 while (it.next()) |component| {
673 i = pos + 1;682 if (std.mem.eql(u8, component, ".")) continue;
674 } else {683 if (std.mem.eql(u8, component, "..")) {
675 i = path.len;684 if (i == 0) return error.Invalid;
676 break;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;
677 }691 }
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;
678 }702 }
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));
680}716}
681717
682test stripComponents {718test sanitizePath {
683 const expectEqualStrings = testing.expectEqualStrings;719 try testSanitizePath("a/b/c", "a/b/c", 0);
684 try expectEqualStrings("a/b/c", stripComponents("a/b/c", 0));720 try testSanitizePath("a/b/c", "a/x/y/../../b/c", 0);
685 try expectEqualStrings("b/c", stripComponents("a/b/c", 1));721 try testSanitizePath("b/c", "a/b/c", 1);
686 try expectEqualStrings("c", stripComponents("a/b/c", 2));722 try testSanitizePath("c", "a/b/c", 2);
687 try expectEqualStrings("", stripComponents("a/b/c", 3));723 try testSanitizePath("", "a/b/c", 3);
688 try expectEqualStrings("", stripComponents("a/b/c", 4));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);
689}729}
690730
691test PaxIterator {731test PaxIterator {
...@@ -958,7 +998,7 @@ test Iterator {...@@ -958,7 +998,7 @@ test Iterator {
958 }998 }
959}999}
9601000
961test pipeToFileSystem {1001test extract {
962 const io = testing.io;1002 const io = testing.io;
963 // Example tar file is created from this tree structure:1003 // Example tar file is created from this tree structure:
964 // $ tree example1004 // $ tree example
...@@ -987,7 +1027,7 @@ test pipeToFileSystem {...@@ -987,7 +1027,7 @@ test pipeToFileSystem {
987 const dir = tmp.dir;1027 const dir = tmp.dir;
9881028
989 // Save tar from reader to the file system `dir`1029 // Save tar from reader to the file system `dir`
990 pipeToFileSystem(io, dir, &reader, .{1030 extract(io, dir, &reader, .{
991 .mode_mode = .ignore,1031 .mode_mode = .ignore,
992 .strip_components = 1,1032 .strip_components = 1,
993 .exclude_empty_directories = true,1033 .exclude_empty_directories = true,
...@@ -1009,7 +1049,7 @@ test pipeToFileSystem {...@@ -1009,7 +1049,7 @@ test pipeToFileSystem {
1009 );1049 );
1010}1050}
10111051
1012test "pipeToFileSystem root_dir" {1052test "extract root_dir" {
1013 const io = testing.io;1053 const io = testing.io;
1014 const data = @embedFile("tar/testdata/example.tar");1054 const data = @embedFile("tar/testdata/example.tar");
1015 var reader: Io.Reader = .fixed(data);1055 var reader: Io.Reader = .fixed(data);
...@@ -1021,7 +1061,7 @@ test "pipeToFileSystem root_dir" {...@@ -1021,7 +1061,7 @@ test "pipeToFileSystem root_dir" {
1021 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };1061 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
1022 defer diagnostics.deinit();1062 defer diagnostics.deinit();
10231063
1024 pipeToFileSystem(io, tmp.dir, &reader, .{1064 extract(io, tmp.dir, &reader, .{
1025 .strip_components = 1,1065 .strip_components = 1,
1026 .diagnostics = &diagnostics,1066 .diagnostics = &diagnostics,
1027 }) catch |err| {1067 }) catch |err| {
...@@ -1043,7 +1083,7 @@ test "pipeToFileSystem root_dir" {...@@ -1043,7 +1083,7 @@ test "pipeToFileSystem root_dir" {
1043 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };1083 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
1044 defer diagnostics.deinit();1084 defer diagnostics.deinit();
10451085
1046 pipeToFileSystem(io, tmp.dir, &reader, .{1086 extract(io, tmp.dir, &reader, .{
1047 .strip_components = 0,1087 .strip_components = 0,
1048 .diagnostics = &diagnostics,1088 .diagnostics = &diagnostics,
1049 }) catch |err| {1089 }) catch |err| {
...@@ -1068,7 +1108,7 @@ test "findRoot with single file archive" {...@@ -1068,7 +1108,7 @@ test "findRoot with single file archive" {
10681108
1069 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };1109 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
1070 defer diagnostics.deinit();1110 defer diagnostics.deinit();
1071 try pipeToFileSystem(io, tmp.dir, &reader, .{ .diagnostics = &diagnostics });1111 try extract(io, tmp.dir, &reader, .{ .diagnostics = &diagnostics });
10721112
1073 try testing.expectEqualStrings("", diagnostics.root_dir);1113 try testing.expectEqualStrings("", diagnostics.root_dir);
1074}1114}
...@@ -1083,12 +1123,12 @@ test "findRoot without explicit root dir" {...@@ -1083,12 +1123,12 @@ test "findRoot without explicit root dir" {
10831123
1084 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };1124 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
1085 defer diagnostics.deinit();1125 defer diagnostics.deinit();
1086 try pipeToFileSystem(io, tmp.dir, &reader, .{ .diagnostics = &diagnostics });1126 try extract(io, tmp.dir, &reader, .{ .diagnostics = &diagnostics });
10871127
1088 try testing.expectEqualStrings("root", diagnostics.root_dir);1128 try testing.expectEqualStrings("root", diagnostics.root_dir);
1089}1129}
10901130
1091test "pipeToFileSystem strip_components" {1131test "extract strip_components" {
1092 const io = testing.io;1132 const io = testing.io;
1093 const data = @embedFile("tar/testdata/example.tar");1133 const data = @embedFile("tar/testdata/example.tar");
1094 var reader: Io.Reader = .fixed(data);1134 var reader: Io.Reader = .fixed(data);
...@@ -1098,7 +1138,7 @@ test "pipeToFileSystem strip_components" {...@@ -1098,7 +1138,7 @@ test "pipeToFileSystem strip_components" {
1098 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };1138 var diagnostics: Diagnostics = .{ .allocator = testing.allocator };
1099 defer diagnostics.deinit();1139 defer diagnostics.deinit();
11001140
1101 pipeToFileSystem(io, tmp.dir, &reader, .{1141 extract(io, tmp.dir, &reader, .{
1102 .strip_components = 3,1142 .strip_components = 3,
1103 .diagnostics = &diagnostics,1143 .diagnostics = &diagnostics,
1104 }) catch |err| {1144 }) catch |err| {
...@@ -1120,7 +1160,7 @@ fn normalizePath(bytes: []u8) []u8 {...@@ -1120,7 +1160,7 @@ fn normalizePath(bytes: []u8) []u8 {
1120}1160}
11211161
1122// File system mode based on tar header mode and mode_mode options.1162// 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 {
1124 return if (!Io.File.Permissions.has_executable_bit or options.mode_mode == .ignore or (mode & 0o100) == 0)1164 return if (!Io.File.Permissions.has_executable_bit or options.mode_mode == .ignore or (mode & 0o100) == 0)
1125 .default_file1165 .default_file
1126 else1166 else
...@@ -1142,13 +1182,13 @@ test "executable bit" {...@@ -1142,13 +1182,13 @@ test "executable bit" {
1142 const S = std.posix.S;1182 const S = std.posix.S;
1143 const data = @embedFile("tar/testdata/example.tar");1183 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| {
1146 var reader: Io.Reader = .fixed(data);1186 var reader: Io.Reader = .fixed(data);
11471187
1148 var tmp = testing.tmpDir(.{ .follow_symlinks = false });1188 var tmp = testing.tmpDir(.{ .follow_symlinks = false });
1149 //defer tmp.cleanup();1189 //defer tmp.cleanup();
11501190
1151 pipeToFileSystem(io, tmp.dir, &reader, .{1191 extract(io, tmp.dir, &reader, .{
1152 .strip_components = 1,1192 .strip_components = 1,
1153 .exclude_empty_directories = true,1193 .exclude_empty_directories = true,
1154 .mode_mode = opt,1194 .mode_mode = opt,