//! Read-only reader for the Compound File Binary format (MS-CFB), also //! known as "OLE2 structured storage". It is a FAT-style filesystem //! inside one file, and it is the container a legacy `.xls` workbook //! lives in: the spreadsheet itself is the `Workbook` stream inside it. //! //! Only what reading a stream needs is implemented: //! //! - header validation (signature, byte order, sector sizes for //! major versions 3 and 4) //! - the DIFAT -> FAT sector-allocation table, including DIFAT //! sectors beyond the 109 entries the header holds //! - the directory, scanned linearly (the red-black tree is ignored; //! a name lookup over a few dozen entries does not need it) //! - regular streams (FAT chains) and small streams (mini FAT chains //! inside the root entry's mini stream) //! //! Every chain walk is bounded by the size of the table it walks, so a //! cyclic chain is reported as `CorruptFile` instead of looping, and a //! declared size larger than the file is rejected before allocating. //! //! Spec: [MS-CFB] Compound File Binary File Format. const std = @import("std"); pub const Error = error{ /// The bytes do not start with the compound-file signature. NotCompoundFile, /// The structure is internally inconsistent: bad header fields, /// a chain that loops or points outside its table, an entry larger /// than the file can hold. CorruptFile, /// The file ends before a sector it references. Usually an /// interrupted download. Truncated, OutOfMemory, }; /// First eight bytes of every compound file. pub const signature = [8]u8{ 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 }; /// Special sector numbers (MS-CFB 2.1). Any value above `max_reg_sect` /// is not a real sector. const max_reg_sect: u32 = 0xFFFFFFFA; const end_of_chain: u32 = 0xFFFFFFFE; const free_sect: u32 = 0xFFFFFFFF; const header_size = 512; const dir_entry_size = 128; const mini_sector_size = 64; /// DIFAT entries stored in the header itself. const header_difat_count = 109; /// True when `bytes` starts with the compound-file signature. Cheap /// enough for content sniffing; does not validate anything else. pub fn isCompoundFile(bytes: []const u8) bool { return bytes.len >= signature.len and std.mem.eql(u8, bytes[0..signature.len], &signature); } pub const EntryType = enum(u8) { unknown = 0, storage = 1, stream = 2, root = 5, _, }; /// One directory entry. Only the fields a reader needs. pub const Entry = struct { /// UTF-16 code units of the name, without the terminating NUL. name: [31]u16, name_len: u8, kind: EntryType, start_sector: u32, size: u64, /// Case-insensitive comparison against an ASCII name. CFB names /// compare case-insensitively (MS-CFB 2.6.4), and every stream name /// a reader looks up by literal is ASCII. pub fn nameEql(self: Entry, ascii: []const u8) bool { if (ascii.len != self.name_len) return false; for (self.name[0..self.name_len], ascii) |unit, c| { if (unit > 0x7F) return false; if (std.ascii.toLower(@intCast(unit)) != std.ascii.toLower(c)) return false; } return true; } }; pub const File = struct { allocator: std.mem.Allocator, bytes: []const u8, sector_shift: u4, mini_cutoff: u32, fat: []u32, mini_fat: []u32, entries: []Entry, /// Contents of the root entry's stream, which holds every small /// stream. Empty when the file has no small streams. mini_stream: []u8, /// Parse the header, allocation tables and directory. `bytes` is /// borrowed and must outlive the `File`. pub fn init(allocator: std.mem.Allocator, bytes: []const u8) Error!File { if (!isCompoundFile(bytes)) return error.NotCompoundFile; if (bytes.len < header_size) return error.Truncated; if (readU16(bytes, 28) != 0xFFFE) return error.CorruptFile; // byte order mark const major = readU16(bytes, 26); const sector_shift: u4 = switch (major) { 3 => 9, 4 => 12, else => return error.CorruptFile, }; if (readU16(bytes, 30) != sector_shift) return error.CorruptFile; if (readU16(bytes, 32) != 6) return error.CorruptFile; // 64-byte mini sectors const sector_size = @as(usize, 1) << sector_shift; // Version 4 pads the header out to a full 4096-byte sector. if (bytes.len < sector_size) return error.Truncated; const num_fat = readU32(bytes, 44); const first_dir = readU32(bytes, 48); const mini_cutoff = readU32(bytes, 56); const first_mini_fat = readU32(bytes, 60); const first_difat = readU32(bytes, 68); // Upper bound on sectors this file can address. Every table // size below is checked against it before allocating, so a // hostile header cannot request a huge allocation. const total_sectors = (bytes.len - sector_size + sector_size - 1) >> sector_shift; if (num_fat > total_sectors) return error.CorruptFile; var self: File = .{ .allocator = allocator, .bytes = bytes, .sector_shift = sector_shift, .mini_cutoff = mini_cutoff, .fat = &.{}, .mini_fat = &.{}, .entries = &.{}, .mini_stream = &.{}, }; errdefer self.deinit(); self.fat = try self.readFat(num_fat, first_difat, total_sectors); self.entries = try self.readDirectory(first_dir); if (self.entries.len == 0 or self.entries[0].kind != .root) return error.CorruptFile; self.mini_fat = try self.readMiniFat(first_mini_fat); const root = self.entries[0]; if (root.size > 0) { self.mini_stream = try self.readRegular(allocator, root.start_sector, root.size); } return self; } pub fn deinit(self: *File) void { self.allocator.free(self.fat); self.allocator.free(self.mini_fat); self.allocator.free(self.entries); self.allocator.free(self.mini_stream); } /// First stream entry whose name matches `ascii` case-insensitively. pub fn find(self: File, ascii: []const u8) ?Entry { for (self.entries) |e| { if (e.kind == .stream and e.nameEql(ascii)) return e; } return null; } /// Read a stream's full contents. Caller owns the returned bytes. pub fn readStream(self: File, allocator: std.mem.Allocator, entry: Entry) Error![]u8 { if (entry.size < self.mini_cutoff) return self.readMini(allocator, entry.start_sector, entry.size); return self.readRegular(allocator, entry.start_sector, entry.size); } fn sectorSize(self: File) usize { return @as(usize, 1) << self.sector_shift; } /// Bytes of sector `index`. The final sector of a file may be short /// (some writers do not pad it), so the slice can be shorter than a /// sector; callers that need a whole sector use `fullSector`. fn sector(self: File, index: u32) Error![]const u8 { if (index > max_reg_sect) return error.CorruptFile; const start = (@as(usize, index) + 1) << self.sector_shift; if (start >= self.bytes.len) return error.Truncated; const end = @min(start + self.sectorSize(), self.bytes.len); return self.bytes[start..end]; } fn fullSector(self: File, index: u32) Error![]const u8 { const s = try self.sector(index); if (s.len != self.sectorSize()) return error.Truncated; return s; } /// Collect the FAT sector numbers (header DIFAT, then the DIFAT /// sector chain) and concatenate those sectors into one table. fn readFat(self: File, num_fat: u32, first_difat: u32, total_sectors: usize) Error![]u32 { const sector_size = self.sectorSize(); const per_sector = sector_size / 4; const fat_sectors = try self.allocator.alloc(u32, num_fat); defer self.allocator.free(fat_sectors); const in_header = @min(num_fat, header_difat_count); for (0..in_header) |i| fat_sectors[i] = readU32(self.bytes, 76 + 4 * i); var filled: usize = in_header; var difat = first_difat; var steps: usize = 0; while (filled < num_fat) { steps += 1; if (difat > max_reg_sect or steps > total_sectors) return error.CorruptFile; const s = try self.fullSector(difat); // The last entry of a DIFAT sector links to the next one. const take = @min(num_fat - filled, per_sector - 1); for (0..take) |i| fat_sectors[filled + i] = readU32(s, 4 * i); filled += take; difat = readU32(s, sector_size - 4); } const fat = try self.allocator.alloc(u32, @as(usize, num_fat) * per_sector); errdefer self.allocator.free(fat); for (fat_sectors, 0..) |fs, n| { const s = try self.fullSector(fs); for (0..per_sector) |i| fat[n * per_sector + i] = readU32(s, 4 * i); } return fat; } fn readDirectory(self: File, first_dir: u32) Error![]Entry { var entries: std.ArrayList(Entry) = .empty; errdefer entries.deinit(self.allocator); var it: ChainIterator = .{ .table = self.fat, .next_index = first_dir }; while (try it.next()) |index| { const s = try self.fullSector(index); var off: usize = 0; while (off + dir_entry_size <= s.len) : (off += dir_entry_size) { try entries.append(self.allocator, try parseEntry(s[off..][0..dir_entry_size], self.sector_shift == 9)); } } return entries.toOwnedSlice(self.allocator); } fn readMiniFat(self: File, first_mini_fat: u32) Error![]u32 { var table: std.ArrayList(u32) = .empty; errdefer table.deinit(self.allocator); if (first_mini_fat == end_of_chain or first_mini_fat == free_sect) return table.toOwnedSlice(self.allocator); var it: ChainIterator = .{ .table = self.fat, .next_index = first_mini_fat }; while (try it.next()) |index| { const s = try self.fullSector(index); var off: usize = 0; while (off + 4 <= s.len) : (off += 4) try table.append(self.allocator, readU32(s, off)); } return table.toOwnedSlice(self.allocator); } /// Read `size` bytes following the FAT chain from `start`. fn readRegular(self: File, allocator: std.mem.Allocator, start: u32, size: u64) Error![]u8 { // Beyond what the FAT can address is impossible; beyond the end // of the bytes we have means the file was cut short. if (size > @as(u64, self.fat.len) << self.sector_shift) return error.CorruptFile; if (size > self.bytes.len) return error.Truncated; const out = try allocator.alloc(u8, @intCast(size)); errdefer allocator.free(out); var filled: usize = 0; var it: ChainIterator = .{ .table = self.fat, .next_index = start }; while (filled < out.len) { const index = (try it.next()) orelse return error.CorruptFile; // chain shorter than size const s = try self.sector(index); const n = @min(s.len, out.len - filled); // A short final sector is only acceptable when it holds the // rest of the stream. if (n < self.sectorSize() and filled + n < out.len) return error.Truncated; @memcpy(out[filled..][0..n], s[0..n]); filled += n; } try it.finish(); return out; } /// Read `size` bytes following the mini FAT chain from `start` /// inside the mini stream. fn readMini(self: File, allocator: std.mem.Allocator, start: u32, size: u64) Error![]u8 { if (size > self.mini_stream.len) return error.CorruptFile; const out = try allocator.alloc(u8, @intCast(size)); errdefer allocator.free(out); var filled: usize = 0; var it: ChainIterator = .{ .table = self.mini_fat, .next_index = start }; while (filled < out.len) { const index = (try it.next()) orelse return error.CorruptFile; const off = @as(usize, index) * mini_sector_size; if (off >= self.mini_stream.len) return error.CorruptFile; const n = @min(mini_sector_size, out.len - filled, self.mini_stream.len - off); // The mini stream ran out before the stream did. if (n < mini_sector_size and filled + n < out.len) return error.CorruptFile; @memcpy(out[filled..][0..n], self.mini_stream[off..][0..n]); filled += n; } try it.finish(); return out; } }; /// Walks a sector chain through a FAT or mini FAT. Each step must land /// inside the table, and a chain can visit at most `table.len` sectors, /// so loops and dangling links are reported instead of followed. const ChainIterator = struct { table: []const u32, next_index: u32, steps: usize = 0, fn next(it: *ChainIterator) Error!?u32 { if (it.next_index == end_of_chain) return null; if (it.next_index >= it.table.len) return error.CorruptFile; it.steps += 1; if (it.steps > it.table.len) return error.CorruptFile; const current = it.next_index; it.next_index = it.table[current]; return current; } /// Walk whatever is left of the chain. A reader stops once it has /// a stream's declared size, so a loop late in the chain would /// otherwise go unnoticed and its repeated sectors would be /// returned as data. Extra sectors that do end are tolerated. fn finish(it: *ChainIterator) Error!void { while (try it.next()) |_| {} } }; fn parseEntry(raw: *const [dir_entry_size]u8, is_v3: bool) Error!Entry { // Name length is in bytes and includes the UTF-16 NUL terminator. const name_bytes = readU16(raw, 64); if (name_bytes > 64 or name_bytes % 2 != 0) return error.CorruptFile; const units: u8 = if (name_bytes == 0) 0 else @intCast(name_bytes / 2 - 1); var e: Entry = .{ // SAFETY: the first `units` code units are written by the loop // below, and nothing reads past `name_len`. .name = undefined, .name_len = units, .kind = @enumFromInt(raw[66]), .start_sector = readU32(raw, 116), .size = std.mem.readInt(u64, raw[120..128], .little), }; // Version 3 files only define the low 32 bits of the size; writers // are allowed to leave junk in the high half (MS-CFB 2.6.3). if (is_v3) e.size &= 0xFFFFFFFF; for (0..units) |i| e.name[i] = readU16(raw, 2 * i); return e; } fn readU16(bytes: []const u8, off: usize) u16 { return std.mem.readInt(u16, bytes[off..][0..2], .little); } fn readU32(bytes: []const u8, off: usize) u32 { return std.mem.readInt(u32, bytes[off..][0..4], .little); } // ---- Tests ---- const testing = std.testing; const test_writer = @import("test_writer.zig"); test "isCompoundFile" { try testing.expect(isCompoundFile(&signature)); try testing.expect(!isCompoundFile(signature[0..7])); try testing.expect(!isCompoundFile("PK\x03\x04 a zip file, i.e. xlsx")); } test "reads a regular stream through the FAT" { const allocator = testing.allocator; const big = try allocator.alloc(u8, 10_000); defer allocator.free(big); for (big, 0..) |*b, i| b.* = @truncate(i *% 7); const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "Workbook", .data = big }}, .{}); defer allocator.free(bytes); var file = try File.init(allocator, bytes); defer file.deinit(); const entry = file.find("Workbook").?; const got = try file.readStream(allocator, entry); defer allocator.free(got); try testing.expectEqualSlices(u8, big, got); } test "reads small streams through the mini FAT" { const allocator = testing.allocator; const bytes = try test_writer.buildCfb(allocator, &.{ .{ .name = "First", .data = "a small stream, well under the 4096-byte cutoff" }, .{ .name = "Second", .data = "x" ** 130 }, // spans three mini sectors }, .{}); defer allocator.free(bytes); var file = try File.init(allocator, bytes); defer file.deinit(); const first = try file.readStream(allocator, file.find("First").?); defer allocator.free(first); try testing.expectEqualStrings("a small stream, well under the 4096-byte cutoff", first); const second = try file.readStream(allocator, file.find("Second").?); defer allocator.free(second); try testing.expectEqualStrings("x" ** 130, second); } test "version 4 files use 4096-byte sectors" { const allocator = testing.allocator; const big = try allocator.alloc(u8, 9_000); defer allocator.free(big); @memset(big, 0x5A); const bytes = try test_writer.buildCfb(allocator, &.{ .{ .name = "Workbook", .data = big }, .{ .name = "Tiny", .data = "tiny" }, }, .{ .major_version = 4 }); defer allocator.free(bytes); var file = try File.init(allocator, bytes); defer file.deinit(); const got = try file.readStream(allocator, file.find("Workbook").?); defer allocator.free(got); try testing.expectEqualSlices(u8, big, got); const tiny = try file.readStream(allocator, file.find("tiny").?); defer allocator.free(tiny); try testing.expectEqualStrings("tiny", tiny); } test "FAT sectors beyond the header's 109 are found through DIFAT sectors" { // 109 FAT sectors address 109 * 128 sectors (~7 MiB). A stream // larger than that forces the writer to spill into a DIFAT sector. const allocator = testing.allocator; const big = try allocator.alloc(u8, 8 * 1024 * 1024); defer allocator.free(big); for (big, 0..) |*b, i| b.* = @truncate(i >> 9); const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "Workbook", .data = big }}, .{}); defer allocator.free(bytes); try testing.expect(readU32(bytes, 72) > 0); // the writer really did use DIFAT sectors var file = try File.init(allocator, bytes); defer file.deinit(); const got = try file.readStream(allocator, file.find("Workbook").?); defer allocator.free(got); try testing.expectEqualSlices(u8, big, got); } test "stream lookup is case-insensitive and type-aware" { const allocator = testing.allocator; const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "Workbook", .data = "data" }}, .{}); defer allocator.free(bytes); var file = try File.init(allocator, bytes); defer file.deinit(); try testing.expect(file.find("WORKBOOK") != null); try testing.expect(file.find("workbook") != null); try testing.expect(file.find("Workboo") == null); // The root entry is not a stream, so its name never matches. try testing.expect(file.find("Root Entry") == null); } test "Entry.nameEql rejects non-ASCII code units" { var e: Entry = .{ .name = @splat(0), .name_len = 1, .kind = .stream, .start_sector = 0, .size = 0 }; e.name[0] = 0x00E9; // e-acute try testing.expect(!e.nameEql("e")); } test "rejects a non-compound file" { try testing.expectError(error.NotCompoundFile, File.init(testing.allocator, "not an ole file at all")); } test "rejects a header shorter than 512 bytes" { try testing.expectError(error.Truncated, File.init(testing.allocator, &signature)); } test "rejects bad header fields" { const allocator = testing.allocator; const good = try test_writer.buildCfb(allocator, &.{.{ .name = "S", .data = "data" }}, .{}); defer allocator.free(good); const Patch = struct { off: usize, value: u16 }; const patches = [_]Patch{ .{ .off = 28, .value = 0xFEFF }, // byte order .{ .off = 26, .value = 5 }, // major version .{ .off = 30, .value = 12 }, // sector shift does not match version 3 .{ .off = 32, .value = 7 }, // mini sector shift }; for (patches) |p| { const bad = try allocator.dupe(u8, good); defer allocator.free(bad); std.mem.writeInt(u16, bad[p.off..][0..2], p.value, .little); try testing.expectError(error.CorruptFile, File.init(allocator, bad)); } } test "rejects a FAT sector count the file cannot hold" { const allocator = testing.allocator; const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "S", .data = "data" }}, .{}); defer allocator.free(bytes); std.mem.writeInt(u32, bytes[44..48], 0xFFFFFF, .little); try testing.expectError(error.CorruptFile, File.init(allocator, bytes)); } test "rejects a version 4 file cut off inside its header sector" { const allocator = testing.allocator; const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "S", .data = "data" }}, .{ .major_version = 4 }); defer allocator.free(bytes); try testing.expectError(error.Truncated, File.init(allocator, bytes[0..1000])); } test "a FAT sector listed past the end of the file is truncation" { const allocator = testing.allocator; const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "S", .data = "data" }}, .{}); defer allocator.free(bytes); std.mem.writeInt(u32, bytes[76..80], 5000, .little); // header DIFAT[0] try testing.expectError(error.Truncated, File.init(allocator, bytes)); } test "a cyclic FAT chain is reported, not followed" { const allocator = testing.allocator; const big = try allocator.alloc(u8, 5000); defer allocator.free(big); @memset(big, 1); const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "Workbook", .data = big }}, .{}); defer allocator.free(bytes); var file = try File.init(allocator, bytes); defer file.deinit(); const entry = file.find("Workbook").?; // Point the stream's first sector back at itself. file.fat[entry.start_sector] = entry.start_sector; try testing.expectError(error.CorruptFile, file.readStream(allocator, entry)); } test "a chain shorter than the declared size is corrupt" { const allocator = testing.allocator; const big = try allocator.alloc(u8, 5000); defer allocator.free(big); @memset(big, 1); const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "Workbook", .data = big }}, .{}); defer allocator.free(bytes); var file = try File.init(allocator, bytes); defer file.deinit(); const entry = file.find("Workbook").?; file.fat[entry.start_sector] = end_of_chain; try testing.expectError(error.CorruptFile, file.readStream(allocator, entry)); var mini = entry; mini.size = 10; // below the cutoff, so read through the (empty) mini stream try testing.expectError(error.CorruptFile, file.readStream(allocator, mini)); } test "a stream larger than the file is rejected before allocating" { const allocator = testing.allocator; const big = try allocator.alloc(u8, 5000); defer allocator.free(big); @memset(big, 1); const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "Workbook", .data = big }}, .{}); defer allocator.free(bytes); var file = try File.init(allocator, bytes); defer file.deinit(); var entry = file.find("Workbook").?; entry.size = 1 << 40; try testing.expectError(error.CorruptFile, file.readStream(allocator, entry)); } test "a mini chain pointing outside the mini stream is corrupt" { const allocator = testing.allocator; const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "S", .data = "x" ** 100 }}, .{}); defer allocator.free(bytes); var file = try File.init(allocator, bytes); defer file.deinit(); const entry = file.find("S").?; // The mini FAT has a full sector of entries (128) but the mini // stream only holds two mini sectors, so entry 100 is in the table // yet outside the stream. file.mini_fat[entry.start_sector] = 100; try testing.expectError(error.CorruptFile, file.readStream(allocator, entry)); } test "a mini stream that ends mid-chain is corrupt" { const allocator = testing.allocator; const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "S", .data = "x" ** 100 }}, .{}); defer allocator.free(bytes); var file = try File.init(allocator, bytes); defer file.deinit(); var entry = file.find("S").?; // Start the chain at the second mini sector and pretend the mini // stream ends 36 bytes into it: the first hop yields a short read // that cannot be the end of a 100-byte stream. entry.start_sector = 1; const full_len = file.mini_stream.len; file.mini_stream.len = 100; defer file.mini_stream.len = full_len; try testing.expectError(error.CorruptFile, file.readStream(allocator, entry)); } test "a file cut short mid-stream reports Truncated" { const allocator = testing.allocator; const big = try allocator.alloc(u8, 20_000); defer allocator.free(big); @memset(big, 3); const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "Workbook", .data = big }}, .{}); defer allocator.free(bytes); // The writer places the stream last, so dropping the tail cuts it. var file = try File.init(allocator, bytes); defer file.deinit(); const cut_at = bytes.len - 4096; file.bytes = bytes[0..cut_at]; try testing.expectError(error.Truncated, file.readStream(allocator, file.find("Workbook").?)); // A cut that lands inside a sector leaves a short sector that is not // the stream's last: also Truncated. file.bytes = bytes[0 .. cut_at + 100]; try testing.expectError(error.Truncated, file.readStream(allocator, file.find("Workbook").?)); } test "an unpadded final sector is accepted when it ends the stream" { const allocator = testing.allocator; const big = try allocator.alloc(u8, 5000); defer allocator.free(big); @memset(big, 9); const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "Workbook", .data = big }}, .{}); defer allocator.free(bytes); // 5000 bytes = 9 full sectors + 392 bytes; drop the padding. const unpadded = bytes[0 .. bytes.len - (512 - 392)]; var file = try File.init(allocator, unpadded); defer file.deinit(); const got = try file.readStream(allocator, file.find("Workbook").?); defer allocator.free(got); try testing.expectEqualSlices(u8, big, got); } test "a directory entry with an impossible name length is corrupt" { var raw: [dir_entry_size]u8 = @splat(0); std.mem.writeInt(u16, raw[64..66], 66, .little); try testing.expectError(error.CorruptFile, parseEntry(&raw, true)); std.mem.writeInt(u16, raw[64..66], 3, .little); try testing.expectError(error.CorruptFile, parseEntry(&raw, true)); } test "version 3 entries ignore the high half of the size" { var raw: [dir_entry_size]u8 = @splat(0); std.mem.writeInt(u64, raw[120..128], 0xDEADBEEF_00000010, .little); try testing.expectEqual(@as(u64, 0x10), (try parseEntry(&raw, true)).size); try testing.expectEqual(@as(u64, 0xDEADBEEF_00000010), (try parseEntry(&raw, false)).size); } test "the root entry must come first" { const allocator = testing.allocator; const bytes = try test_writer.buildCfb(allocator, &.{.{ .name = "S", .data = "data" }}, .{}); defer allocator.free(bytes); // Root is the first entry of the first directory sector, which the // writer places right after the FAT. Retype it as a stream. const dir_off = (@as(usize, readU32(bytes, 48)) + 1) * 512; bytes[dir_off + 66] = @intFromEnum(EntryType.stream); try testing.expectError(error.CorruptFile, File.init(allocator, bytes)); }