zig/lib/std/tar.zig
Igor Anić e1424b84b8 tar: add parsing size in gnu extended format
Reference:
https://www.gnu.org/software/tar/manual/html_node/Extensions.html#Extensions

If the leading byte is 0x80 (128), the non-leading bytes of the field
are concatenated in big-endian order, with the result being a positive
number expressed in binary form.
2024-01-13 19:37:33 -07:00

872 lines
31 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

pub const Options = struct {
/// Number of directory levels to skip when extracting files.
strip_components: u32 = 0,
/// How to handle the "mode" property of files from within the tar file.
mode_mode: ModeMode = .executable_bit_only,
/// Prevents creation of empty directories.
exclude_empty_directories: bool = false,
/// Provide this to receive detailed error messages.
/// When this is provided, some errors which would otherwise be returned immediately
/// will instead be added to this structure. The API user must check the errors
/// in diagnostics to know whether the operation succeeded or failed.
diagnostics: ?*Diagnostics = null,
pub const ModeMode = enum {
/// The mode from the tar file is completely ignored. Files are created
/// with the default mode when creating files.
ignore,
/// The mode from the tar file is inspected for the owner executable bit
/// only. This bit is copied to the group and other executable bits.
/// Other bits of the mode are left as the default when creating files.
executable_bit_only,
};
pub const Diagnostics = struct {
allocator: std.mem.Allocator,
errors: std.ArrayListUnmanaged(Error) = .{},
pub const Error = union(enum) {
unable_to_create_sym_link: struct {
code: anyerror,
file_name: []const u8,
link_name: []const u8,
},
unable_to_create_file: struct {
code: anyerror,
file_name: []const u8,
},
unsupported_file_type: struct {
file_name: []const u8,
file_type: Header.FileType,
},
};
pub fn deinit(d: *Diagnostics) void {
for (d.errors.items) |item| {
switch (item) {
.unable_to_create_sym_link => |info| {
d.allocator.free(info.file_name);
d.allocator.free(info.link_name);
},
.unable_to_create_file => |info| {
d.allocator.free(info.file_name);
},
.unsupported_file_type => |info| {
d.allocator.free(info.file_name);
},
}
}
d.errors.deinit(d.allocator);
d.* = undefined;
}
};
};
const BLOCK_SIZE = 512;
pub const Header = struct {
bytes: *const [BLOCK_SIZE]u8,
pub const FileType = enum(u8) {
normal_alias = 0,
normal = '0',
hard_link = '1',
symbolic_link = '2',
character_special = '3',
block_special = '4',
directory = '5',
fifo = '6',
contiguous = '7',
global_extended_header = 'g',
extended_header = 'x',
_,
};
pub fn fileSize(header: Header) !u64 {
const raw = header.bytes[124..][0..12];
// If the leading byte is 0xff (255), all the bytes of the field
// (including the leading byte) are concatenated in big-endian order,
// with the result being a negative number expressed in twos
// complement form.
if (raw[0] == 0xff) return error.SizeNegative;
// If the leading byte is 0x80 (128), the non-leading bytes of the
// field are concatenated in big-endian order.
if (raw[0] == 0x80) {
if (raw[1] + raw[2] + raw[3] != 0) return error.SizeTooBig;
return std.mem.readInt(u64, raw[4..12], .big);
}
// Zero-filled octal number in ASCII. Each numeric field of width w
// contains w minus 1 digits, and a null
const ltrimmed = std.mem.trimLeft(u8, raw, "0 ");
const rtrimmed = std.mem.trimRight(u8, ltrimmed, " \x00");
if (rtrimmed.len == 0) return 0;
return std.fmt.parseInt(u64, rtrimmed, 8);
}
pub fn is_ustar(header: Header) bool {
return std.mem.eql(u8, header.bytes[257..][0..6], "ustar\x00");
}
/// Includes prefix concatenated, if any.
/// Return value may point into Header buffer, or might point into the
/// argument buffer.
/// TODO: check against "../" and other nefarious things
pub fn fullFileName(header: Header, buffer: *[std.fs.MAX_PATH_BYTES]u8) ![]const u8 {
const n = name(header);
if (!is_ustar(header))
return n;
const p = prefix(header);
if (p.len == 0)
return n;
@memcpy(buffer[0..p.len], p);
buffer[p.len] = '/';
@memcpy(buffer[p.len + 1 ..][0..n.len], n);
return buffer[0 .. p.len + 1 + n.len];
}
pub fn name(header: Header) []const u8 {
return str(header, 0, 0 + 100);
}
pub fn linkName(header: Header) []const u8 {
return str(header, 157, 157 + 100);
}
pub fn prefix(header: Header) []const u8 {
return str(header, 345, 345 + 155);
}
pub fn fileType(header: Header) FileType {
const result: FileType = @enumFromInt(header.bytes[156]);
if (result == .normal_alias) return .normal;
return result;
}
fn str(header: Header, start: usize, end: usize) []const u8 {
var i: usize = start;
while (i < end) : (i += 1) {
if (header.bytes[i] == 0) break;
}
return header.bytes[start..i];
}
pub fn isZero(header: Header) bool {
for (header.bytes) |b| {
if (b != 0) return false;
}
return true;
}
};
fn BufferedReader(comptime ReaderType: type) type {
return struct {
unbuffered_reader: ReaderType,
buffer: [BLOCK_SIZE * 8]u8 = undefined,
start: usize = 0,
end: usize = 0,
const Self = @This();
pub fn readChunk(self: *Self, count: usize) ![]const u8 {
self.ensureCapacity(1024);
const ask = @min(self.buffer.len - self.end, count -| (self.end - self.start));
self.end += try self.unbuffered_reader.readAtLeast(self.buffer[self.end..], ask);
return self.buffer[self.start..self.end];
}
pub fn readBlock(self: *Self) !?[]const u8 {
const block_bytes = try self.readChunk(BLOCK_SIZE * 2);
switch (block_bytes.len) {
0 => return null,
1...(BLOCK_SIZE - 1) => return error.UnexpectedEndOfStream,
else => {},
}
self.advance(BLOCK_SIZE);
return block_bytes[0..BLOCK_SIZE];
}
pub fn advance(self: *Self, count: usize) void {
self.start += count;
assert(self.start <= self.end);
}
pub fn skip(self: *Self, count: usize) !void {
if (self.start + count > self.end) {
try self.unbuffered_reader.skipBytes(self.start + count - self.end, .{});
self.start = self.end;
} else {
self.advance(count);
}
}
pub fn skipPadding(self: *Self, file_size: usize) !void {
return self.skip(filePadding(file_size));
}
pub fn skipFile(self: *Self, file_size: usize) !void {
return self.skip(roundedFileSize(file_size));
}
inline fn ensureCapacity(self: *Self, count: usize) void {
if (self.buffer.len - self.start < count) {
const dest_end = self.end - self.start;
@memcpy(self.buffer[0..dest_end], self.buffer[self.start..self.end]);
self.end = dest_end;
self.start = 0;
}
}
pub fn write(self: *Self, writer: anytype, size: usize) !void {
var rdr = self.sliceReader(size, true);
while (try rdr.next()) |slice| {
try writer.writeAll(slice);
}
}
// copy dst.len bytes into dst
pub fn copy(self: *Self, dst: []u8) ![]const u8 {
var rdr = self.sliceReader(dst.len, true);
var pos: usize = 0;
while (try rdr.next()) |slice| : (pos += slice.len) {
@memcpy(dst[pos .. pos + slice.len], slice);
}
return dst;
}
const SliceReader = struct {
size: usize,
chunk_size: usize,
offset: usize,
reader: *Self,
auto_advance: bool,
fn next(self: *@This()) !?[]const u8 {
if (self.offset >= self.size) return null;
const temp = try self.reader.readChunk(self.chunk_size - self.offset);
if (temp.len == 0) return error.UnexpectedEndOfStream;
const slice = temp[0..@min(self.remainingSize(), temp.len)];
if (self.auto_advance) try self.advance(slice.len);
return slice;
}
fn advance(self: *@This(), len: usize) !void {
self.offset += len;
try self.reader.skip(len);
}
fn copy(self: *@This(), dst: []u8) ![]const u8 {
_ = try self.reader.copy(dst);
self.offset += dst.len;
return dst;
}
fn remainingSize(self: *@This()) usize {
return self.size - self.offset;
}
};
pub fn sliceReader(self: *Self, size: usize, auto_advance: bool) Self.SliceReader {
return .{
.size = size,
.chunk_size = roundedFileSize(size) + BLOCK_SIZE,
.offset = 0,
.reader = self,
.auto_advance = auto_advance,
};
}
};
}
// File size rounded to te block boundary.
inline fn roundedFileSize(file_size: usize) usize {
return std.mem.alignForward(usize, file_size, BLOCK_SIZE);
}
// Number of padding bytes in the last file block.
inline fn filePadding(file_size: usize) usize {
return roundedFileSize(file_size) - file_size;
}
fn Iterator(comptime ReaderType: type) type {
const BufferedReaderType = BufferedReader(ReaderType);
return struct {
attrs: struct {
buffer: [std.fs.MAX_PATH_BYTES * 2]u8 = undefined,
tail: usize = 0,
fn alloc(self: *@This(), size: usize) ![]u8 {
if (size > self.len()) return error.NameTooLong;
const head = self.tail;
self.tail += size;
assert(self.tail <= self.buffer.len);
return self.buffer[head..self.tail];
}
fn free(self: *@This()) void {
self.tail = 0;
}
fn len(self: *@This()) usize {
return self.buffer.len - self.tail;
}
} = .{},
reader: BufferedReaderType,
diagnostics: ?*Options.Diagnostics,
const Self = @This();
const File = struct {
name: []const u8 = &[_]u8{},
link_name: []const u8 = &[_]u8{},
size: usize = 0,
file_type: Header.FileType = .normal,
reader: *BufferedReaderType,
pub fn write(self: File, writer: anytype) !void {
try self.reader.write(writer, self.size);
try self.skipPadding();
}
pub fn skip(self: File) !void {
try self.reader.skip(roundedFileSize(self.size));
}
fn skipPadding(self: File) !void {
try self.reader.skip(filePadding(self.size));
}
fn chksum(self: File) ![16]u8 {
var sum = [_]u8{0} ** 16;
if (self.size == 0) return sum;
var rdr = self.reader.sliceReader(self.size, true);
var h = std.crypto.hash.Md5.init(.{});
while (try rdr.next()) |slice| {
h.update(slice);
}
h.final(&sum);
try self.skipPadding();
return sum;
}
};
// Externally, `next` iterates through the tar archive as if it is a
// series of files. Internally, the tar format often uses fake "files"
// to add meta data that describes the next file. These meta data
// "files" should not normally be visible to the outside. As such, this
// loop iterates through one or more "header files" until it finds a
// "normal file".
pub fn next(self: *Self) !?File {
var file: File = .{ .reader = &self.reader };
self.attrs.free();
while (try self.reader.readBlock()) |block_bytes| {
const block: Header = .{ .bytes = block_bytes[0..BLOCK_SIZE] };
if (block.isZero()) return null;
const file_type = block.fileType();
const file_size = try block.fileSize();
switch (file_type) {
.directory, .normal, .symbolic_link => {
if (file.size == 0) file.size = file_size;
if (file.name.len == 0)
file.name = try block.fullFileName((try self.attrs.alloc(std.fs.MAX_PATH_BYTES))[0..std.fs.MAX_PATH_BYTES]);
if (file.link_name.len == 0) file.link_name = block.linkName();
file.file_type = file_type;
return file;
},
.global_extended_header => {
self.reader.skipFile(file_size) catch return error.TarHeadersTooBig;
},
.extended_header => {
if (file_size == 0) continue;
var rdr = self.reader.sliceReader(file_size, false);
while (try rdr.next()) |slice| {
const attr = try parsePaxAttribute(slice, rdr.remainingSize());
try rdr.advance(attr.value_off);
if (attr.is("path")) {
file.name = try rdr.copy(try self.attrs.alloc(attr.value_len));
} else if (attr.is("linkpath")) {
file.link_name = try rdr.copy(try self.attrs.alloc(attr.value_len));
} else if (attr.is("size")) {
var buf = [_]u8{'0'} ** 32;
file.size = try std.fmt.parseInt(usize, try rdr.copy(buf[0..attr.value_len]), 10);
} else {
try rdr.advance(attr.value_len);
}
try rdr.advance(1);
}
try self.reader.skipPadding(file_size);
},
.hard_link => return error.TarUnsupportedFileType,
else => {
const d = self.diagnostics orelse return error.TarUnsupportedFileType;
try d.errors.append(d.allocator, .{ .unsupported_file_type = .{
.file_name = try d.allocator.dupe(u8, block.name()),
.file_type = file_type,
} });
},
}
}
return null;
}
};
}
pub fn iterator(reader: anytype, diagnostics: ?*Options.Diagnostics) Iterator(@TypeOf(reader)) {
const ReaderType = @TypeOf(reader);
return .{
.reader = BufferedReader(ReaderType){ .unbuffered_reader = reader },
.diagnostics = diagnostics,
};
}
pub fn pipeToFileSystem(dir: std.fs.Dir, reader: anytype, options: Options) !void {
switch (options.mode_mode) {
.ignore => {},
.executable_bit_only => {
// This code does not look at the mode bits yet. To implement this feature,
// the implementation must be adjusted to look at the mode, and check the
// user executable bit, then call fchmod on newly created files when
// the executable bit is supposed to be set.
// It also needs to properly deal with ACLs on Windows.
@panic("TODO: unimplemented: tar ModeMode.executable_bit_only");
},
}
var iter = iterator(reader, options.diagnostics);
while (try iter.next()) |file| {
switch (file.file_type) {
.directory => {
const file_name = try stripComponents(file.name, options.strip_components);
if (file_name.len != 0 and !options.exclude_empty_directories) {
try dir.makePath(file_name);
}
},
.normal => {
if (file.size == 0 and file.name.len == 0) return;
const file_name = try stripComponents(file.name, options.strip_components);
const fs_file = dir.createFile(file_name, .{}) catch |err| switch (err) {
error.FileNotFound => again: {
const code = code: {
if (std.fs.path.dirname(file_name)) |dir_name| {
dir.makePath(dir_name) catch |code| break :code code;
break :again dir.createFile(file_name, .{}) catch |code| {
break :code code;
};
}
break :code err;
};
const d = options.diagnostics orelse return error.UnableToCreateFile;
try d.errors.append(d.allocator, .{ .unable_to_create_file = .{
.code = code,
.file_name = try d.allocator.dupe(u8, file_name),
} });
break :again null;
},
else => |e| return e,
};
defer if (fs_file) |f| f.close();
if (fs_file) |f| {
try file.write(f);
} else {
try file.skip();
}
},
.symbolic_link => {
// The file system path of the symbolic link.
const file_name = try stripComponents(file.name, options.strip_components);
// The data inside the symbolic link.
const link_name = file.link_name;
dir.symLink(link_name, file_name, .{}) catch |err| again: {
const code = code: {
if (err == error.FileNotFound) {
if (std.fs.path.dirname(file_name)) |dir_name| {
dir.makePath(dir_name) catch |code| break :code code;
break :again dir.symLink(link_name, file_name, .{}) catch |code| {
break :code code;
};
}
}
break :code err;
};
const d = options.diagnostics orelse return error.UnableToCreateSymLink;
try d.errors.append(d.allocator, .{ .unable_to_create_sym_link = .{
.code = code,
.file_name = try d.allocator.dupe(u8, file_name),
.link_name = try d.allocator.dupe(u8, link_name),
} });
};
},
else => unreachable,
}
}
}
fn stripComponents(path: []const u8, count: u32) ![]const u8 {
var i: usize = 0;
var c = count;
while (c > 0) : (c -= 1) {
if (std.mem.indexOfScalarPos(u8, path, i, '/')) |pos| {
i = pos + 1;
} else {
return error.TarComponentsOutsideStrippedPrefix;
}
}
return path[i..];
}
test stripComponents {
const expectEqualStrings = std.testing.expectEqualStrings;
try expectEqualStrings("a/b/c", try stripComponents("a/b/c", 0));
try expectEqualStrings("b/c", try stripComponents("a/b/c", 1));
try expectEqualStrings("c", try stripComponents("a/b/c", 2));
}
const PaxAttributeInfo = struct {
size: usize,
key: []const u8,
value_off: usize,
value_len: usize,
inline fn is(self: @This(), key: []const u8) bool {
return (std.mem.eql(u8, self.key, key));
}
};
fn parsePaxAttribute(data: []const u8, max_size: usize) !PaxAttributeInfo {
const pos_space = std.mem.indexOfScalar(u8, data, ' ') orelse return error.InvalidPaxAttribute;
const pos_equals = std.mem.indexOfScalarPos(u8, data, pos_space, '=') orelse return error.InvalidPaxAttribute;
const kv_size = try std.fmt.parseInt(usize, data[0..pos_space], 10);
if (kv_size > max_size) {
return error.InvalidPaxAttribute;
}
return .{
.size = kv_size,
.key = data[pos_space + 1 .. pos_equals],
.value_off = pos_equals + 1,
.value_len = kv_size - pos_equals - 2,
};
}
test parsePaxAttribute {
const expectEqual = std.testing.expectEqual;
const expectEqualStrings = std.testing.expectEqualStrings;
const expectError = std.testing.expectError;
const prefix = "1011 path=";
const file_name = "0123456789" ** 100;
const header = prefix ++ file_name ++ "\n";
const attr_info = try parsePaxAttribute(header, 1011);
try expectEqual(@as(usize, 1011), attr_info.size);
try expectEqualStrings("path", attr_info.key);
try expectEqual(prefix.len, attr_info.value_off);
try expectEqual(file_name.len, attr_info.value_len);
try expectEqual(attr_info, try parsePaxAttribute(header, 1012));
try expectError(error.InvalidPaxAttribute, parsePaxAttribute(header, 1010));
try expectError(error.InvalidPaxAttribute, parsePaxAttribute("", 0));
}
const std = @import("std");
const assert = std.debug.assert;
const TestCase = struct {
const File = struct {
const empty_string = &[0]u8{};
name: []const u8,
size: usize = 0,
link_name: []const u8 = empty_string,
file_type: Header.FileType = .normal,
truncated: bool = false, // when there is no file body, just header, usefull for huge files
};
path: []const u8,
files: []const File = &[_]TestCase.File{},
chksums: []const []const u8 = &[_][]const u8{},
err: ?anyerror = null,
};
test "Go test cases" {
const test_dir = try std.fs.openDirAbsolute("/usr/local/go/src/archive/tar/testdata", .{});
const cases = [_]TestCase{
.{
.path = "gnu.tar",
.files = &[_]TestCase.File{
.{
.name = "small.txt",
.size = 5,
.file_type = .normal,
},
.{
.name = "small2.txt",
.size = 11,
.file_type = .normal,
},
},
.chksums = &[_][]const u8{
"e38b27eaccb4391bdec553a7f3ae6b2f",
"c65bd2e50a56a2138bf1716f2fd56fe9",
},
},
.{
.path = "sparse-formats.tar",
.err = error.TarUnsupportedFileType,
},
.{
.path = "star.tar",
.files = &[_]TestCase.File{
.{
.name = "small.txt",
.size = 5,
.file_type = .normal,
},
.{
.name = "small2.txt",
.size = 11,
.file_type = .normal,
},
},
.chksums = &[_][]const u8{
"e38b27eaccb4391bdec553a7f3ae6b2f",
"c65bd2e50a56a2138bf1716f2fd56fe9",
},
},
.{
.path = "v7.tar",
.files = &[_]TestCase.File{
.{
.name = "small.txt",
.size = 5,
.file_type = .normal,
},
.{
.name = "small2.txt",
.size = 11,
.file_type = .normal,
},
},
.chksums = &[_][]const u8{
"e38b27eaccb4391bdec553a7f3ae6b2f",
"c65bd2e50a56a2138bf1716f2fd56fe9",
},
},
.{
.path = "pax.tar",
.files = &[_]TestCase.File{
.{
.name = "a/123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100",
.size = 7,
.file_type = .normal,
},
.{
.name = "a/b",
.size = 0,
.file_type = .symbolic_link,
.link_name = "123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100",
},
},
.chksums = &[_][]const u8{
"3c382e8f5b6631aa2db52643912ffd4a",
},
},
// TODO: this should fail
// .{
// .path = "pax-bad-hdr-file.tar",
// .err = error.TarBadHeader,
// },
// .{
// .path = "pax-bad-mtime-file.tar",
// .err = error.TarBadHeader,
// },
//
// TODO: giving wrong result because we are not reading pax size header
.{
.path = "pax-pos-size-file.tar",
.files = &[_]TestCase.File{
.{
.name = "foo",
.size = 999,
.file_type = .normal,
},
},
.chksums = &[_][]const u8{
"0afb597b283fe61b5d4879669a350556",
},
},
.{
// has pax records which we are not interested in
.path = "pax-records.tar",
.files = &[_]TestCase.File{
.{
.name = "file",
},
},
},
.{
// has global records which we are ignoring
.path = "pax-global-records.tar",
.files = &[_]TestCase.File{
.{
.name = "file1",
},
.{
.name = "file2",
},
.{
.name = "file3",
},
.{
.name = "file4",
},
},
},
.{
.path = "nil-uid.tar",
.files = &[_]TestCase.File{
.{
.name = "P1050238.JPG.log",
.size = 14,
.file_type = .normal,
},
},
.chksums = &[_][]const u8{
"08d504674115e77a67244beac19668f5",
},
},
.{
// has xattrs and pax records which we are ignoring
.path = "xattrs.tar",
.files = &[_]TestCase.File{
.{
.name = "small.txt",
.size = 5,
.file_type = .normal,
},
.{
.name = "small2.txt",
.size = 11,
.file_type = .normal,
},
},
.chksums = &[_][]const u8{
"e38b27eaccb4391bdec553a7f3ae6b2f",
"c65bd2e50a56a2138bf1716f2fd56fe9",
},
},
.{
.path = "gnu-multi-hdrs.tar",
.err = error.TarUnsupportedFileType,
},
.{
.path = "gnu-incremental.tar",
.err = error.TarUnsupportedFileType,
},
// .{
// .path = "pax-multi-hdrs.tar",
// },
// .{
// .path = "gnu-long-nul.tar",
// .files = &[_]TestCase.File{
// .{
// .name = "012233456789",
// },
// },
// },
// .{
// .path = "gnu-utf8.tar",
// .files = &[_]TestCase.File{
// .{
// .name = "012233456789",
// },
// },
// },
//
.{
.path = "gnu-not-utf8.tar",
.files = &[_]TestCase.File{
.{
.name = "hi\x80\x81\x82\x83bye",
},
},
},
// TODO some files with errors:
// pax-nul-xattrs.tar, pax-nul-path.tar, neg-size.tar, issue10968.tar, issue11169.tar, issue12435.tar
.{
.path = "trailing-slash.tar",
.files = &[_]TestCase.File{
.{
.name = "123456789/" ** 30,
.file_type = .directory,
},
},
},
.{
// Has size in gnu extended format. To represent size bigger than 8 GB.
.path = "writer-big.tar",
.files = &[_]TestCase.File{
.{
.name = "tmp/16gig.txt",
.size = 16 * 1024 * 1024 * 1024,
.truncated = true,
},
},
},
.{
// Size in gnu extended format, and name in pax attribute.
.path = "writer-big-long.tar",
.files = &[_]TestCase.File{
.{
.name = "longname/" ** 15 ++ "16gig.txt",
.size = 16 * 1024 * 1024 * 1024,
.truncated = true,
},
},
},
};
for (cases) |case| {
//if (!std.mem.eql(u8, case.path, "pax-pos-size-file.tar")) continue;
var fs_file = try test_dir.openFile(case.path, .{});
defer fs_file.close();
var iter = iterator(fs_file.reader(), null);
var i: usize = 0;
while (iter.next() catch |err| {
if (case.err) |e| {
try std.testing.expectEqual(e, err);
continue;
} else {
return err;
}
}) |actual| {
const expected = case.files[i];
try std.testing.expectEqualStrings(expected.name, actual.name);
try std.testing.expectEqual(expected.size, actual.size);
try std.testing.expectEqual(expected.file_type, actual.file_type);
try std.testing.expectEqualStrings(expected.link_name, actual.link_name);
if (case.chksums.len > i) {
var actual_chksum = try actual.chksum();
var hex_to_bytes_buffer: [16]u8 = undefined;
const expected_chksum = try std.fmt.hexToBytes(&hex_to_bytes_buffer, case.chksums[i]);
// std.debug.print("actual chksum: {s}\n", .{std.fmt.fmtSliceHexLower(&actual_chksum)});
try std.testing.expectEqualStrings(expected_chksum, &actual_chksum);
} else {
if (!expected.truncated) try actual.skip(); // skip file content
}
i += 1;
}
try std.testing.expectEqual(case.files.len, i);
}
}