| 1 | const std = @import("std"); |
| 2 | const tar = @import("../tar.zig"); |
| 3 | const testing = std.testing; |
| 4 | |
| 5 | const Case = struct { |
| 6 | const File = struct { |
| 7 | name: []const u8, |
| 8 | size: u64 = 0, |
| 9 | mode: u32 = 0, |
| 10 | link_name: []const u8 = &[0]u8{}, |
| 11 | kind: tar.FileKind = .file, |
| 12 | truncated: bool = false, // when there is no file body, just header, useful for huge files |
| 13 | }; |
| 14 | |
| 15 | data: []const u8, // testdata file content |
| 16 | files: []const File = &[_]@This().File{}, // expected files to found in archive |
| 17 | chksums: []const []const u8 = &[_][]const u8{}, // chksums of each file content |
| 18 | err: ?anyerror = null, // parsing should fail with this error |
| 19 | }; |
| 20 | |
| 21 | const gnu_case: Case = .{ |
| 22 | .data = @embedFile("testdata/gnu.tar"), |
| 23 | .files = &[_]Case.File{ |
| 24 | .{ |
| 25 | .name = "small.txt", |
| 26 | .size = 5, |
| 27 | .mode = 0o640, |
| 28 | }, |
| 29 | .{ |
| 30 | .name = "small2.txt", |
| 31 | .size = 11, |
| 32 | .mode = 0o640, |
| 33 | }, |
| 34 | }, |
| 35 | .chksums = &[_][]const u8{ |
| 36 | "e38b27eaccb4391bdec553a7f3ae6b2f", |
| 37 | "c65bd2e50a56a2138bf1716f2fd56fe9", |
| 38 | }, |
| 39 | }; |
| 40 | |
| 41 | const gnu_multi_headers_case: Case = .{ |
| 42 | .data = @embedFile("testdata/gnu-multi-hdrs.tar"), |
| 43 | .files = &[_]Case.File{ |
| 44 | .{ |
| 45 | .name = "GNU2/GNU2/long-path-name", |
| 46 | .link_name = "GNU4/GNU4/long-linkpath-name", |
| 47 | .kind = .sym_link, |
| 48 | }, |
| 49 | }, |
| 50 | }; |
| 51 | |
| 52 | const trailing_slash_case: Case = .{ |
| 53 | .data = @embedFile("testdata/trailing-slash.tar"), |
| 54 | .files = &[_]Case.File{ |
| 55 | .{ |
| 56 | .name = @ptrCast(&@as([30][10]u8, @splat("123456789/".*))), |
| 57 | .kind = .directory, |
| 58 | }, |
| 59 | }, |
| 60 | }; |
| 61 | |
| 62 | const writer_big_long_case: Case = .{ |
| 63 | // Size in gnu extended format, and name in pax attribute. |
| 64 | .data = @embedFile("testdata/writer-big-long.tar"), |
| 65 | .files = &[_]Case.File{ |
| 66 | .{ |
| 67 | .name = name: { |
| 68 | const buf: [15][9]u8 = @splat("longname/".*); |
| 69 | const dir: []const u8 = @ptrCast(&buf); |
| 70 | break :name dir ++ "16gig.txt"; |
| 71 | }, |
| 72 | .size = 16 * 1024 * 1024 * 1024, |
| 73 | .mode = 0o644, |
| 74 | .truncated = true, |
| 75 | }, |
| 76 | }, |
| 77 | }; |
| 78 | |
| 79 | const fuzz1_case: Case = .{ |
| 80 | .data = @embedFile("testdata/fuzz1.tar"), |
| 81 | .err = error.TarInsufficientBuffer, |
| 82 | }; |
| 83 | |
| 84 | test "run test cases" { |
| 85 | try testCase(gnu_case); |
| 86 | try testCase(.{ |
| 87 | .data = @embedFile("testdata/sparse-formats.tar"), |
| 88 | .err = error.TarUnsupportedHeader, |
| 89 | }); |
| 90 | try testCase(.{ |
| 91 | .data = @embedFile("testdata/star.tar"), |
| 92 | .files = &[_]Case.File{ |
| 93 | .{ |
| 94 | .name = "small.txt", |
| 95 | .size = 5, |
| 96 | .mode = 0o640, |
| 97 | }, |
| 98 | .{ |
| 99 | .name = "small2.txt", |
| 100 | .size = 11, |
| 101 | .mode = 0o640, |
| 102 | }, |
| 103 | }, |
| 104 | .chksums = &[_][]const u8{ |
| 105 | "e38b27eaccb4391bdec553a7f3ae6b2f", |
| 106 | "c65bd2e50a56a2138bf1716f2fd56fe9", |
| 107 | }, |
| 108 | }); |
| 109 | try testCase(.{ |
| 110 | .data = @embedFile("testdata/v7.tar"), |
| 111 | .files = &[_]Case.File{ |
| 112 | .{ |
| 113 | .name = "small.txt", |
| 114 | .size = 5, |
| 115 | .mode = 0o444, |
| 116 | }, |
| 117 | .{ |
| 118 | .name = "small2.txt", |
| 119 | .size = 11, |
| 120 | .mode = 0o444, |
| 121 | }, |
| 122 | }, |
| 123 | .chksums = &[_][]const u8{ |
| 124 | "e38b27eaccb4391bdec553a7f3ae6b2f", |
| 125 | "c65bd2e50a56a2138bf1716f2fd56fe9", |
| 126 | }, |
| 127 | }); |
| 128 | try testCase(.{ |
| 129 | .data = @embedFile("testdata/pax.tar"), |
| 130 | .files = &[_]Case.File{ |
| 131 | .{ |
| 132 | .name = "a/123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100", |
| 133 | .size = 7, |
| 134 | .mode = 0o664, |
| 135 | }, |
| 136 | .{ |
| 137 | .name = "a/b", |
| 138 | .size = 0, |
| 139 | .kind = .sym_link, |
| 140 | .mode = 0o777, |
| 141 | .link_name = "123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100", |
| 142 | }, |
| 143 | }, |
| 144 | .chksums = &[_][]const u8{ |
| 145 | "3c382e8f5b6631aa2db52643912ffd4a", |
| 146 | }, |
| 147 | }); |
| 148 | try testCase(.{ |
| 149 | // pax attribute don't end with \n |
| 150 | .data = @embedFile("testdata/pax-bad-hdr-file.tar"), |
| 151 | .err = error.PaxInvalidAttributeEnd, |
| 152 | }); |
| 153 | try testCase(.{ |
| 154 | // size is in pax attribute |
| 155 | .data = @embedFile("testdata/pax-pos-size-file.tar"), |
| 156 | .files = &[_]Case.File{ |
| 157 | .{ |
| 158 | .name = "foo", |
| 159 | .size = 999, |
| 160 | .kind = .file, |
| 161 | .mode = 0o640, |
| 162 | }, |
| 163 | }, |
| 164 | .chksums = &[_][]const u8{ |
| 165 | "0afb597b283fe61b5d4879669a350556", |
| 166 | }, |
| 167 | }); |
| 168 | try testCase(.{ |
| 169 | // has pax records which we are not interested in |
| 170 | .data = @embedFile("testdata/pax-records.tar"), |
| 171 | .files = &[_]Case.File{ |
| 172 | .{ |
| 173 | .name = "file", |
| 174 | }, |
| 175 | }, |
| 176 | }); |
| 177 | try testCase(.{ |
| 178 | // has global records which we are ignoring |
| 179 | .data = @embedFile("testdata/pax-global-records.tar"), |
| 180 | .files = &[_]Case.File{ |
| 181 | .{ |
| 182 | .name = "file1", |
| 183 | }, |
| 184 | .{ |
| 185 | .name = "file2", |
| 186 | }, |
| 187 | .{ |
| 188 | .name = "file3", |
| 189 | }, |
| 190 | .{ |
| 191 | .name = "file4", |
| 192 | }, |
| 193 | }, |
| 194 | }); |
| 195 | try testCase(.{ |
| 196 | .data = @embedFile("testdata/nil-uid.tar"), |
| 197 | .files = &[_]Case.File{ |
| 198 | .{ |
| 199 | .name = "P1050238.JPG.log", |
| 200 | .size = 14, |
| 201 | .kind = .file, |
| 202 | .mode = 0o664, |
| 203 | }, |
| 204 | }, |
| 205 | .chksums = &[_][]const u8{ |
| 206 | "08d504674115e77a67244beac19668f5", |
| 207 | }, |
| 208 | }); |
| 209 | try testCase(.{ |
| 210 | // has xattrs and pax records which we are ignoring |
| 211 | .data = @embedFile("testdata/xattrs.tar"), |
| 212 | .files = &[_]Case.File{ |
| 213 | .{ |
| 214 | .name = "small.txt", |
| 215 | .size = 5, |
| 216 | .kind = .file, |
| 217 | .mode = 0o644, |
| 218 | }, |
| 219 | .{ |
| 220 | .name = "small2.txt", |
| 221 | .size = 11, |
| 222 | .kind = .file, |
| 223 | .mode = 0o644, |
| 224 | }, |
| 225 | }, |
| 226 | .chksums = &[_][]const u8{ |
| 227 | "e38b27eaccb4391bdec553a7f3ae6b2f", |
| 228 | "c65bd2e50a56a2138bf1716f2fd56fe9", |
| 229 | }, |
| 230 | }); |
| 231 | try testCase(gnu_multi_headers_case); |
| 232 | try testCase(.{ |
| 233 | // has gnu type D (directory) and S (sparse) blocks |
| 234 | .data = @embedFile("testdata/gnu-incremental.tar"), |
| 235 | .err = error.TarUnsupportedHeader, |
| 236 | }); |
| 237 | try testCase(.{ |
| 238 | // should use values only from last pax header |
| 239 | .data = @embedFile("testdata/pax-multi-hdrs.tar"), |
| 240 | .files = &[_]Case.File{ |
| 241 | .{ |
| 242 | .name = "bar", |
| 243 | .link_name = "PAX4/PAX4/long-linkpath-name", |
| 244 | .kind = .sym_link, |
| 245 | }, |
| 246 | }, |
| 247 | }); |
| 248 | try testCase(.{ |
| 249 | .data = @embedFile("testdata/gnu-long-nul.tar"), |
| 250 | .files = &[_]Case.File{ |
| 251 | .{ |
| 252 | .name = "0123456789", |
| 253 | .mode = 0o644, |
| 254 | }, |
| 255 | }, |
| 256 | }); |
| 257 | try testCase(.{ |
| 258 | .data = @embedFile("testdata/gnu-utf8.tar"), |
| 259 | .files = &[_]Case.File{ |
| 260 | .{ |
| 261 | .name = "☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹☺☻☹", |
| 262 | .mode = 0o644, |
| 263 | }, |
| 264 | }, |
| 265 | }); |
| 266 | try testCase(.{ |
| 267 | .data = @embedFile("testdata/gnu-not-utf8.tar"), |
| 268 | .files = &[_]Case.File{ |
| 269 | .{ |
| 270 | .name = "hi\x80\x81\x82\x83bye", |
| 271 | .mode = 0o644, |
| 272 | }, |
| 273 | }, |
| 274 | }); |
| 275 | try testCase(.{ |
| 276 | // null in pax key |
| 277 | .data = @embedFile("testdata/pax-nul-xattrs.tar"), |
| 278 | .err = error.PaxNullInKeyword, |
| 279 | }); |
| 280 | try testCase(.{ |
| 281 | .data = @embedFile("testdata/pax-nul-path.tar"), |
| 282 | .err = error.PaxNullInValue, |
| 283 | }); |
| 284 | try testCase(.{ |
| 285 | .data = @embedFile("testdata/neg-size.tar"), |
| 286 | .err = error.TarHeader, |
| 287 | }); |
| 288 | try testCase(.{ |
| 289 | .data = @embedFile("testdata/issue10968.tar"), |
| 290 | .err = error.TarHeader, |
| 291 | }); |
| 292 | try testCase(.{ |
| 293 | .data = @embedFile("testdata/issue11169.tar"), |
| 294 | .err = error.TarHeader, |
| 295 | }); |
| 296 | try testCase(.{ |
| 297 | .data = @embedFile("testdata/issue12435.tar"), |
| 298 | .err = error.TarHeaderChksum, |
| 299 | }); |
| 300 | try testCase(.{ |
| 301 | // has magic with space at end instead of null |
| 302 | .data = @embedFile("testdata/invalid-go17.tar"), |
| 303 | .files = &[_]Case.File{ |
| 304 | .{ |
| 305 | .name = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/foo", |
| 306 | }, |
| 307 | }, |
| 308 | }); |
| 309 | try testCase(.{ |
| 310 | .data = @embedFile("testdata/ustar-file-devs.tar"), |
| 311 | .files = &[_]Case.File{ |
| 312 | .{ |
| 313 | .name = "file", |
| 314 | .mode = 0o644, |
| 315 | }, |
| 316 | }, |
| 317 | }); |
| 318 | try testCase(trailing_slash_case); |
| 319 | try testCase(.{ |
| 320 | // Has size in gnu extended format. To represent size bigger than 8 GB. |
| 321 | .data = @embedFile("testdata/writer-big.tar"), |
| 322 | .files = &[_]Case.File{ |
| 323 | .{ |
| 324 | .name = "tmp/16gig.txt", |
| 325 | .size = 16 * 1024 * 1024 * 1024, |
| 326 | .truncated = true, |
| 327 | .mode = 0o640, |
| 328 | }, |
| 329 | }, |
| 330 | }); |
| 331 | try testCase(writer_big_long_case); |
| 332 | try testCase(fuzz1_case); |
| 333 | try testCase(.{ |
| 334 | .data = @embedFile("testdata/fuzz2.tar"), |
| 335 | .err = error.PaxSizeAttrOverflow, |
| 336 | }); |
| 337 | } |
| 338 | |
| 339 | fn testCase(case: Case) !void { |
| 340 | var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; |
| 341 | var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; |
| 342 | |
| 343 | var br: std.Io.Reader = .fixed(case.data); |
| 344 | var it: tar.Iterator = .init(&br, .{ |
| 345 | .file_name_buffer = &file_name_buffer, |
| 346 | .link_name_buffer = &link_name_buffer, |
| 347 | }); |
| 348 | var i: usize = 0; |
| 349 | while (it.next() catch |err| { |
| 350 | if (case.err) |e| { |
| 351 | try testing.expectEqual(e, err); |
| 352 | return; |
| 353 | } else { |
| 354 | return err; |
| 355 | } |
| 356 | }) |actual| : (i += 1) { |
| 357 | const expected = case.files[i]; |
| 358 | try testing.expectEqualStrings(expected.name, actual.name); |
| 359 | try testing.expectEqual(expected.size, actual.size); |
| 360 | try testing.expectEqual(expected.kind, actual.kind); |
| 361 | try testing.expectEqual(expected.mode, actual.mode); |
| 362 | try testing.expectEqualStrings(expected.link_name, actual.link_name); |
| 363 | |
| 364 | if (case.chksums.len > i) { |
| 365 | var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); |
| 366 | defer aw.deinit(); |
| 367 | try it.streamRemaining(actual, &aw.writer); |
| 368 | const chksum = std.fmt.bytesToHex(std.crypto.hash.Md5.hashResult(aw.written()), .lower); |
| 369 | try testing.expectEqualStrings(case.chksums[i], &chksum); |
| 370 | } else { |
| 371 | if (expected.truncated) { |
| 372 | it.unread_file_bytes = 0; |
| 373 | } |
| 374 | } |
| 375 | } |
| 376 | try testing.expectEqual(case.files.len, i); |
| 377 | } |
| 378 | |
| 379 | test "pax/gnu long names with small buffer" { |
| 380 | try testLongNameCase(gnu_multi_headers_case); |
| 381 | try testLongNameCase(trailing_slash_case); |
| 382 | try testLongNameCase(.{ |
| 383 | .data = @embedFile("testdata/fuzz1.tar"), |
| 384 | .err = error.TarInsufficientBuffer, |
| 385 | }); |
| 386 | } |
| 387 | |
| 388 | fn testLongNameCase(case: Case) !void { |
| 389 | // should fail with insufficient buffer error |
| 390 | |
| 391 | var min_file_name_buffer: [256]u8 = undefined; |
| 392 | var min_link_name_buffer: [100]u8 = undefined; |
| 393 | |
| 394 | var br: std.Io.Reader = .fixed(case.data); |
| 395 | var iter: tar.Iterator = .init(&br, .{ |
| 396 | .file_name_buffer = &min_file_name_buffer, |
| 397 | .link_name_buffer = &min_link_name_buffer, |
| 398 | }); |
| 399 | |
| 400 | var iter_err: ?anyerror = null; |
| 401 | while (iter.next() catch |err| brk: { |
| 402 | iter_err = err; |
| 403 | break :brk null; |
| 404 | }) |_| {} |
| 405 | |
| 406 | try testing.expect(iter_err != null); |
| 407 | try testing.expectEqual(error.TarInsufficientBuffer, iter_err.?); |
| 408 | } |
| 409 | |
| 410 | test "insufficient buffer in Header name filed" { |
| 411 | var min_file_name_buffer: [9]u8 = undefined; |
| 412 | var min_link_name_buffer: [100]u8 = undefined; |
| 413 | |
| 414 | var br: std.Io.Reader = .fixed(gnu_case.data); |
| 415 | var iter: tar.Iterator = .init(&br, .{ |
| 416 | .file_name_buffer = &min_file_name_buffer, |
| 417 | .link_name_buffer = &min_link_name_buffer, |
| 418 | }); |
| 419 | |
| 420 | var iter_err: ?anyerror = null; |
| 421 | while (iter.next() catch |err| brk: { |
| 422 | iter_err = err; |
| 423 | break :brk null; |
| 424 | }) |_| {} |
| 425 | |
| 426 | try testing.expect(iter_err != null); |
| 427 | try testing.expectEqual(error.TarInsufficientBuffer, iter_err.?); |
| 428 | } |
| 429 | |
| 430 | test "should not overwrite existing file" { |
| 431 | const io = testing.io; |
| 432 | // Starting from this folder structure: |
| 433 | // $ tree root |
| 434 | // root |
| 435 | // ├── a |
| 436 | // │   └── b |
| 437 | // │   └── c |
| 438 | // │   └── file.txt |
| 439 | // └── d |
| 440 | // └── b |
| 441 | // └── c |
| 442 | // └── file.txt |
| 443 | // |
| 444 | // Packed with command: |
| 445 | // $ cd root; tar cf overwrite_file.tar * |
| 446 | // Resulting tar has following structure: |
| 447 | // $ tar tvf overwrite_file.tar |
| 448 | // size path |
| 449 | // 0 a/ |
| 450 | // 0 a/b/ |
| 451 | // 0 a/b/c/ |
| 452 | // 2 a/b/c/file.txt |
| 453 | // 0 d/ |
| 454 | // 0 d/b/ |
| 455 | // 0 d/b/c/ |
| 456 | // 2 d/b/c/file.txt |
| 457 | // |
| 458 | // Note that there is no root folder in archive. |
| 459 | // |
| 460 | // With strip_components = 1 resulting unpacked folder was: |
| 461 | // root |
| 462 | // └── b |
| 463 | // └── c |
| 464 | // └── file.txt |
| 465 | // |
| 466 | // a/b/c/file.txt is overwritten with d/b/c/file.txt !!! |
| 467 | // This ensures that file is not overwritten. |
| 468 | // |
| 469 | const data = @embedFile("testdata/overwrite_file.tar"); |
| 470 | var r: std.Io.Reader = .fixed(data); |
| 471 | |
| 472 | // Unpack with strip_components = 1 should fail |
| 473 | var root = std.testing.tmpDir(.{}); |
| 474 | defer root.cleanup(); |
| 475 | try testing.expectError( |
| 476 | error.PathAlreadyExists, |
| 477 | tar.extract(io, root.dir, &r, .{ .mode_mode = .ignore, .strip_components = 1 }), |
| 478 | ); |
| 479 | |
| 480 | // Unpack with strip_components = 0 should pass |
| 481 | r = .fixed(data); |
| 482 | var root2 = std.testing.tmpDir(.{}); |
| 483 | defer root2.cleanup(); |
| 484 | try tar.extract(io, root2.dir, &r, .{ .mode_mode = .ignore, .strip_components = 0 }); |
| 485 | } |
| 486 | |
| 487 | test "case sensitivity" { |
| 488 | const io = testing.io; |
| 489 | // Mimicking issue #18089, this tar contains, same file name in two case |
| 490 | // sensitive name version. Should fail on case insensitive file systems. |
| 491 | // |
| 492 | // $ tar tvf 18089.tar |
| 493 | // 18089/ |
| 494 | // 18089/alacritty/ |
| 495 | // 18089/alacritty/darkermatrix.yml |
| 496 | // 18089/alacritty/Darkermatrix.yml |
| 497 | // |
| 498 | const data = @embedFile("testdata/18089.tar"); |
| 499 | var r: std.Io.Reader = .fixed(data); |
| 500 | |
| 501 | var root = std.testing.tmpDir(.{}); |
| 502 | defer root.cleanup(); |
| 503 | |
| 504 | tar.extract(io, root.dir, &r, .{ .mode_mode = .ignore, .strip_components = 1 }) catch |err| { |
| 505 | // on case insensitive fs we fail on overwrite existing file |
| 506 | try testing.expectEqual(error.PathAlreadyExists, err); |
| 507 | return; |
| 508 | }; |
| 509 | |
| 510 | // on case sensitive os both files are created |
| 511 | try testing.expect((try root.dir.statFile(io, "alacritty/darkermatrix.yml", .{})).kind == .file); |
| 512 | try testing.expect((try root.dir.statFile(io, "alacritty/Darkermatrix.yml", .{})).kind == .file); |
| 513 | } |