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 }