lib/xkb/src/compose/path.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const scratch = @import("workspace/root.zig");
3
4 pub const default_system_directory = "/usr/share/X11/locale";
5 pub const fallback_locale = "en_US.UTF-8";
6
7 pub const Options = struct {
8 locale: []const u8,
9 home: ?[]const u8 = null,
10 xcompose_file: ?[]const u8 = null,
11 xdg_config_home: ?[]const u8 = null,
12 system_directory: []const u8 = default_system_directory,
13 io: std.Io = std.Options.debug_io,
14 };
15
16 pub fn xdg(output: []u8, options: Options) !?[]const u8 {
17 if (options.xdg_config_home) |directory| {
18 if (std.fs.path.isAbsolute(directory)) {
19 return try join(output, &.{ directory, "XCompose" });
20 }
21 }
22 const home_directory = options.home orelse return null;
23 return try join(output, &.{ home_directory, ".config", "XCompose" });
24 }
25
26 pub fn home(output: []u8, options: Options) !?[]const u8 {
27 const directory = options.home orelse return null;
28 return try join(output, &.{ directory, ".XCompose" });
29 }
30
31 pub fn locale(storage: *scratch.Storage, output: []u8, options: Options) ![]const u8 {
32 return try copy(output, try localeValue(storage, options));
33 }
34
35 pub fn expandInclude(
36 storage: *scratch.Storage,
37 output: []u8,
38 options: Options,
39 input: []const u8,
40 ) ![]const u8 {
41 var builder = Builder.init(output);
42 var index: usize = 0;
43 while (index < input.len) {
44 if (input[index] != '%') {
45 try builder.appendByte(input[index]);
46 index += 1;
47 continue;
48 }
49 if (index + 1 >= input.len) return error.InvalidIncludeExpansion;
50 switch (input[index + 1]) {
51 '%' => try builder.appendByte('%'),
52 'H' => try builder.append(options.home orelse return error.MissingHome),
53 'S' => try builder.append(options.system_directory),
54 'L' => try builder.append(try localeValue(storage, options)),
55 else => return error.InvalidIncludeExpansion,
56 }
57 index += 2;
58 }
59 return builder.value();
60 }
61
62 fn localeValue(storage: *scratch.Storage, options: Options) ![]const u8 {
63 const resolved_locale = try resolveLocale(storage, options);
64 const requested = if (std.mem.eql(u8, resolved_locale, "C"))
65 fallback_locale
66 else
67 resolved_locale;
68
69 const registry_path = try join(
70 storage.auxiliaryPath(0),
71 &.{ options.system_directory, "compose.dir" },
72 );
73 const relative = (try resolveRegistry(
74 storage,
75 options.io,
76 registry_path,
77 .right_to_left,
78 requested,
79 )) orelse return error.LocaleUnavailable;
80 const output = storage.auxiliaryPath(1);
81 if (std.fs.path.isAbsolute(relative)) return try copy(output, relative);
82 return try join(output, &.{ options.system_directory, relative });
83 }
84
85 fn resolveLocale(storage: *scratch.Storage, options: Options) ![]const u8 {
86 const alias_path = try join(
87 storage.auxiliaryPath(0),
88 &.{ options.system_directory, "locale.alias" },
89 );
90 const resolved = try resolveRegistry(
91 storage,
92 options.io,
93 alias_path,
94 .left_to_right,
95 options.locale,
96 );
97 return try copy(storage.auxiliaryPath(1), resolved orelse options.locale);
98 }
99
100 const Direction = enum {
101 left_to_right,
102 right_to_left,
103 };
104
105 fn resolveRegistry(
106 storage: *scratch.Storage,
107 io: std.Io,
108 file_path: []const u8,
109 direction: Direction,
110 name: []const u8,
111 ) !?[]const u8 {
112 var file = std.Io.Dir.cwd().openFile(io, file_path, .{}) catch |err| switch (err) {
113 error.FileNotFound => return null,
114 else => return err,
115 };
116 defer file.close(io);
117 const stat = try file.stat(io);
118 if (stat.kind != .file) return null;
119 if (stat.size >= scratch.max_registry_bytes) return error.StreamTooLong;
120 var reader = file.reader(io, storage.registryLine());
121 while (true) {
122 const raw_line = reader.interface.takeDelimiter('\n') catch |err| switch (err) {
123 error.ReadFailed => return reader.err.?,
124 error.StreamTooLong => return error.LineByteCapacityExceeded,
125 } orelse return null;
126 if (raw_line.len > storage.lineLimit()) return error.LineByteCapacityExceeded;
127 if (resolveLine(raw_line, direction, name)) |resolved| return resolved;
128 }
129 }
130
131 fn resolveLine(raw_line: []const u8, direction: Direction, name: []const u8) ?[]const u8 {
132 var line = std.mem.trim(u8, raw_line, " \t\x0b\x0c\r\n");
133 if (line.len == 0 or line[0] == '#') return null;
134
135 const left_end = std.mem.indexOfAny(u8, line, " \t\x0b\x0c\r:") orelse line.len;
136 if (left_end == 0) return null;
137 const left = line[0..left_end];
138 line = line[left_end..];
139 line = std.mem.trimStart(u8, line, " \t\x0b\x0c\r");
140 if (line.len > 0 and line[0] == ':') line = line[1..];
141 line = std.mem.trimStart(u8, line, " \t\x0b\x0c\r");
142 const right_end = std.mem.indexOfAny(u8, line, " \t\x0b\x0c\r") orelse line.len;
143 if (right_end == 0) return null;
144 const right = line[0..right_end];
145
146 return switch (direction) {
147 .left_to_right => if (std.mem.eql(u8, left, name)) right else null,
148 .right_to_left => if (std.mem.eql(u8, right, name)) left else null,
149 };
150 }
151
152 fn join(output: []u8, parts: []const []const u8) ![]const u8 {
153 var builder = Builder.init(output);
154 const first_index = for (parts, 0..) |part, index| {
155 if (part.len != 0) break index;
156 } else return builder.value();
157 try builder.append(parts[first_index]);
158 var previous = parts[first_index];
159 for (parts[first_index + 1 ..]) |part| {
160 if (part.len == 0) continue;
161 const previous_separator = std.fs.path.isSep(previous[previous.len - 1]);
162 const current_separator = std.fs.path.isSep(part[0]);
163 if (!previous_separator and !current_separator) {
164 try builder.appendByte(std.fs.path.sep);
165 }
166 try builder.append(if (previous_separator and current_separator) part[1..] else part);
167 previous = part;
168 }
169 return builder.value();
170 }
171
172 fn copy(output: []u8, input: []const u8) ![]const u8 {
173 var builder = Builder.init(output);
174 try builder.append(input);
175 return builder.value();
176 }
177
178 const Builder = struct {
179 bytes: []u8,
180 length: usize = 0,
181
182 fn init(bytes: []u8) Builder {
183 return .{ .bytes = bytes };
184 }
185
186 fn appendByte(self: *Builder, byte: u8) !void {
187 if (self.length == self.bytes.len) return error.PathByteCapacityExceeded;
188 self.bytes[self.length] = byte;
189 self.length += 1;
190 }
191
192 fn append(self: *Builder, input: []const u8) !void {
193 if (input.len > self.bytes.len - self.length) {
194 return error.PathByteCapacityExceeded;
195 }
196 @memcpy(self.bytes[self.length..][0..input.len], input);
197 self.length += input.len;
198 }
199
200 fn value(self: *const Builder) []const u8 {
201 return self.bytes[0..self.length];
202 }
203 };
204
205 test "locale aliases and Compose registry resolve through bounded scratch" {
206 const allocator = std.testing.allocator;
207 var temporary = std.testing.tmpDir(.{});
208 defer temporary.cleanup();
209 const root = try temporary.dir.realPathFileAlloc(std.Options.debug_io, ".", allocator);
210 defer allocator.free(root);
211
212 try temporary.dir.writeFile(std.Options.debug_io, .{
213 .sub_path = "locale.alias",
214 .data = "friendly en_US.UTF-8\n",
215 });
216 try temporary.dir.writeFile(std.Options.debug_io, .{
217 .sub_path = "compose.dir",
218 .data = "custom/Compose en_US.UTF-8\n",
219 });
220
221 var storage = try scratch.Storage.init(allocator, scratch.default_limits);
222 defer storage.deinit(allocator);
223 storage.activate();
224 try storage.acquire();
225 defer storage.reset();
226 const resolved = try locale(&storage, storage.filePath(0), .{
227 .locale = "friendly",
228 .system_directory = root,
229 });
230 const expected = try std.fs.path.join(allocator, &.{ root, "custom/Compose" });
231 defer allocator.free(expected);
232 try std.testing.expectEqualStrings(expected, resolved);
233 }
234
235 test "include expansion uses bounded home locale system and percent substitutions" {
236 const allocator = std.testing.allocator;
237 var temporary = std.testing.tmpDir(.{});
238 defer temporary.cleanup();
239 const root = try temporary.dir.realPathFileAlloc(std.Options.debug_io, ".", allocator);
240 defer allocator.free(root);
241
242 try temporary.dir.writeFile(std.Options.debug_io, .{
243 .sub_path = "compose.dir",
244 .data = "Compose en_US.UTF-8\n",
245 });
246 var storage = try scratch.Storage.init(allocator, scratch.default_limits);
247 defer storage.deinit(allocator);
248 storage.activate();
249 try storage.acquire();
250 defer storage.reset();
251 const expanded = try expandInclude(
252 &storage,
253 storage.filePath(1),
254 .{ .locale = "C", .home = "/home/example", .system_directory = root },
255 "%H|%S|%L|%%",
256 );
257
258 const locale_path = try std.fs.path.join(allocator, &.{ root, "Compose" });
259 defer allocator.free(locale_path);
260 const expected = try std.fmt.allocPrint(
261 allocator,
262 "/home/example|{s}|{s}|%",
263 .{ root, locale_path },
264 );
265 defer allocator.free(expected);
266 try std.testing.expectEqualStrings(expected, expanded);
267 }
268
269 test "unregistered locale does not silently select the fallback" {
270 const allocator = std.testing.allocator;
271 var temporary = std.testing.tmpDir(.{});
272 defer temporary.cleanup();
273 const root = try temporary.dir.realPathFileAlloc(std.Options.debug_io, ".", allocator);
274 defer allocator.free(root);
275
276 try temporary.dir.writeFile(std.Options.debug_io, .{
277 .sub_path = "compose.dir",
278 .data = "Compose en_US.UTF-8\n",
279 });
280 var storage = try scratch.Storage.init(allocator, scratch.default_limits);
281 defer storage.deinit(allocator);
282 storage.activate();
283 try storage.acquire();
284 defer storage.reset();
285 try std.testing.expectError(error.LocaleUnavailable, locale(
286 &storage,
287 storage.filePath(0),
288 .{ .locale = "blabla", .system_directory = root },
289 ));
290 }