diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..258a74e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +on: + pull_request: + workflow_dispatch: + push: + branches: + - master + tags: + - v?[0-9]+.[0-9]+.[0-9]+* + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + DeterminateCI: + uses: DeterminateSystems/ci/.github/workflows/workflow.yml@main + permissions: + id-token: write + contents: read diff --git a/.gitignore b/.gitignore index f366cdb..46968ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ -zig-cache +.zig-cache zig-out *.dtb +!test/*.dtb + +.direnv +result +result-* diff --git a/build.zig b/build.zig index 56779fe..3c28cbd 100644 --- a/build.zig +++ b/build.zig @@ -7,23 +7,40 @@ pub fn build(b: *std.Build) void { const no_docs = b.option(bool, "no-docs", "skip installing documentation") orelse false; const dtree = b.addModule("dtree", .{ - .root_source_file = .{ .path = b.pathFromRoot("dtree.zig") }, + .root_source_file = b.path("dtree.zig"), + .target = target, + .optimize = optimize, }); if (!no_tests) { const step_test = b.step("test", "Run all unit tests"); const unit_tests = b.addTest(.{ - .root_source_file = .{ - .path = b.pathFromRoot("dtree.zig"), - }, - .target = target, - .optimize = optimize, + .root_module = b.createModule(.{ + .root_source_file = b.path("dtree.zig"), + .target = target, + .optimize = optimize, + }), }); const run_unit_tests = b.addRunArtifact(unit_tests); step_test.dependOn(&run_unit_tests.step); + const integration_tests = b.addTest(.{ + .name = "integration-test", + .root_module = b.createModule(.{ + .root_source_file = b.path("test/root.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "dtree", .module = dtree }, + }, + }), + }); + + const run_integration_tests = b.addRunArtifact(integration_tests); + step_test.dependOn(&run_integration_tests.step); + if (!no_docs) { const docs = b.addInstallDirectory(.{ .source_dir = unit_tests.getEmittedDocs(), @@ -37,13 +54,15 @@ pub fn build(b: *std.Build) void { const exe_example = b.addExecutable(.{ .name = "example", - .root_source_file = .{ - .path = b.pathFromRoot("example.zig"), - }, - .target = target, - .optimize = optimize, + .root_module = b.createModule(.{ + .root_source_file = b.path("example.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "dtree", .module = dtree }, + }, + }), }); - exe_example.root_module.addImport("dtree", dtree); b.installArtifact(exe_example); } diff --git a/build.zig.zon b/build.zig.zon index 3b32fce..3bd2471 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,5 +1,6 @@ .{ - .name = "dtree", - .version = "0.1.0", - .paths = .{"."}, + .name = .dtree, + .version = "0.1.0", + .fingerprint = 0x3ceaa750f068abef, + .paths = .{"."}, } diff --git a/dtree.zig b/dtree.zig index 7ab5db7..3eef734 100644 --- a/dtree.zig +++ b/dtree.zig @@ -1,2 +1,7 @@ pub const Reader = @import("dtree/reader.zig"); pub const types = @import("dtree/types.zig"); + +test { + _ = Reader; + _ = types; +} diff --git a/dtree/reader.zig b/dtree/reader.zig index 8154652..8f834ee 100644 --- a/dtree/reader.zig +++ b/dtree/reader.zig @@ -13,11 +13,9 @@ pub const Node = union(enum) { depth: usize, name: []const u8, - pub fn format(self: Begin, comptime _: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { - _ = options; - + pub fn format(self: Begin, writer: *std.Io.Writer) std.Io.Writer.Error!void { try writer.writeAll(@typeName(Begin)); - try writer.print("{{ .depth = {}, .name = \"{s}\" }}", .{ + try writer.print("{{ .depth = {d}, .name = \"{s}\" }}", .{ self.depth, self.name, }); @@ -33,11 +31,9 @@ pub const Node = union(enum) { name: []const u8, value: []const u8, - pub fn format(self: Prop, comptime _: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { - _ = options; - + pub fn format(self: Prop, writer: *std.Io.Writer) std.Io.Writer.Error!void { try writer.writeAll(@typeName(Prop)); - try writer.print("{{ .depth = {}, .name = \"{s}\", .value = {any} }}", .{ + try writer.print("{{ .depth = {d}, .name = \"{s}\", .value = {any} }}", .{ self.depth, self.name, self.value, @@ -98,7 +94,7 @@ pub const NodeIterator = struct { } pub fn readInt(self: *NodeIterator, comptime T: type) T { - const len = @divExact(@typeInfo(T).Int.bits, 8); + const len = @divExact(@typeInfo(T).int.bits, 8); const pos = self.offset(); const value = self.reader.buff[pos..][0..len]; self.pos += len; @@ -114,8 +110,8 @@ pub const NodeIterator = struct { return res[0]; } - pub fn token(self: *NodeIterator) std.meta.IntToEnumError!types.Token { - return std.meta.intToEnum(types.Token, self.readInt(u32)); + pub fn token(self: *NodeIterator) error{InvalidToken}!types.Token { + return std.enums.fromInt(types.Token, self.readInt(u32)) orelse error.InvalidToken; } pub fn stringAt(self: *NodeIterator, off: usize) []const u8 { @@ -170,21 +166,15 @@ allocator: ?Allocator, hdr: types.Header, buff: []const u8, -fn init( - allocator: ?Allocator, - reader: anytype, - args: anytype, - errors: anytype, - initBufferFunc: fn (?Allocator, types.Header, @TypeOf(args)) (Allocator.Error || @TypeOf(reader).NoEofError || errors)![]const u8, -) !Self { - const hdr = try reader.readStructBig(types.Header); - if (hdr.magic != types.magic) return error.InvalidMagic; - - const buff = try initBufferFunc(allocator, hdr, args); - errdefer { - if (allocator) |alloc| alloc.free(buff); +fn readHeader(bytes: *const [@sizeOf(types.Header)]u8) types.Header { + var hdr: types.Header = @bitCast(bytes.*); + if (builtin.cpu.arch.endian() != std.builtin.Endian.big) { + std.mem.byteSwapAllFields(types.Header, &hdr); } + return hdr; +} +fn finish(allocator: ?Allocator, hdr: types.Header, buff: []const u8) error{ Truncated, OverRead, InvalidToken }!Self { const buffSize = hdr.totalsize - @sizeOf(types.Header); if (buff.len < buffSize) return error.Truncated; if (buff.len > buffSize) return error.OverRead; @@ -198,41 +188,26 @@ fn init( } pub fn initBuffer(buff: []const u8) !Self { - var stream = std.io.fixedBufferStream(buff); - return try init(null, stream.reader(), stream, error{}, (struct { - fn func( - _: ?Allocator, - hdr: types.Header, - argStream: std.io.FixedBufferStream([]const u8), - ) (Allocator.Error || std.io.FixedBufferStream([]const u8).Reader.NoEofError)![]const u8 { - return argStream.buffer[argStream.pos..hdr.totalsize]; - } - }).func); + if (buff.len < @sizeOf(types.Header)) return error.Truncated; + const hdr = readHeader(buff[0..@sizeOf(types.Header)]); + if (hdr.magic != types.magic) return error.InvalidMagic; + if (buff.len < hdr.totalsize) return error.Truncated; + return finish(null, hdr, buff[@sizeOf(types.Header)..hdr.totalsize]); } -pub fn initReader(alloc: Allocator, reader: anytype) !Self { - return try init(alloc, reader, reader, error{StreamTooLong}, (struct { - fn func( - argAlloc: ?Allocator, - hdr: types.Header, - argReader: @TypeOf(reader), - ) (Allocator.Error || @TypeOf(reader).NoEofError || error{StreamTooLong})![]const u8 { - return try argReader.readAllAlloc(argAlloc.?, hdr.totalsize - @sizeOf(types.Header)); - } - }).func); +pub fn initReader(alloc: Allocator, reader: *std.Io.Reader) !Self { + const hdr = readHeader(try reader.takeArray(@sizeOf(types.Header))); + if (hdr.magic != types.magic) return error.InvalidMagic; + + const buff = try reader.readAlloc(alloc, hdr.totalsize - @sizeOf(types.Header)); + errdefer alloc.free(buff); + return finish(alloc, hdr, buff); } -pub fn initFile(alloc: Allocator, file: std.fs.File) !Self { - return try init(alloc, file.reader(), file, std.fs.File.ReadError || std.fs.File.MetadataError || error{FileTooBig}, (struct { - fn func( - argAlloc: ?Allocator, - _: types.Header, - argFile: std.fs.File, - ) (Allocator.Error || std.fs.File.Reader.NoEofError || std.fs.File.ReadError || std.fs.File.MetadataError || error{FileTooBig})![]const u8 { - const metadata = try argFile.metadata(); - return try argFile.readToEndAlloc(argAlloc.?, metadata.size() - @sizeOf(types.Header)); - } - }).func); +pub fn initFile(alloc: Allocator, io: std.Io, file: std.Io.File) !Self { + var buf: [4096]u8 = undefined; + var file_reader = file.reader(io, &buf); + return initReader(alloc, &file_reader.interface); } pub fn deinit(self: *const Self) void { @@ -337,3 +312,46 @@ pub fn findLoose(self: *const Self, path: []const []const u8) ![]const u8 { } return error.NotFound; } + +pub fn findAs(self: *const Self, comptime T: type, path: []const []const u8) !T { + if (T == bool) { + _ = self.find(path) catch |err| { + if (err == error.NotFound) return false; + return err; + }; + return true; + } + + return decode(T, try self.find(path)); +} + +fn decode(comptime T: type, value: []const u8) error{WrongSize}!T { + return switch (@typeInfo(T)) { + .int => |info| blk: { + if (info.signedness != .unsigned) @compileError("findAs only supports unsigned integers, got " ++ @typeName(T)); + const len = @divExact(info.bits, 8); + if (value.len != len) return error.WrongSize; + break :blk std.mem.readInt(T, value[0..len], .big); + }, + .pointer => |info| blk: { + if (info.size != .slice or info.child != u8) @compileError("findAs only supports []const u8 slices, got " ++ @typeName(T)); + const end = std.mem.indexOfScalar(u8, value, 0) orelse value.len; + break :blk value[0..end]; + }, + else => @compileError("findAs does not support " ++ @typeName(T)), + }; +} + +test "decode reads an unsigned cell big-endian" { + try std.testing.expectEqual(@as(u32, 0x989680), try decode(u32, &.{ 0x00, 0x98, 0x96, 0x80 })); + try std.testing.expectEqual(@as(u64, 0x1), try decode(u64, &.{ 0, 0, 0, 0, 0, 0, 0, 1 })); +} + +test "decode rejects a mismatched width" { + try std.testing.expectError(error.WrongSize, decode(u32, &.{ 0x00, 0x98 })); +} + +test "decode strips a trailing NUL from a string" { + try std.testing.expectEqualStrings("ok", try decode([]const u8, "ok\x00")); + try std.testing.expectEqualStrings("nonul", try decode([]const u8, "nonul")); +} diff --git a/dtree/types.zig b/dtree/types.zig index 1ea7d73..916ae59 100644 --- a/dtree/types.zig +++ b/dtree/types.zig @@ -2,7 +2,7 @@ const std = @import("std"); pub const magic: u32 = 0xd00dfeed; -pub const Header = packed struct { +pub const Header = extern struct { magic: u32, totalsize: u32, off_dt_struct: u32, @@ -14,31 +14,29 @@ pub const Header = packed struct { size_dt_strings: u32, size_dt_struct: u32, - pub fn format(self: Header, comptime _: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { - _ = options; - + pub fn format(self: Header, writer: *std.Io.Writer) std.Io.Writer.Error!void { try writer.writeAll(@typeName(Header)); - try writer.print("{{ .magic = 0x{x}, .totalsize = {}, .off_dt_struct = 0x{x}, .off_dt_strings = 0x{x}, .off_mem_rsvmap = 0x{x}, .version = {}, .last_comp_version = {}, .boot_cpuid_phys = {}, .size_dt_strings = {}, .size_dt_struct = {} }}", .{ + try writer.print("{{ .magic = 0x{x}, .totalsize = {d}, .off_dt_struct = 0x{x}, .off_dt_strings = 0x{x}, .off_mem_rsvmap = 0x{x}, .version = {d}, .last_comp_version = {d}, .boot_cpuid_phys = {d}, .size_dt_strings = {d}, .size_dt_struct = {d} }}", .{ self.magic, - std.fmt.fmtIntSizeDec(self.totalsize), + self.totalsize, self.off_dt_struct, self.off_dt_strings, self.off_mem_rsvmap, self.version, self.last_comp_version, self.boot_cpuid_phys, - std.fmt.fmtIntSizeDec(self.size_dt_strings), - std.fmt.fmtIntSizeDec(self.size_dt_struct), + self.size_dt_strings, + self.size_dt_struct, }); } }; -pub const ReserveEntry = packed struct { +pub const ReserveEntry = extern struct { address: u64, size: u64, }; -pub const Prop = packed struct { +pub const Prop = extern struct { len: u32, name: u32, }; @@ -50,3 +48,9 @@ pub const Token = enum(u32) { nop = 0x00000004, end = 0x00000009, }; + +test "on-disk layouts match the FDT spec" { + try std.testing.expectEqual(@as(usize, 40), @sizeOf(Header)); + try std.testing.expectEqual(@as(usize, 16), @sizeOf(ReserveEntry)); + try std.testing.expectEqual(@as(usize, 8), @sizeOf(Prop)); +} diff --git a/example.zig b/example.zig index 60444a5..3897777 100644 --- a/example.zig +++ b/example.zig @@ -1,28 +1,28 @@ const std = @import("std"); const dtree = @import("dtree"); -const alloc = std.heap.page_allocator; +pub fn main(init: std.process.Init) !void { + const io = init.io; + const gpa = init.gpa; -pub fn main() !void { - var args = try std.process.ArgIterator.initWithAllocator(alloc); + var args = try init.minimal.args.iterateAllocator(gpa); defer args.deinit(); - _ = args.next(); + _ = args.skip(); - const path = blk: { - const tmp = args.next() orelse return error.MissingArgument; - if (std.fs.path.isAbsolute(tmp)) break :blk try alloc.dupe(u8, tmp); + const path = args.next() orelse return error.MissingArgument; - const cwd = try std.process.getCwdAlloc(alloc); - defer alloc.free(cwd); - break :blk try std.fs.path.join(alloc, &.{ cwd, tmp }); - }; + const file = if (std.fs.path.isAbsolute(path)) + try std.Io.Dir.openFileAbsolute(io, path, .{}) + else + try std.Io.Dir.cwd().openFile(io, path, .{}); + defer file.close(io); - const file = try std.fs.openFileAbsolute(path, .{}); - defer file.close(); - - const fdt = try dtree.Reader.initFile(alloc, file); + const fdt = try dtree.Reader.initFile(gpa, io, file); defer fdt.deinit(); - try fdt.writeDts(std.io.getStdOut().writer()); + var stdout_buf: [4096]u8 = undefined; + var stdout = std.Io.File.stdout().writer(io, &stdout_buf); + try fdt.writeDts(&stdout.interface); + try stdout.interface.flush(); } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..b073635 --- /dev/null +++ b/flake.lock @@ -0,0 +1,63 @@ +{ + "nodes": { + "flakever": { + "locked": { + "lastModified": 1763450705, + "narHash": "sha256-TUSrRfT76OAXty9A4fXlOOfVfJGDglFQs06b8b+f5NY=", + "owner": "numinit", + "repo": "flakever", + "rev": "a69629e4133fbcdf3c7aae477bd6687bb19e0778", + "type": "github" + }, + "original": { + "owner": "numinit", + "repo": "flakever", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1780971357, + "narHash": "sha256-GPzGbD+uyVwyYD5fcO6cw4UakmrpcKmDGEUUWtNSBwg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4ea89ff69f570c228c6d5f0d18eae61ea5a27ef5", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flakever": "flakever", + "nixpkgs": "nixpkgs", + "treefmt-nix": "treefmt-nix" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780220602, + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..814faab --- /dev/null +++ b/flake.nix @@ -0,0 +1,89 @@ +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs"; + treefmt-nix = { + url = "github:numtide/treefmt-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + flakever.url = "github:numinit/flakever"; + }; + + outputs = + { + self, + nixpkgs, + treefmt-nix, + flakever, + ... + }@inputs: + let + inherit (nixpkgs) lib; + + nameValuePair = name: value: { inherit name value; }; + genAttrs = names: f: builtins.listToAttrs (map (n: nameValuePair n (f n)) names); + allSystems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; + + flakeverConfig = flakever.lib.mkFlakever { + inherit inputs; + + digits = [ + 1 + 2 + 2 + ]; + }; + + forAllSystems = + f: + genAttrs allSystems ( + system: + f { + inherit system; + pkgs = import nixpkgs { inherit system; }; + } + ); + + treefmtEval = forAllSystems ({ pkgs, ... }: treefmt-nix.lib.evalModule pkgs (import ./treefmt.nix)); + in + { + versionTemplate = "1.1pre--"; + + devShells = forAllSystems ( + { pkgs, ... }: + { + default = pkgs.mkShell { + name = "dtree-dev-shell"; + packages = with pkgs; [ + zig + ]; + }; + } + ); + + formatter = forAllSystems ({ system, ... }: treefmtEval.${system}.config.build.wrapper); + + checks = forAllSystems ( + { system, pkgs, ... }: + { + default = pkgs.stdenv.mkDerivation (finalAttrs: { + pname = "dtree"; + inherit (flakeverConfig) version; + + src = lib.cleanSource ./.; + + nativeBuildInputs = with pkgs; [ + zig + ]; + + doCheck = true; + }); + + formatting = treefmtEval.${system}.config.build.check self; + } + ); + }; +} diff --git a/test/dtb.zig b/test/dtb.zig new file mode 100644 index 0000000..76ae44e --- /dev/null +++ b/test/dtb.zig @@ -0,0 +1,54 @@ +const std = @import("std"); +const dtree = @import("dtree"); + +const blob = @embedFile("riscv-virt.dtb").*; + +fn parsed() dtree.Reader { + @setEvalBranchQuota(1_000_000); + return dtree.Reader.initBuffer(&blob) catch unreachable; +} + +test "comptime: bake a u32 cell into a constant" { + @setEvalBranchQuota(1_000_000); + const timebase = comptime try parsed().findAs(u32, &.{ "", "cpus", "timebase-frequency" }); + try std.testing.expectEqual(@as(u32, 0x989680), timebase); +} + +test "comptime: string has its trailing NUL stripped" { + @setEvalBranchQuota(1_000_000); + const model = comptime try parsed().findAs([]const u8, &.{ "", "model" }); + try std.testing.expectEqualStrings("riscv-virtio,qemu", model); +} + +test "comptime: bool reports presence without erroring" { + @setEvalBranchQuota(1_000_000); + try std.testing.expect(comptime try parsed().findAs(bool, &.{ "", "model" })); + try std.testing.expect(!(comptime try parsed().findAs(bool, &.{ "", "not-a-real-prop" }))); +} + +test "findAs is strict about integer width" { + @setEvalBranchQuota(1_000_000); + try std.testing.expectError(error.WrongSize, comptime parsed().findAs(u32, &.{ "", "model" })); +} + +test "runtime: the same parse path works without comptime" { + const fdt = try dtree.Reader.initBuffer(&blob); + try std.testing.expectEqual(@as(u32, 0x989680), try fdt.findAs(u32, &.{ "", "cpus", "timebase-frequency" })); + try std.testing.expectEqualStrings("riscv-virtio,qemu", try fdt.findAs([]const u8, &.{ "", "model" })); +} + +test "runtime: find returns the raw big-endian value" { + const fdt = try dtree.Reader.initBuffer(&blob); + const raw = try fdt.find(&.{ "", "cpus", "timebase-frequency" }); + try std.testing.expectEqualSlices(u8, &.{ 0x00, 0x98, 0x96, 0x80 }, raw); +} + +test "runtime: writeDts renders the tree" { + const fdt = try dtree.Reader.initBuffer(&blob); + var buf: [16 * 1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&buf); + try fdt.writeDts(&writer); + const out = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "cpus {") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "timebase-frequency = <") != null); +} diff --git a/test/riscv-virt.dtb b/test/riscv-virt.dtb new file mode 100644 index 0000000..b44c3be Binary files /dev/null and b/test/riscv-virt.dtb differ diff --git a/test/root.zig b/test/root.zig new file mode 100644 index 0000000..a803675 --- /dev/null +++ b/test/root.zig @@ -0,0 +1,3 @@ +test { + _ = @import("dtb.zig"); +} diff --git a/treefmt.nix b/treefmt.nix new file mode 100644 index 0000000..b2d6f6d --- /dev/null +++ b/treefmt.nix @@ -0,0 +1,9 @@ +{ lib, pkgs, ... }: +{ + projectRootFile = "flake.nix"; + + programs = { + nixfmt.enable = true; + zig.enable = true; + }; +}