lib/sys/src/font/roots.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const builtin = @import("builtin");
  3 const sys = @import("../root.zig");
  4 
  5 const env = sys.env;
  6 
  7 const native_os = builtin.os.tag;
  8 
  9 /// The largest number of roots a root set retains, at 24. Past it the set
 10 /// refuses roots and counts them in `dropped`.
 11 pub const roots_max: u8 = 24;
 12 
 13 /// The largest total byte count a root set retains for its root paths, at 4096.
 14 /// Past it the set refuses roots and counts them in `dropped`.
 15 pub const root_bytes_max: usize = 4096;
 16 
 17 /// The reason a platform reports no font roots, read by a caller that got none.
 18 pub const Unsupported = enum {
 19     /// `lib/sys` carries no list of font directories for this operating system.
 20     platform,
 21 };
 22 
 23 /// The outcome of resolving the platform's default roots, either the count of
 24 /// roots written or the reason none were, so a caller can tell an empty
 25 /// platform from an empty search.
 26 pub const Support = union(enum) {
 27     supported: u8,
 28     unsupported: Unsupported,
 29 };
 30 
 31 /// Holds the three environment values that shape the Linux list: `HOME`,
 32 /// `XDG_DATA_HOME`, and `XDG_DATA_DIRS`. A program fills it from the running
 33 /// process with `hostEnvironment`, while a test fills it with literals so the
 34 /// search order can be checked on any machine without touching the process
 35 /// environment.
 36 pub const Environment = struct {
 37     home: ?[]const u8 = null,
 38     xdg_data_home: ?[]const u8 = null,
 39     xdg_data_dirs: ?[]const u8 = null,
 40 };
 41 
 42 const Span = struct {
 43     offset: u16,
 44     len: u16,
 45 };
 46 
 47 /// Keeps a list of absolute font directories in inline storage, ordered as the
 48 /// search visits them, which a caller holds across a resolve and a walk with no
 49 /// allocation anywhere in between. Once either bound is reached the set refuses
 50 /// the next directory and adds one to `dropped`, which leaves a caller whose
 51 /// machine lists more directories than the bound with the prefix that fits and
 52 /// a count of what fell away.
 53 pub const RootSet = struct {
 54     bytes: [root_bytes_max]u8 = undefined,
 55     spans: [roots_max]Span = @splat(.{ .offset = 0, .len = 0 }),
 56     count: u8 = 0,
 57     dropped: u16 = 0,
 58     used: u16 = 0,
 59 
 60     pub fn clear(self: *RootSet) void {
 61         self.count = 0;
 62         self.dropped = 0;
 63         self.used = 0;
 64     }
 65 
 66     pub fn path(self: *const RootSet, index: u8) []const u8 {
 67         std.debug.assert(index < self.count);
 68         const span = self.spans[index];
 69         std.debug.assert(span.len != 0);
 70         std.debug.assert(span.offset + span.len <= self.used);
 71         return self.bytes[span.offset..][0..span.len];
 72     }
 73 
 74     /// Adds `directory` to the end of the list, when the path is absolute, the
 75     /// list lacks it, and the bytes fit, and reports whether it went in. A
 76     /// caller uses it to add a root of its own to the platform's list. Trailing
 77     /// separators are trimmed before the comparison and the copy, while a
 78     /// relative, empty, or duplicate path returns false and leaves `dropped`
 79     /// alone so `dropped` counts capacity refusals only.
 80     pub fn append(self: *RootSet, directory: []const u8) bool {
 81         const trimmed = sys.path.trimTrailingSeparators(directory);
 82         if (trimmed.len == 0 or !sys.path.isAbsolute(trimmed)) return false;
 83         for (0..self.count) |index| {
 84             if (std.mem.eql(u8, self.path(@intCast(index)), trimmed)) return false;
 85         }
 86         if (self.count == roots_max or trimmed.len > root_bytes_max - self.used) {
 87             self.dropped +|= 1;
 88             return false;
 89         }
 90         const offset = self.used;
 91         @memcpy(self.bytes[offset..][0..trimmed.len], trimmed);
 92         self.spans[self.count] = .{ .offset = offset, .len = @intCast(trimmed.len) };
 93         self.count += 1;
 94         self.used = offset + @as(u16, @intCast(trimmed.len));
 95         std.debug.assert(self.count <= roots_max);
 96         std.debug.assert(self.used <= root_bytes_max);
 97         return true;
 98     }
 99 
100     /// Joins `parent` and `child` with a single separator and adds the result.
101     /// Platform lists build most of their roots this way, joining a directory
102     /// from the environment to a fixed suffix. A join longer than the path
103     /// buffer counts a drop and returns false.
104     pub fn appendJoined(self: *RootSet, parent: []const u8, child: []const u8) bool {
105         var buffer: [std.Io.Dir.max_path_bytes]u8 = undefined;
106         const trimmed = sys.path.trimTrailingSeparators(parent);
107         const joined = std.fmt.bufPrint(&buffer, "{s}/{s}", .{ trimmed, child }) catch {
108             self.dropped +|= 1;
109             return false;
110         };
111         return self.append(joined);
112     }
113 };
114 
115 /// Looks up `HOME`, `XDG_DATA_HOME`, and `XDG_DATA_DIRS` in the running
116 /// process, for a caller running against the real host.
117 pub fn hostEnvironment() Environment {
118     return .{
119         .home = env.get("HOME"),
120         .xdg_data_home = env.get("XDG_DATA_HOME"),
121         .xdg_data_dirs = env.get("XDG_DATA_DIRS"),
122     };
123 }
124 
125 /// Fills `set` with the directories this machine's operating system keeps fonts
126 /// in, ordered as the search visits them. A caller enumerating host fonts
127 /// starts here.
128 pub fn defaultRoots(set: *RootSet, environment: Environment) Support {
129     return rootsFor(set, native_os, environment);
130 }
131 
132 /// Fills `set` for the operating system named by `os_tag` after clearing the
133 /// set first, so a test on one host can check another platform's search order
134 /// and the refusal an unlisted one produces. Linux and the BSD targets take the
135 /// XDG order, the Apple targets take the Library order, and every other target
136 /// reports itself unsupported.
137 pub fn rootsFor(
138     set: *RootSet,
139     os_tag: std.Target.Os.Tag,
140     environment: Environment,
141 ) Support {
142     set.clear();
143     return switch (os_tag) {
144         .linux, .freebsd, .netbsd, .openbsd, .dragonfly, .illumos, .hurd => .{
145             .supported = linuxRoots(set, environment),
146         },
147         .macos, .maccatalyst, .ios, .tvos, .watchos, .visionos => .{
148             .supported = appleRoots(set, environment),
149         },
150         else => .{ .unsupported = .platform },
151     };
152 }
153 
154 fn linuxRoots(set: *RootSet, environment: Environment) u8 {
155     std.debug.assert(set.count == 0);
156     appendDataHome(set, environment);
157     appendDataDirs(set, environment);
158     _ = set.append("/usr/local/share/fonts");
159     _ = set.append("/usr/share/fonts");
160     if (nonEmpty(environment.home)) |home| {
161         _ = set.appendJoined(home, ".local/share/fonts");
162         _ = set.appendJoined(home, ".fonts");
163     }
164     std.debug.assert(set.count <= roots_max);
165     return set.count;
166 }
167 
168 fn appendDataHome(set: *RootSet, environment: Environment) void {
169     if (nonEmpty(environment.xdg_data_home)) |directory| {
170         _ = set.appendJoined(directory, "fonts");
171         return;
172     }
173     if (nonEmpty(environment.home)) |home| {
174         _ = set.appendJoined(home, ".local/share/fonts");
175     }
176 }
177 
178 fn appendDataDirs(set: *RootSet, environment: Environment) void {
179     const configured = nonEmpty(environment.xdg_data_dirs) orelse
180         "/usr/local/share:/usr/share";
181     var entries = std.mem.splitScalar(u8, configured, ':');
182     var visited: usize = 0;
183     while (entries.next()) |entry| : (visited += 1) {
184         std.debug.assert(visited <= configured.len + 1);
185         if (entry.len == 0) continue;
186         _ = set.appendJoined(entry, "fonts");
187     }
188 }
189 
190 fn appleRoots(set: *RootSet, environment: Environment) u8 {
191     std.debug.assert(set.count == 0);
192     if (nonEmpty(environment.home)) |home| {
193         _ = set.appendJoined(home, "Library/Fonts");
194     }
195     _ = set.append("/Library/Fonts");
196     _ = set.append("/System/Library/Fonts");
197     _ = set.append("/System/Library/Fonts/Supplemental");
198     _ = set.append("/Network/Library/Fonts");
199     std.debug.assert(set.count <= roots_max);
200     return set.count;
201 }
202 
203 fn nonEmpty(value: ?[]const u8) ?[]const u8 {
204     const text = value orelse return null;
205     if (text.len == 0) return null;
206     return text;
207 }
208 
209 fn collect(set: *const RootSet, output: [][]const u8) [][]const u8 {
210     std.debug.assert(output.len >= set.count);
211     for (0..set.count) |index| output[index] = set.path(@intCast(index));
212     return output[0..set.count];
213 }
214 
215 test "linux font roots follow the XDG search order and drop duplicates" {
216     var set: RootSet = .{};
217     const support = rootsFor(&set, .linux, .{
218         .home = "/home/u",
219         .xdg_data_dirs = "/a:/b",
220     });
221     try std.testing.expectEqual(@as(u8, 6), support.supported);
222     var buffer: [roots_max][]const u8 = undefined;
223     try std.testing.expectEqualDeep(@as([]const []const u8, &.{
224         "/home/u/.local/share/fonts",
225         "/a/fonts",
226         "/b/fonts",
227         "/usr/local/share/fonts",
228         "/usr/share/fonts",
229         "/home/u/.fonts",
230     }), collect(&set, &buffer));
231     try std.testing.expectEqual(@as(u16, 0), set.dropped);
232 }
233 
234 test "an empty XDG data directory list falls back to the specified default" {
235     var set: RootSet = .{};
236     const support = rootsFor(&set, .linux, .{ .xdg_data_dirs = "" });
237     try std.testing.expectEqual(@as(u8, 2), support.supported);
238     var buffer: [roots_max][]const u8 = undefined;
239     try std.testing.expectEqualDeep(@as([]const []const u8, &.{
240         "/usr/local/share/fonts",
241         "/usr/share/fonts",
242     }), collect(&set, &buffer));
243 }
244 
245 test "an explicit XDG data home replaces the home relative default" {
246     var set: RootSet = .{};
247     const support = rootsFor(&set, .linux, .{
248         .home = "/home/u",
249         .xdg_data_home = "/x/",
250         .xdg_data_dirs = "/usr/share",
251     });
252     try std.testing.expectEqual(@as(u8, 5), support.supported);
253     try std.testing.expectEqualStrings("/x/fonts", set.path(0));
254     try std.testing.expectEqualStrings("/usr/share/fonts", set.path(1));
255     try std.testing.expectEqualStrings("/home/u/.local/share/fonts", set.path(3));
256 }
257 
258 test "apple font roots follow the library search order" {
259     var set: RootSet = .{};
260     const support = rootsFor(&set, .macos, .{ .home = "/Users/u" });
261     try std.testing.expectEqual(@as(u8, 5), support.supported);
262     var buffer: [roots_max][]const u8 = undefined;
263     try std.testing.expectEqualDeep(@as([]const []const u8, &.{
264         "/Users/u/Library/Fonts",
265         "/Library/Fonts",
266         "/System/Library/Fonts",
267         "/System/Library/Fonts/Supplemental",
268         "/Network/Library/Fonts",
269     }), collect(&set, &buffer));
270 }
271 
272 test "a platform without a font directory protocol reports it as unsupported" {
273     var set: RootSet = .{};
274     const support = rootsFor(&set, .windows, .{ .home = "C:/Users/u" });
275     try std.testing.expectEqual(Unsupported.platform, support.unsupported);
276     try std.testing.expectEqual(@as(u8, 0), set.count);
277     const freestanding = rootsFor(&set, .freestanding, .{});
278     try std.testing.expectEqual(Unsupported.platform, freestanding.unsupported);
279 }
280 
281 test "the root set rejects relative roots and counts capacity drops" {
282     var set: RootSet = .{};
283     try std.testing.expect(!set.append("relative/fonts"));
284     try std.testing.expect(!set.append(""));
285     try std.testing.expectEqual(@as(u8, 0), set.count);
286 
287     var long: [2001]u8 = @splat('a');
288     long[0] = '/';
289     try std.testing.expect(set.append(&long));
290     long[1] = 'b';
291     try std.testing.expect(set.append(&long));
292     long[1] = 'c';
293     try std.testing.expect(!set.append(&long));
294     try std.testing.expectEqual(@as(u8, 2), set.count);
295     try std.testing.expectEqual(@as(u16, 1), set.dropped);
296 
297     set.clear();
298     var name: [8]u8 = undefined;
299     for (0..roots_max + 1) |index| {
300         const text = try std.fmt.bufPrint(&name, "/r{d}", .{index});
301         _ = set.append(text);
302     }
303     try std.testing.expectEqual(roots_max, set.count);
304     try std.testing.expectEqual(@as(u16, 1), set.dropped);
305 }
306 
307 test "the host environment reports the process XDG values" {
308     const environment = hostEnvironment();
309     var set: RootSet = .{};
310     const support = defaultRoots(&set, environment);
311     switch (support) {
312         .supported => |count| try std.testing.expectEqual(count, set.count),
313         .unsupported => |reason| try std.testing.expectEqual(Unsupported.platform, reason),
314     }
315 }