lib/sys/src/font/catalog.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const alloc_phase = @import("alloc_phase");
3
4 const capacity = alloc_phase.capacity;
5
6 /// Identifies which container a font file uses, among TrueType, OpenType, the
7 /// two collection containers, and Type 1, so a caller tells a TrueType file
8 /// from a Type 1 file. The answer comes from the text after the last dot in the
9 /// name, and no file gets opened to reach it.
10 pub const Format = enum(u8) {
11 truetype,
12 opentype,
13 truetype_collection,
14 opentype_collection,
15 type1,
16
17 /// Decides whether a file is a font at all by reading the text after a
18 /// name's last dot and matching it against the known extensions, upper and
19 /// lower case alike. The matched extensions are ttf, otf, ttc, otc, and
20 /// pfb, while a name with no dot, an empty extension, or an extension
21 /// longer than `extension_bytes_max` returns null.
22 pub fn fromName(name: []const u8) ?Format {
23 const dot = std.mem.lastIndexOfScalar(u8, name, '.') orelse return null;
24 const extension = name[dot + 1 ..];
25 if (extension.len == 0 or extension.len > extension_bytes_max) return null;
26 var lowered: [extension_bytes_max]u8 = undefined;
27 for (extension, 0..) |byte, index| lowered[index] = std.ascii.toLower(byte);
28 const folded = lowered[0..extension.len];
29 if (std.mem.eql(u8, folded, "ttf")) return .truetype;
30 if (std.mem.eql(u8, folded, "otf")) return .opentype;
31 if (std.mem.eql(u8, folded, "ttc")) return .truetype_collection;
32 if (std.mem.eql(u8, folded, "otc")) return .opentype_collection;
33 if (std.mem.eql(u8, folded, "pfb")) return .type1;
34 return null;
35 }
36 };
37
38 /// Records one accepted font file so a caller walking admitted entries can get
39 /// each font's path and size. The record keeps where its path sits in the path
40 /// arena and how long it is, the file size, the format, and which root it came
41 /// from. The size and the format come from one stat call, so the file itself
42 /// stays closed, and `Catalog.entryPath` turns the offset and length back into
43 /// the path bytes.
44 pub const Entry = extern struct {
45 size_bytes: u64,
46 path_offset: u32,
47 path_len: u16,
48 format: Format,
49 root: u8,
50 };
51
52 /// Records one visited directory so the walk can descend from it and detect a
53 /// cycle. The record stores where its path sits in the path arena, its inode,
54 /// the record it was reached from, how deep it sits, and which root it came
55 /// from. The walk's cycle guard compares the inode against every ancestor's
56 /// inode by following the parent index, and a root's parent index is its own
57 /// index, which ends that climb.
58 pub const Directory = extern struct {
59 inode: u64,
60 path_offset: u32,
61 parent: u32,
62 path_len: u16,
63 depth: u8,
64 root: u8,
65 };
66
67 /// The longest extension `Format.fromName` will case-fold before matching, at
68 /// eight bytes. A longer extension is no extension.
69 pub const extension_bytes_max: usize = 8;
70
71 /// The ceilings on the three counts a caller picks as limits: files,
72 /// directories, and path bytes. A limit past any of the three is refused with
73 /// `CapacityExceeded`.
74 pub const entries_max: usize = 1 << 20;
75 pub const directories_max: usize = 1 << 16;
76 pub const path_bytes_max: usize = 1 << 26;
77
78 /// Defines the longest path the catalog will take, which lets a record hold its
79 /// length in sixteen bits. A longer path is refused with
80 /// `FontPathBytesExhausted`.
81 pub const path_bytes_limit: usize = std.math.maxInt(u16);
82
83 /// Specifies how large one enumeration may get through the three numbers a
84 /// caller picks: how many files, how many directories, and how many bytes of
85 /// path text.
86 pub const Limits = struct {
87 entries: usize,
88 directories: usize,
89 path_bytes: usize,
90 };
91
92 /// Identifies which count was wrong when deriving a capacity, covering the
93 /// three ways a limits value fails to resolve. A zero count gives
94 /// `InvalidLimit`, a count past its maximum gives `CapacityExceeded`, and a
95 /// byte total that overflows gives `CapacityOverflow`.
96 pub const CapacityError = error{
97 InvalidLimit,
98 CapacityExceeded,
99 CapacityOverflow,
100 };
101
102 /// What one limits value works out to: the three record counts and the size of
103 /// the single byte region that holds them, so a caller learns how many bytes of
104 /// storage the catalog needs before it allocates them. `derive` sizes the
105 /// storage as the entry records, plus the directory records, plus the path
106 /// bytes, so a caller sizes its byte region from `storage_bytes` before it
107 /// calls `Catalog.init`.
108 pub const Capacity = struct {
109 entries: usize,
110 directories: usize,
111 path_bytes: usize,
112 storage_bytes: usize,
113
114 pub const DeriveError: type = CapacityError;
115
116 pub fn derive(limits: Limits) DeriveError!Capacity {
117 if (limits.entries == 0) return error.InvalidLimit;
118 if (limits.directories == 0) return error.InvalidLimit;
119 if (limits.path_bytes == 0) return error.InvalidLimit;
120 if (limits.entries > entries_max) return error.CapacityExceeded;
121 if (limits.directories > directories_max) return error.CapacityExceeded;
122 if (limits.path_bytes > path_bytes_max) return error.CapacityExceeded;
123 const entry_bytes = try capacity.mul(usize, limits.entries, @sizeOf(Entry));
124 const directory_bytes = try capacity.mul(
125 usize,
126 limits.directories,
127 @sizeOf(Directory),
128 );
129 const record_bytes = try capacity.add(usize, entry_bytes, directory_bytes);
130 const storage_bytes = try capacity.add(usize, record_bytes, limits.path_bytes);
131 std.debug.assert(storage_bytes >= limits.path_bytes);
132 return .{
133 .entries = limits.entries,
134 .directories = limits.directories,
135 .path_bytes = limits.path_bytes,
136 .storage_bytes = storage_bytes,
137 };
138 }
139 };
140
141 /// Reports which of the three declared capacities ran out:
142 /// `FontEntriesExhausted`, `FontDirectoriesExhausted`, and
143 /// `FontPathBytesExhausted`.
144 pub const Exhaustion = error{
145 FontEntriesExhausted,
146 FontDirectoriesExhausted,
147 FontPathBytesExhausted,
148 };
149
150 /// Distinguishes a bad limit from a short region when a caller hands over
151 /// storage. A region smaller than the derived `storage_bytes` gives
152 /// `StorageTooShort`, and the capacity errors carry through.
153 pub const InitError = CapacityError || error{StorageTooShort};
154
155 const CatalogLimits = Limits;
156 const CatalogCapacity = Capacity;
157 const CatalogExhaustion = Exhaustion;
158 const CatalogInitError = InitError;
159
160 /// Holds one enumeration's results and the directories still queued for the
161 /// walk over a byte region the caller supplies, so a caller reads the whole
162 /// enumeration back out of it. That byte region holds the entry records, then
163 /// the directory records, then the shared path arena. Each admission tests the
164 /// entry count, the directory count, and the remaining path bytes before it
165 /// writes anything, which leaves everything accepted so far untouched when one
166 /// of the three refuses, so a caller may read a refusal as the answer being cut
167 /// short at `entryCount`. The order of use runs `init`, then `activate`, then
168 /// admissions, then `deinit`, which hands the caller's storage back.
169 pub const Catalog = struct {
170 phase: capacity.Phase,
171 capacity: CatalogCapacity,
172 storage: Storage,
173 entry_count: u32,
174 directory_count: u32,
175 directory_head: u32,
176 path_used: u32,
177
178 pub const storage_alignment: usize = 8;
179 pub const Storage: type = []align(storage_alignment) u8;
180 pub const Limits: type = CatalogLimits;
181 pub const Capacity: type = CatalogCapacity;
182 pub const Exhaustion: type = CatalogExhaustion;
183 pub const InitError: type = CatalogInitError;
184
185 pub const work_limits: capacity.WorkLimits = .{
186 .transition_steps_max = 1,
187 .cleanup_steps_per_call_max = 0,
188 .cleanup_calls_at_capacity_max = 0,
189 };
190
191 pub const claim: capacity.Declaration = .{
192 .source = .{
193 .id = "sys.font_walk",
194 .kind = .phase_static,
195 .limit_source = .caller,
196 .storage = .{
197 .covered = &.{
198 .{
199 .id = "caller_font_entry_records",
200 .lifetime = .transferred,
201 .detail = "caller entry records for accepted font files",
202 },
203 .{
204 .id = "caller_font_directory_records",
205 .lifetime = .transferred,
206 .detail = "caller directory records for the depth bounded frontier",
207 },
208 .{
209 .id = "caller_font_path_bytes",
210 .lifetime = .transferred,
211 .detail = "caller path arena shared by entry and directory records",
212 },
213 },
214 .excluded = &.{
215 "caller-owned root set, walk options, and enumeration statistics",
216 "directory handles, iterator read buffers, and kernel path state",
217 "process environment strings borrowed while default roots resolve",
218 },
219 },
220 .capacity = .{
221 .inputs = &.{
222 capacity.bindInput(CatalogLimits, "entries", "entries"),
223 capacity.bindInput(CatalogLimits, "directories", "directories"),
224 capacity.bindInput(CatalogLimits, "path_bytes", "path_bytes"),
225 },
226 .type_selectors = &.{
227 capacity.bindType(Entry, "entry"),
228 capacity.bindType(Directory, "directory"),
229 },
230 .nodes = &.{
231 .{ .input = 0 },
232 .{ .input = 1 },
233 .{ .input = 2 },
234 .{ .scale = .{ .node = 0, .coefficient = .{ .size_of_concrete_type = 0 } } },
235 .{ .scale = .{ .node = 1, .coefficient = .{ .size_of_concrete_type = 1 } } },
236 .{ .add = .{ .left = 3, .right = 4 } },
237 .{ .add = .{ .left = 5, .right = 2 } },
238 },
239 .assertions = &.{.{
240 .scope = .closure_total,
241 .measure = .retained,
242 .relation = .exact,
243 .expression = 6,
244 }},
245 },
246 .overload = .{
247 .kind = .reject_before_mutation,
248 .detail = "an admission past entry, directory, or path capacity writes nothing",
249 },
250 .risks = .{
251 .transitive = .{
252 .status = .open,
253 .detail = "the walk enters directory iteration owned by the standard library",
254 },
255 .foreign = .{
256 .status = .open,
257 .detail = "directory handles and iterator read buffers belong to the kernel",
258 },
259 },
260 .work = .{ .equation = "transition_steps <= 1 and cleanup_steps == 0" },
261 .obligations = &.{
262 .{ .key = "sys_font_walk_capacity_model", .role = .capacity_model },
263 .{ .key = "sys_font_walk_overload", .role = .overload },
264 .{ .key = "sys_font_walk_work_bound", .role = .work_bound },
265 },
266 },
267 .bindings = .{
268 .owner = @This(),
269 .seal = .{
270 .family = capacity.selector(@This().activate),
271 .premise = .{
272 .class = .checked_semantic_fact,
273 .authority = .checker,
274 },
275 },
276 .teardown = .{
277 .family = capacity.selector(@This().deinit),
278 .premise = .{
279 .class = .checked_semantic_fact,
280 .authority = .checker,
281 },
282 },
283 },
284 };
285
286 pub fn init(storage: Storage, limits: CatalogLimits) CatalogInitError!Catalog {
287 const derived = try CatalogCapacity.derive(limits);
288 if (storage.len < derived.storage_bytes) return error.StorageTooShort;
289 return .{
290 .phase = .initialization,
291 .capacity = derived,
292 .storage = storage,
293 .entry_count = 0,
294 .directory_count = 0,
295 .directory_head = 0,
296 .path_used = 0,
297 };
298 }
299
300 pub fn activate(self: *Catalog) void {
301 std.debug.assert(self.phase == .initialization);
302 self.phase = .steady;
303 }
304
305 /// Resets the catalog so a caller reuses one region for a second
306 /// enumeration. The call sets the entry count, the directory count, the
307 /// queue position, and the used path bytes back to zero, while the caller's
308 /// storage stays with the catalog.
309 pub fn reset(self: *Catalog) void {
310 std.debug.assert(self.phase == .steady);
311 self.entry_count = 0;
312 self.directory_count = 0;
313 self.directory_head = 0;
314 self.path_used = 0;
315 }
316
317 /// Appends one accepted font file during the walk, taking its path, its
318 /// size, its format, and the index of the root it came from. The entry
319 /// count and the remaining path bytes are both checked before anything is
320 /// written, so a refusal changes no record and no byte.
321 pub fn appendEntry(
322 self: *Catalog,
323 path: []const u8,
324 size_bytes: u64,
325 format: Format,
326 root: u8,
327 ) CatalogExhaustion!void {
328 std.debug.assert(self.phase == .steady);
329 std.debug.assert(path.len != 0);
330 if (self.entry_count == self.capacity.entries) return error.FontEntriesExhausted;
331 const offset = try self.reservePath(path);
332 self.entryStore()[self.entry_count] = .{
333 .size_bytes = size_bytes,
334 .path_offset = offset,
335 .path_len = @intCast(path.len),
336 .format = format,
337 .root = root,
338 };
339 self.entry_count += 1;
340 }
341
342 /// Puts one root directory on the queue and hands back the index of its
343 /// record. The walk calls it once per font root before it starts
344 /// descending. The record's parent index is its own index, which terminates
345 /// the ancestor climb of the walk's cycle guard.
346 pub fn appendRoot(
347 self: *Catalog,
348 path: []const u8,
349 inode: u64,
350 root: u8,
351 ) CatalogExhaustion!u32 {
352 const index = try self.appendDirectory(path, inode, self.directory_count, 0, root);
353 std.debug.assert(self.directoryStore()[index].parent == index);
354 return index;
355 }
356
357 /// Puts one subdirectory on the queue, once the walk decides to descend
358 /// into it, and hands back the index of its record. The record keeps the
359 /// inode, the index of the record it was reached from, how far beneath the
360 /// root it sits, and which root that is. The directory count and the
361 /// remaining path bytes are both checked before anything is written, so a
362 /// refusal changes no record and no byte.
363 pub fn appendDirectory(
364 self: *Catalog,
365 path: []const u8,
366 inode: u64,
367 parent: u32,
368 depth: u8,
369 root: u8,
370 ) CatalogExhaustion!u32 {
371 std.debug.assert(self.phase == .steady);
372 std.debug.assert(path.len != 0);
373 if (self.directory_count == self.capacity.directories) {
374 return error.FontDirectoriesExhausted;
375 }
376 const offset = try self.reservePath(path);
377 std.debug.assert(self.directory_count < self.capacity.directories);
378 const index = self.directory_count;
379 std.debug.assert(parent <= index);
380 self.directoryStore()[index] = .{
381 .inode = inode,
382 .path_offset = offset,
383 .parent = parent,
384 .path_len = @intCast(path.len),
385 .depth = depth,
386 .root = root,
387 };
388 self.directory_count += 1;
389 return index;
390 }
391
392 /// Drains the frontier one directory per turn for the walk, taking the next
393 /// directory off the queue and returning its index. The call returns null
394 /// once the frontier is drained. Records come back in the order they went
395 /// in, which makes the walk breadth-first.
396 pub fn takeDirectory(self: *Catalog) ?u32 {
397 std.debug.assert(self.phase == .steady);
398 std.debug.assert(self.directory_head <= self.directory_count);
399 if (self.directory_head == self.directory_count) return null;
400 const index = self.directory_head;
401 self.directory_head += 1;
402 return index;
403 }
404
405 pub fn directoryAt(self: *const Catalog, index: u32) Directory {
406 std.debug.assert(self.phase == .steady);
407 std.debug.assert(index < self.directory_count);
408 return self.directoryStore()[index];
409 }
410
411 pub fn entryCount(self: *const Catalog) u32 {
412 return self.entry_count;
413 }
414
415 pub fn directoryCount(self: *const Catalog) u32 {
416 return self.directory_count;
417 }
418
419 pub fn pathBytesUsed(self: *const Catalog) u32 {
420 return self.path_used;
421 }
422
423 pub fn entries(self: *const Catalog) []const Entry {
424 return self.entryStore()[0..self.entry_count];
425 }
426
427 pub fn entryPath(self: *const Catalog, entry: Entry) []const u8 {
428 const start = entry.path_offset;
429 std.debug.assert(start + entry.path_len <= self.path_used);
430 return self.pathStore()[start..][0..entry.path_len];
431 }
432
433 pub fn directoryPath(self: *const Catalog, directory: Directory) []const u8 {
434 const start = directory.path_offset;
435 std.debug.assert(start + directory.path_len <= self.path_used);
436 return self.pathStore()[start..][0..directory.path_len];
437 }
438
439 pub fn deinit(self: *Catalog) Storage {
440 std.debug.assert(self.phase == .steady);
441 self.phase = .teardown;
442 const storage = self.storage;
443 self.* = undefined;
444 return storage;
445 }
446
447 fn reservePath(self: *Catalog, path: []const u8) CatalogExhaustion!u32 {
448 std.debug.assert(path.len != 0);
449 std.debug.assert(self.path_used <= self.capacity.path_bytes);
450 if (path.len > path_bytes_limit) return error.FontPathBytesExhausted;
451 const remaining = self.capacity.path_bytes - self.path_used;
452 if (path.len > remaining) return error.FontPathBytesExhausted;
453 const offset = self.path_used;
454 @memcpy(self.pathStore()[offset..][0..path.len], path);
455 self.path_used = offset + @as(u32, @intCast(path.len));
456 std.debug.assert(self.path_used <= self.capacity.path_bytes);
457 return offset;
458 }
459
460 fn entryStore(self: *const Catalog) []Entry {
461 std.debug.assert(self.storage.len >= self.capacity.storage_bytes);
462 const bytes = self.storage[0 .. self.capacity.entries * @sizeOf(Entry)];
463 return @alignCast(std.mem.bytesAsSlice(Entry, bytes));
464 }
465
466 fn directoryStore(self: *const Catalog) []Directory {
467 std.debug.assert(self.storage.len >= self.capacity.storage_bytes);
468 const start = self.capacity.entries * @sizeOf(Entry);
469 std.debug.assert(start % storage_alignment == 0);
470 const count = self.capacity.directories * @sizeOf(Directory);
471 const bytes = self.storage[start..][0..count];
472 return @alignCast(std.mem.bytesAsSlice(Directory, bytes));
473 }
474
475 fn pathStore(self: *const Catalog) []u8 {
476 std.debug.assert(self.storage.len >= self.capacity.storage_bytes);
477 const start = self.capacity.entries * @sizeOf(Entry) +
478 self.capacity.directories * @sizeOf(Directory);
479 return self.storage[start..][0..self.capacity.path_bytes];
480 }
481 };
482
483 comptime {
484 capacity.requireProvisionedRejectingOwnerShape(Catalog);
485 }
486
487 comptime {
488 std.debug.assert(@sizeOf(Entry) == 16);
489 std.debug.assert(@sizeOf(Directory) == 24);
490 std.debug.assert(@alignOf(Entry) <= Catalog.storage_alignment);
491 std.debug.assert(@alignOf(Directory) <= Catalog.storage_alignment);
492 }
493
494 fn modelledStorageBytes(limits: Catalog.Limits) usize {
495 return limits.entries * 16 + limits.directories * 24 + limits.path_bytes;
496 }
497
498 test "font format classifies container extensions and rejects the rest" {
499 try std.testing.expectEqual(Format.truetype, Format.fromName("Regular.ttf").?);
500 try std.testing.expectEqual(Format.opentype, Format.fromName("Italic.OTF").?);
501 try std.testing.expectEqual(Format.truetype_collection, Format.fromName("a.TtC").?);
502 try std.testing.expectEqual(Format.opentype_collection, Format.fromName("a.otc").?);
503 try std.testing.expectEqual(Format.type1, Format.fromName("a.pfb").?);
504 try std.testing.expectEqual(@as(?Format, null), Format.fromName("Notes.txt"));
505 try std.testing.expectEqual(@as(?Format, null), Format.fromName("bare"));
506 try std.testing.expectEqual(@as(?Format, null), Format.fromName("trailing."));
507 try std.testing.expectEqual(@as(?Format, null), Format.fromName("a.ttfontcollection"));
508 }
509
510 test "font catalog capacity matches an independent three region model" {
511 comptime {
512 capacity.record(capacity.witness(Catalog, "sys_font_walk_capacity_model"));
513 }
514
515 for (1..17) |entries| {
516 for (1..9) |directories| {
517 const limits: Catalog.Limits = .{
518 .entries = entries,
519 .directories = directories,
520 .path_bytes = entries * 24 + 1,
521 };
522 const derived = try Catalog.Capacity.derive(limits);
523 try std.testing.expectEqual(modelledStorageBytes(limits), derived.storage_bytes);
524 try std.testing.expectEqual(entries, derived.entries);
525 try std.testing.expectEqual(directories, derived.directories);
526 }
527 }
528
529 const zero: Catalog.Limits = .{ .entries = 0, .directories = 1, .path_bytes = 1 };
530 try std.testing.expectError(error.InvalidLimit, Catalog.Capacity.derive(zero));
531 const over: Catalog.Limits = .{
532 .entries = entries_max + 1,
533 .directories = 1,
534 .path_bytes = 1,
535 };
536 try std.testing.expectError(error.CapacityExceeded, Catalog.Capacity.derive(over));
537 }
538
539 test "font catalog rejects every admission past capacity without mutating" {
540 comptime {
541 capacity.record(capacity.witness(Catalog, "sys_font_walk_overload"));
542 }
543
544 var storage: [1024]u8 align(Catalog.storage_alignment) = undefined;
545 var catalog = try Catalog.init(&storage, .{
546 .entries = 2,
547 .directories = 1,
548 .path_bytes = 20,
549 });
550 catalog.activate();
551 try catalog.appendEntry("/a.ttf", 10, .truetype, 0);
552 try catalog.appendEntry("/b.otf", 20, .opentype, 0);
553 try std.testing.expectError(
554 error.FontEntriesExhausted,
555 catalog.appendEntry("/c.ttc", 30, .truetype_collection, 0),
556 );
557 try std.testing.expectEqual(@as(u32, 2), catalog.entryCount());
558 try std.testing.expectEqual(@as(u32, 12), catalog.pathBytesUsed());
559
560 _ = try catalog.appendRoot("/r", 7, 0);
561 try std.testing.expectError(
562 error.FontDirectoriesExhausted,
563 catalog.appendDirectory("/s", 8, 0, 1, 0),
564 );
565 try std.testing.expectEqual(@as(u32, 1), catalog.directoryCount());
566 try std.testing.expectEqual(@as(u32, 14), catalog.pathBytesUsed());
567 _ = catalog.deinit();
568 }
569
570 test "font catalog rejects a path past the remaining arena without mutating" {
571 var storage: [1024]u8 align(Catalog.storage_alignment) = undefined;
572 var catalog = try Catalog.init(&storage, .{
573 .entries = 4,
574 .directories = 1,
575 .path_bytes = 8,
576 });
577 catalog.activate();
578 try catalog.appendEntry("/a.ttf", 10, .truetype, 0);
579 try std.testing.expectError(
580 error.FontPathBytesExhausted,
581 catalog.appendEntry("/bb.ttf", 20, .truetype, 0),
582 );
583 try std.testing.expectEqual(@as(u32, 1), catalog.entryCount());
584 try std.testing.expectEqual(@as(u32, 6), catalog.pathBytesUsed());
585 try std.testing.expectEqualStrings("/a.ttf", catalog.entryPath(catalog.entries()[0]));
586 _ = catalog.deinit();
587 }
588
589 test "font catalog transitions once and releases the caller's storage" {
590 comptime {
591 capacity.record(capacity.witness(Catalog, "sys_font_walk_work_bound"));
592 }
593
594 try std.testing.expectEqual(@as(usize, 1), Catalog.work_limits.transition_steps_max);
595 try std.testing.expectEqual(@as(usize, 0), Catalog.work_limits.cleanup_steps_per_call_max);
596 try std.testing.expectEqual(
597 @as(usize, 0),
598 Catalog.work_limits.cleanup_calls_at_capacity_max,
599 );
600
601 var storage: [512]u8 align(Catalog.storage_alignment) = undefined;
602 const limits: Catalog.Limits = .{ .entries = 2, .directories = 2, .path_bytes = 32 };
603 try std.testing.expectError(
604 error.StorageTooShort,
605 Catalog.init(storage[0..8], limits),
606 );
607 var catalog = try Catalog.init(&storage, limits);
608 try std.testing.expectEqual(capacity.Phase.initialization, catalog.phase);
609 catalog.activate();
610 try std.testing.expectEqual(capacity.Phase.steady, catalog.phase);
611 try catalog.appendEntry("/a.ttf", 1, .truetype, 0);
612 catalog.reset();
613 try std.testing.expectEqual(@as(u32, 0), catalog.entryCount());
614 const released = catalog.deinit();
615 try std.testing.expectEqual(@as(usize, storage.len), released.len);
616 }