diff --git a/build.zig b/build.zig index 529fdc3..7c768cc 100644 --- a/build.zig +++ b/build.zig @@ -21,6 +21,8 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); const libnif = libnif_dep.artifact("nif"); + // ini file format parser + const libini = b.addModule("ini", .{ .source_file = .{ .path = "lib/ini/src/ini.zig" } }); const common_cflags = .{ "-Wall", @@ -119,6 +121,7 @@ pub fn build(b: *std.Build) void { }); tests.addOptions("build_options", buildopts); tests.addModule("nif", libnif_dep.module("nif")); + tests.addModule("ini", libini); tests.linkLibrary(libnif); const run_tests = b.addRunArtifact(tests); diff --git a/src/lightning.zig b/src/lightning.zig new file mode 100644 index 0000000..038ff37 --- /dev/null +++ b/src/lightning.zig @@ -0,0 +1,7 @@ +pub const LndConf = @import("lightning/LndConf.zig"); +pub const lndhttp = @import("lightning/lndhttp.zig"); + +test { + const std = @import("std"); + std.testing.refAllDecls(@This()); +} diff --git a/src/lightning/LndConf.zig b/src/lightning/LndConf.zig new file mode 100644 index 0000000..06f949c --- /dev/null +++ b/src/lightning/LndConf.zig @@ -0,0 +1,288 @@ +//! lnd config parser and serializer based on github.com/jessevdk/go-flags, +//! specifically https://pkg.go.dev/github.com/jessevdk/go-flags#IniParser.Parse +//! +//! see https://docs.lightning.engineering/lightning-network-tools/lnd/lnd.conf +//! for lnd config docs. + +const std = @import("std"); +const ini = @import("ini"); + +const logger = std.log.scoped(.lndconf); + +sections: std.ArrayList(Section), +// holds section allocations and their key/value pairs. +// initialized at `init` and dropped at `deinit`. +arena: *std.heap.ArenaAllocator, + +// case-insensitive default group name according to source doc comments +// at github.com/jessevdk/go-flags +pub const MainSection = "application options"; + +// a section key/value pairs (properties) followed by its declaration +// in square brackets like "[section name]". +pub const Section = struct { + name: []const u8, // section name without square brackets + props: std.StringArrayHashMap(PropValue), + + /// holds all allocations throughout the lifetime of the section. + /// initialized in `appendSection`. + alloc: std.mem.Allocator, + + /// key and value are dup'ed by `Section.alloc`. + /// if an existing property exist, it necessarily becomes an array and the + /// new value is appended to the array. + pub fn appendPropStr(self: *Section, key: []const u8, value: []const u8) !void { + const vdup = try self.alloc.dupe(u8, value); + errdefer self.alloc.free(vdup); + + var res = try self.props.getOrPut(try self.alloc.dupe(u8, key)); + if (!res.found_existing) { + res.value_ptr.* = .{ .str = vdup }; + return; + } + + switch (res.value_ptr.*) { + .str => |s| { + var list = try std.ArrayList([]const u8).initCapacity(self.alloc, 2); + try list.append(s); // s is already owned by arena backing self.alloc + try list.append(vdup); + res.value_ptr.* = .{ .astr = try list.toOwnedSlice() }; + }, + .astr => |a| { + var list = std.ArrayList([]const u8).fromOwnedSlice(self.alloc, a); + try list.append(vdup); + res.value_ptr.* = .{ .astr = try list.toOwnedSlice() }; + }, + } + } + + /// replaces any existing property by the given key with the new value. + /// value is duplicated in the owned memory. + pub fn setPropStr(self: *Section, key: []const u8, value: []const u8) !void { + const vdup = try self.alloc.dupe(u8, value); + errdefer self.alloc.free(vdup); + var res = try self.props.getOrPut(try self.alloc.dupe(u8, key)); + if (res.found_existing) { + res.value_ptr.free(self.alloc); + } + res.value_ptr.* = .{ .str = vdup }; + } + + /// formats value using `std.fmt.format` and calls `setPropStr`. + /// the resulting formatted value cannot exceed 512 characters. + pub fn setPropFmt(self: *Section, key: []const u8, comptime fmt: []const u8, args: anytype) !void { + var buf = [_]u8{0} ** 512; + const val = try std.fmt.bufPrint(&buf, fmt, args); + return self.setPropStr(key, val); + } +}; + +/// the value part of a key/value pair. +pub const PropValue = union(enum) { + str: []const u8, // a "string" + astr: [][]const u8, // an array of strings, all values of repeated keys + + /// used internally when replacing an existing value in functions such as + /// `Section.setPropStr` + fn free(self: PropValue, allocator: std.mem.Allocator) void { + switch (self) { + .str => |s| allocator.free(s), + .astr => |a| { + for (a) |s| allocator.free(s); + allocator.free(a); + }, + } + } +}; + +const LndConf = @This(); + +/// creates a empty config, ready to be populated start with `appendSection`. +/// the object is serialized with `dumpWriter`. `deinit` when no long used. +/// to parse an existing config use `load` or `loadReader`. +pub fn init(allocator: std.mem.Allocator) !LndConf { + var arena = try allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(allocator); + return .{ + .arena = arena, + .sections = std.ArrayList(Section).init(arena.allocator()), + }; +} + +// frees all resources used by the config object. +pub fn deinit(self: LndConf) void { + const allocator = self.arena.child_allocator; + self.arena.deinit(); + allocator.destroy(self.arena); +} + +/// parses an existing config at the specified file path. +/// a thin wrapper around `loadReader` passing it a file reader. +pub fn load(allocator: std.mem.Allocator, filepath: []const u8) !LndConf { + const f = try std.fs.cwd().openFile(filepath, .{ .mode = .read_only }); + defer f.close(); + return loadReader(allocator, f.reader()); +} + +/// parses config contents from reader `r`. +/// makes no section deduplication: all sections are simply appended to `sections` +/// in the encountered order, with ascii characters of the name converted to lower case. +/// values of identical key names are grouped into `PropValue.astr`. +pub fn loadReader(allocator: std.mem.Allocator, r: anytype) !LndConf { + var parser = ini.parse(allocator, r); + defer parser.deinit(); + + var conf = try LndConf.init(allocator); + errdefer conf.deinit(); + + var currsect: ?*Section = null; + while (try parser.next()) |record| { + switch (record) { + .section => |name| currsect = try conf.appendSection(name), + .property => |kv| { + if (currsect == null) { + currsect = try conf.appendSection(MainSection); + } + try currsect.?.appendPropStr(kv.key, kv.value); + }, + .enumeration => |v| logger.warn("ignoring key without value: {s}", .{v}), + } + } + + return conf; +} + +/// serializes the config into writer `w`. +pub fn dumpWriter(self: LndConf, w: anytype) !void { + for (self.sections.items, 0..) |*sec, i| { + if (i > 0) { + try w.writeByte('\n'); + } + try w.print("[{s}]\n", .{sec.name}); + var it = sec.props.iterator(); + while (it.next()) |kv| { + const key = kv.key_ptr.*; + switch (kv.value_ptr.*) { + .str => |s| try w.print("{s}={s}\n", .{ key, s }), + .astr => |a| for (a) |s| try w.print("{s}={s}\n", .{ key, s }), + } + } + } +} + +/// makes no deduplication: callers must do it themselves. +pub fn appendDefaultSection(self: *LndConf) !*Section { + return self.appendSection(MainSection); +} + +/// creates a new confg section, owning a name copy dup'ed using the `arena` allocator. +/// the section name ascii is converted to lower case. +pub fn appendSection(self: *LndConf, name: []const u8) !*Section { + const alloc = self.arena.allocator(); + var name_dup = try alloc.dupe(u8, name); + toLower(name_dup); + try self.sections.append(.{ + .name = name_dup, + .props = std.StringArrayHashMap(PropValue).init(alloc), + .alloc = alloc, + }); + return &self.sections.items[self.sections.items.len - 1]; +} + +/// returns a section named `MainSection`, if any. +pub fn mainSection(self: LndConf) ?*Section { + return self.findSection(MainSection); +} + +/// O(n); name must be in lower case. +pub fn findSection(self: *const LndConf, name: []const u8) ?*Section { + for (self.sections.items) |*sec| { + if (std.mem.eql(u8, sec.name, name)) { + return sec; + } + } + return null; +} + +fn toLower(s: []u8) void { + for (s, 0..) |c, i| { + switch (c) { + 'A'...'Z' => s[i] = c | 0b00100000, + else => {}, + } + } +} + +test "lnd: conf load dump" { + const t = std.testing; + const tt = @import("../test.zig"); + + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + try tmp.dir.writeFile("conf.ini", + \\; top comment + \\[application options] + \\foo = bar + \\; line comment + \\baz = quix1 ; inline comment + \\baz = quix2 + \\ + \\[AutopiloT] + \\autopilot.active=false + ); + const clean_conf = + \\[application options] + \\foo=bar + \\baz=quix1 + \\baz=quix2 + \\ + \\[autopilot] + \\autopilot.active=false + \\ + ; + + const conf = try LndConf.load(t.allocator, try tmp.join(&.{"conf.ini"})); + defer conf.deinit(); + + var dump = std.ArrayList(u8).init(t.allocator); + defer dump.deinit(); + try conf.dumpWriter(dump.writer()); + try t.expectEqualStrings(clean_conf, dump.items); + + const sec = conf.mainSection().?; + try t.expectEqualStrings("bar", sec.props.get("foo").?.str); + const bazval: []const []const u8 = &.{ "quix1", "quix2" }; + try tt.expectDeepEqual(bazval, sec.props.get("baz").?.astr); +} + +test "lnd: conf append and dump" { + const t = std.testing; + + var conf = try LndConf.init(t.allocator); + defer conf.deinit(); + var sec = try conf.appendSection(MainSection); + try sec.appendPropStr("foo", "bar"); + + var buf = std.ArrayList(u8).init(t.allocator); + defer buf.deinit(); + try conf.dumpWriter(buf.writer()); + try t.expectEqualStrings("[application options]\nfoo=bar\n", buf.items); + + try sec.setPropStr("foo", "baz"); + var sec2 = try conf.appendSection("test"); + try sec2.setPropStr("bar", "quix1"); + try sec2.appendPropStr("bar", "quix2"); + + buf.clearAndFree(); + try conf.dumpWriter(buf.writer()); + const want_conf = + \\[application options] + \\foo=baz + \\ + \\[test] + \\bar=quix1 + \\bar=quix2 + \\ + ; + try t.expectEqualStrings(want_conf, buf.items); +} diff --git a/src/lndhttp.zig b/src/lightning/lndhttp.zig similarity index 99% rename from src/lndhttp.zig rename to src/lightning/lndhttp.zig index a6c435e..656c0c3 100644 --- a/src/lndhttp.zig +++ b/src/lightning/lndhttp.zig @@ -3,7 +3,7 @@ const std = @import("std"); const base64enc = std.base64.standard.Encoder; -const types = @import("types.zig"); +const types = @import("../types.zig"); /// safe for concurrent use as long as Client.allocator is. pub const Client = struct { diff --git a/src/nd/Config.zig b/src/nd/Config.zig index 2f85cef..588ac37 100644 --- a/src/nd/Config.zig +++ b/src/nd/Config.zig @@ -2,6 +2,8 @@ //! the structure is defined in `Data`. const std = @import("std"); +const lightning = @import("../lightning.zig"); +const types = @import("../types.zig"); const logger = std.log.scoped(.config); @@ -270,7 +272,7 @@ pub fn lndConnectWaitMacaroonFile(self: Config, allocator: std.mem.Allocator, ty /// returns the bytes printed to outbuf. pub fn makeWalletUnlockFile(self: Config, outbuf: []u8, comptime raw_size: usize) ![]const u8 { const filepath = LND_WALLETUNLOCK_PATH; - const lnduser = try std.process.getUserInfo(LND_OS_USER); + const lnduser = try types.getUserInfo(LND_OS_USER); const allocator = self.arena.child_allocator; const opt = .{ .mode = 0o400 }; @@ -290,64 +292,70 @@ pub fn makeWalletUnlockFile(self: Config, outbuf: []u8, comptime raw_size: usize return hex; } -/// creates or overwrites existing lnd config file at `LND_CONF_PATH`. -pub fn genLndConfig(self: Config, opt: struct { autounlock: bool }) !void { - const confpath = LND_CONF_PATH; - const lnduser = try std.process.getUserInfo(LND_OS_USER); +/// options for genLndConfig. +pub const LndConfOpt = struct { + autounlock: bool, + path: ?[]const u8 = null, // defaults to LND_CONF_PATH +}; + +/// creates or overwrites existing lnd config file on disk. +pub fn genLndConfig(self: Config, opt: LndConfOpt) !void { + const confpath = opt.path orelse LND_CONF_PATH; + const lnduser = try types.getUserInfo(LND_OS_USER); const allocator = self.arena.child_allocator; - const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), confpath, .{ .mode = 0o400 }); - defer file.destroy(); // frees resources; does NOT delete the file - const w = file.writer(); - - // main app settings - try w.writeAll("[Application Options]\n"); - try w.writeAll("debuglevel=info\n"); - try w.writeAll("maxpendingchannels=10\n"); - try w.writeAll("maxlogfiles=3\n"); - try w.writeAll("listen=[::]:9735\n"); // or 0.0.0.0:9735 - try w.writeAll("rpclisten=0.0.0.0:10009\n"); - try w.writeAll("restlisten=0.0.0.0:10010\n"); // TODO: replace with 127.0.0.1 and no-rest-tls=true? - try std.fmt.format(w, "alias={s}\n", .{"nakamochi"}); // TODO: make alias configurable - try std.fmt.format(w, "datadir={s}\n", .{LND_DATA_DIR}); - try std.fmt.format(w, "logdir={s}\n", .{LND_LOG_DIR}); + var conf = try lightning.LndConf.init(allocator); + defer conf.deinit(); + var sec = try conf.appendDefaultSection(); + try sec.setPropStr("debuglevel", "info"); + try sec.setPropStr("maxpendingchannels", "10"); + try sec.setPropStr("maxlogfiles", "3"); + try sec.setPropStr("listen", "[::]:9735"); // or 0.0.0.0:9735 + try sec.setPropStr("rpclisten", "0.0.0.0:10009"); + try sec.setPropStr("restlisten", "0.0.0.0:10010"); + try sec.setPropStr("alias", "nakamochi"); // TODO: make alias configurable + try sec.setPropStr("datadir", LND_DATA_DIR); + try sec.setPropStr("logdir", LND_LOG_DIR); if (self.static.lnd_tor_hostname) |torhost| { - try std.fmt.format(w, "tlsextradomain={s}\n", .{torhost}); - try std.fmt.format(w, "externalhosts={s}\n", .{torhost}); + try sec.setPropStr("tlsextradomain", torhost); + try sec.setPropStr("externalhosts", torhost); } if (opt.autounlock) { - try std.fmt.format(w, "wallet-unlock-password-file={s}\n", .{LND_WALLETUNLOCK_PATH}); + try sec.setPropStr("wallet-unlock-password-file", LND_WALLETUNLOCK_PATH); } // bitcoin chain settings - try w.writeAll("\n[bitcoin]\n"); - try std.fmt.format(w, "bitcoin.chaindir={s}/chain/mainnet\n", .{LND_DATA_DIR}); - try w.writeAll("bitcoin.active=true\n"); - try w.writeAll("bitcoin.mainnet=True\n"); - try w.writeAll("bitcoin.testnet=False\n"); - try w.writeAll("bitcoin.regtest=False\n"); - try w.writeAll("bitcoin.simnet=False\n"); - try w.writeAll("bitcoin.node=bitcoind\n"); - try w.writeAll("\n[bitcoind]\n"); - try w.writeAll("bitcoind.zmqpubrawblock=tcp://127.0.0.1:8331\n"); - try w.writeAll("bitcoind.zmqpubrawtx=tcp://127.0.0.1:8330\n"); - try w.writeAll("bitcoind.rpchost=127.0.0.1\n"); - try w.writeAll("bitcoind.rpcuser=rpc\n"); + sec = try conf.appendSection("bitcoin"); + try sec.setPropFmt("bitcoin.chaindir", "{s}/chain/mainnet", .{LND_DATA_DIR}); + try sec.setPropStr("bitcoin.active", "true"); + try sec.setPropStr("bitcoin.mainnet", "true"); + try sec.setPropStr("bitcoin.testnet", "false"); + try sec.setPropStr("bitcoin.regtest", "false"); + try sec.setPropStr("bitcoin.simnet", "false"); + try sec.setPropStr("bitcoin.node", "bitcoind"); + sec = try conf.appendSection("bitcoind"); + try sec.setPropStr("bitcoind.zmqpubrawblock", "tcp://127.0.0.1:8331"); + try sec.setPropStr("bitcoind.zmqpubrawtx", "tcp://127.0.0.1:8330"); + try sec.setPropStr("bitcoind.rpchost", "127.0.0.1"); + try sec.setPropStr("bitcoind.rpcuser", "rpc"); if (self.static.bitcoind_rpc_pass) |rpcpass| { - try std.fmt.format(w, "bitcoind.rpcpass={s}\n", .{rpcpass}); + try sec.setPropStr("bitcoind.rpcpass", rpcpass); } else { return error.GenLndConfigNoBitcoindRpcPass; } // other settings - try w.writeAll("\n[autopilot]\n"); - try w.writeAll("autopilot.active=false\n"); - try w.writeAll("\n[tor]\n"); - try w.writeAll("tor.active=true\n"); - try w.writeAll("tor.skip-proxy-for-clearnet-targets=true\n"); + sec = try conf.appendSection("autopilot"); + try sec.setPropStr("autopilot.active", "false"); + sec = try conf.appendSection("tor"); + try sec.setPropStr("tor.active", "true"); + try sec.setPropStr("tor.skip-proxy-for-clearnet-targets", "true"); - // persist the file in the correct location. - try file.finish(); + // dump config into the file. + const file = try std.io.BufferedAtomicFile.create(allocator, std.fs.cwd(), confpath, .{ .mode = 0o400 }); + defer file.destroy(); // frees resources; does NOT delete the file + try conf.dumpWriter(file.writer()); + try file.finish(); // persist the file in the correct location // change file ownership to that of the lnd system user. const f = try std.fs.cwd().openFile(confpath, .{}); @@ -355,7 +363,7 @@ pub fn genLndConfig(self: Config, opt: struct { autounlock: bool }) !void { try f.chown(lnduser.uid, lnduser.gid); } -test "init existing" { +test "ndconfig: init existing" { const t = std.testing; const tt = @import("../test.zig"); @@ -375,7 +383,7 @@ test "init existing" { try t.expectEqualStrings("/sysupdates/run.sh", conf.data.sysrunscript); } -test "init null" { +test "ndconfig: init null" { const t = std.testing; const conf = try init(t.allocator, "/non/existent/config/file"); @@ -385,7 +393,7 @@ test "init null" { try t.expectEqualStrings(SYSUPDATES_RUN_SCRIPT_PATH, conf.data.sysrunscript); } -test "dump" { +test "ndconfig: dump" { const t = std.testing; const tt = @import("../test.zig"); @@ -416,7 +424,7 @@ test "dump" { try t.expectEqualStrings("runscript.sh", parsed.value.sysrunscript); } -test "switch sysupdates and infer" { +test "ndconfig: switch sysupdates and infer" { const t = std.testing; const tt = @import("../test.zig"); @@ -448,11 +456,11 @@ test "switch sysupdates and infer" { try t.expectEqual(SysupdatesChannel.dev, inferSysupdatesChannel(cronscript)); } -test "switch sysupdates with .run=true" { +test "ndconfig: switch sysupdates with .run=true" { const t = std.testing; const tt = @import("../test.zig"); - // no arena deinit: expecting Config to + // no arena deinit here: expecting Config to auto-deinit. var conf_arena = try std.testing.allocator.create(std.heap.ArenaAllocator); conf_arena.* = std.heap.ArenaAllocator.init(std.testing.allocator); var tmp = try tt.TempDir.create(); @@ -492,3 +500,52 @@ fn testLoadConfigData(path: []const u8) !std.json.Parsed(Data) { const jopt = .{ .ignore_unknown_fields = true, .allocate = .alloc_always }; return try std.json.parseFromSlice(Data, allocator, bytes, jopt); } + +test "ndconfig: genLndConfig" { + const t = std.testing; + const tt = @import("../test.zig"); + + // Config auto-deinits the arena. + var conf_arena = try std.testing.allocator.create(std.heap.ArenaAllocator); + conf_arena.* = std.heap.ArenaAllocator.init(std.testing.allocator); + var tmp = try tt.TempDir.create(); + defer tmp.cleanup(); + + var conf = Config{ + .arena = conf_arena, + .confpath = undefined, // unused + .data = .{ + .syschannel = .master, // unused + .syscronscript = undefined, // unused + .sysrunscript = undefined, // unused + }, + .static = .{ + .lnd_tor_hostname = "test.onion", + .bitcoind_rpc_pass = "test secret", + }, + }; + defer conf.deinit(); + + const confpath = try tmp.join(&.{"lndconf.ini"}); + try conf.genLndConfig(.{ .autounlock = false, .path = confpath }); + + const bytes = try std.fs.cwd().readFileAlloc(t.allocator, confpath, 1 << 20); + defer t.allocator.free(bytes); + try tt.expectSubstring("tlsextradomain=test.onion\n", bytes); + try tt.expectSubstring("externalhosts=test.onion\n", bytes); + try tt.expectNoSubstring("wallet-unlock-password-file", bytes); + try tt.expectSubstring("bitcoind.rpcpass=test secret\n", bytes); + + try conf.genLndConfig(.{ .autounlock = true, .path = confpath }); + const bytes2 = try std.fs.cwd().readFileAlloc(t.allocator, confpath, 1 << 20); + defer t.allocator.free(bytes2); + try tt.expectSubstring("wallet-unlock-password-file=", bytes2); + + const lndconf = try lightning.LndConf.load(t.allocator, confpath); + defer lndconf.deinit(); + try t.expect(lndconf.mainSection() != null); + try t.expect(lndconf.findSection("bitcoin") != null); + try t.expect(lndconf.findSection("bitcoind") != null); + try t.expect(lndconf.findSection("autopilot") != null); + try t.expect(lndconf.findSection("tor") != null); +} diff --git a/src/nd/Daemon.zig b/src/nd/Daemon.zig index 155d6cf..9fe0413 100644 --- a/src/nd/Daemon.zig +++ b/src/nd/Daemon.zig @@ -18,7 +18,7 @@ const time = std.time; const bitcoindrpc = @import("../bitcoindrpc.zig"); const comm = @import("../comm.zig"); const Config = @import("Config.zig"); -const lndhttp = @import("../lndhttp.zig"); +const lndhttp = @import("../lightning.zig").lndhttp; const network = @import("network.zig"); const screen = @import("../ui/screen.zig"); const SysService = @import("SysService.zig"); diff --git a/src/test.zig b/src/test.zig index b885375..0fa528c 100644 --- a/src/test.zig +++ b/src/test.zig @@ -315,11 +315,22 @@ pub fn expectDeepEqual(expected: anytype, actual: @TypeOf(expected)) !void { } } +pub fn expectSubstring(needle: []const u8, haystack: []const u8) !void { + const n = std.mem.count(u8, haystack, needle); + try std.testing.expect(n > 0); +} + +pub fn expectNoSubstring(needle: []const u8, haystack: []const u8) !void { + const n = std.mem.count(u8, haystack, needle); + try std.testing.expect(n == 0); +} + test { _ = @import("nd.zig"); _ = @import("nd/Daemon.zig"); _ = @import("nd/SysService.zig"); _ = @import("ngui.zig"); + _ = @import("lightning.zig"); std.testing.refAllDecls(@This()); } diff --git a/src/types.zig b/src/types.zig index b226c69..7fe3956 100644 --- a/src/types.zig +++ b/src/types.zig @@ -9,11 +9,25 @@ pub usingnamespace if (builtin.is_test) struct { pub const Timer = tt.TestTimer; pub const ChildProcess = tt.TestChildProcess; pub const WpaControl = tt.TestWpaControl; + + /// always returns caller's (current process) user/group IDs. + /// atm works only on linux via getuid syscalls. + pub fn getUserInfo(name: []const u8) !std.process.UserInfo { + _ = name; + return .{ + .uid = std.os.linux.getuid(), + .gid = std.os.linux.getgid(), + }; + } } else struct { // regular types for production code. pub const Timer = std.time.Timer; pub const ChildProcess = std.ChildProcess; pub const WpaControl = nif.wpa.Control; + + pub fn getUserInfo(name: []const u8) !std.process.UserInfo { + return std.process.getUserInfo(name); + } }; /// prefer this type over the std.ArrayList(u8) just to ensure consistency