init: data-driven boot service list via /etc/init.csv

Replace init's hardcoded boot_services array with an authoritative,
human-readable service list read at boot, mirroring /etc/devices.csv. Each row
is a service binary path followed by its argv; startup order is file order,
shutdown the reverse. There is no hardcoded fallback — a missing file starts
nothing (the no-ramdisk isolation behavior).

- library/csv: shared CSV helpers (comment strip, field iteration) with unit
  tests; device-registry is refactored onto them so both /etc/*.csv files parse
  through one place.
- init reads /etc/init.csv into fixed-max static tables (the same pattern as the
  device registry) and passes each row's argv straight to spawnSupervised. This
  also makes boot-time modes (e.g. device-manager test-usb-restart) expressible
  as data rather than hardcoded.
- Diagnose mode (-Ddiagnose omits the display stack) becomes build-time file
  selection between etc/init.csv and etc/init-diagnose.csv, so init carries no
  comptime service logic; the diagnose build option is dropped from init.
- build.zig: csv module wired; /etc/init.csv bundled into the initrd; the
  device-registry tests move to a dedicated block since they now import csv.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJqSiLLchDUUCoXn5jsiwd
This commit is contained in:
2026-07-26 17:13:51 +01:00
co-authored by Claude Opus 4.8
parent 203528c8a7
commit 081ba1d74e
6 changed files with 235 additions and 77 deletions
+56
View File
@@ -0,0 +1,56 @@
//! Minimal CSV helpers shared by the `/etc/*.csv` config files — the device
//! registry (`/etc/devices.csv`) and the init service list (`/etc/init.csv`).
//! Freestanding, no allocator: returned fields are slices into the source line,
//! so the source must outlive them. `#` starts a comment (whole-line or trailing);
//! whitespace around a field is trimmed, so columns may be padded for alignment.
const std = @import("std");
/// Strip a trailing `#` comment and surrounding whitespace from one raw line.
/// A blank or comment-only line returns "" (length 0) — the caller's skip signal.
pub fn stripComment(raw: []const u8) []const u8 {
const body = if (std.mem.indexOfScalar(u8, raw, '#')) |hash| raw[0..hash] else raw;
return std.mem.trim(u8, body, " \t\r\n");
}
/// Iterate the comma-separated fields of a line body, each trimmed of spaces and
/// tabs. Build it from a `stripComment`ed body.
pub const Fields = struct {
inner: std.mem.SplitIterator(u8, .scalar),
/// The next field, trimmed, or null when the row is exhausted.
pub fn next(self: *Fields) ?[]const u8 {
const field = self.inner.next() orelse return null;
return std.mem.trim(u8, field, " \t");
}
};
pub fn fields(body: []const u8) Fields {
return .{ .inner = std.mem.splitScalar(u8, body, ',') };
}
// --- tests -------------------------------------------------------------------
const testing = std.testing;
test "stripComment trims and drops comments" {
try testing.expectEqualStrings("a, b", stripComment(" a, b # trailing\r\n"));
try testing.expectEqualStrings("", stripComment(" # whole-line comment"));
try testing.expectEqualStrings("", stripComment(" \t "));
try testing.expectEqualStrings("x", stripComment("x"));
}
test "fields splits and trims each column" {
var it = fields(stripComment("pci, 03 , 80 , /system/drivers/x # note"));
try testing.expectEqualStrings("pci", it.next().?);
try testing.expectEqualStrings("03", it.next().?);
try testing.expectEqualStrings("80", it.next().?);
try testing.expectEqualStrings("/system/drivers/x", it.next().?);
try testing.expect(it.next() == null);
}
test "a single field yields one column then null" {
var it = fields(stripComment("/system/services/input"));
try testing.expectEqualStrings("/system/services/input", it.next().?);
try testing.expect(it.next() == null);
}
+28 -33
View File
@@ -23,6 +23,7 @@
//! (e.g. "PNP0303") with the triple left blank. `driver` is a full ramdisk path.
const std = @import("std");
const csv = @import("csv");
/// Which bus a rule or a reported device belongs to. `unknown` is what an
/// unrecognised `bus` token parses to — such a rule never matches (its bus
@@ -168,12 +169,6 @@ pub const ParseResult = struct {
truncated: bool,
};
/// Strip a trailing `#` comment and surrounding whitespace from one raw line.
fn stripComment(raw: []const u8) []const u8 {
const body = if (std.mem.indexOfScalar(u8, raw, '#')) |hash| raw[0..hash] else raw;
return std.mem.trim(u8, body, " \t\r\n");
}
/// Parse one hex field into `T`, honouring `*`/empty as a wildcard (`null`) and
/// an optional `0x` prefix. Returns an error only for a genuinely unparsable
/// non-wildcard token, so the caller can mark the whole line malformed.
@@ -197,36 +192,36 @@ fn parseStringField(field: []const u8) ?[]const u8 {
/// Classify and (if a rule) parse one line. Split out from `parse` so it can be
/// unit-tested directly. `line` is the raw line including no newline.
fn parseLine(line: []const u8) Line {
const body = stripComment(line);
const body = csv.stripComment(line);
if (body.len == 0) return .ignorable;
// Nine comma-separated fields: bus, base, class, prog_if, vendor, device,
// subsystem, hid, driver.
var fields: [9][]const u8 = undefined;
// Nine comma-separated fields (csv.fields trims each): bus, base, class,
// prog_if, vendor, device, subsystem, hid, driver.
var cols: [9][]const u8 = undefined;
var count: usize = 0;
var it = std.mem.splitScalar(u8, body, ',');
var it = csv.fields(body);
while (it.next()) |field| {
if (count >= fields.len) return .malformed; // too many columns
fields[count] = field;
if (count >= cols.len) return .malformed; // too many columns
cols[count] = field;
count += 1;
}
if (count != fields.len) return .malformed; // too few columns
if (count != cols.len) return .malformed; // too few columns
const bus = Bus.fromToken(std.mem.trim(u8, fields[0], " \t"));
const bus = Bus.fromToken(cols[0]);
if (bus == .unknown) return .malformed;
const driver = std.mem.trim(u8, fields[8], " \t");
const driver = cols[8];
if (driver.len == 0) return .malformed;
return .{ .rule = .{
.bus = bus,
.base = parseHexField(u8, fields[1]) catch return .malformed,
.subclass = parseHexField(u8, fields[2]) catch return .malformed,
.prog_if = parseHexField(u8, fields[3]) catch return .malformed,
.vendor = parseHexField(u16, fields[4]) catch return .malformed,
.device = parseHexField(u16, fields[5]) catch return .malformed,
.subsystem = parseHexField(u32, fields[6]) catch return .malformed,
.hid = parseStringField(fields[7]),
.base = parseHexField(u8, cols[1]) catch return .malformed,
.subclass = parseHexField(u8, cols[2]) catch return .malformed,
.prog_if = parseHexField(u8, cols[3]) catch return .malformed,
.vendor = parseHexField(u16, cols[4]) catch return .malformed,
.device = parseHexField(u16, cols[5]) catch return .malformed,
.subsystem = parseHexField(u32, cols[6]) catch return .malformed,
.hid = parseStringField(cols[7]),
.driver = driver,
} };
}
@@ -262,13 +257,13 @@ const testing = std.testing;
// the specific one must win. And a plain VGA adapter still falls to the generic
// rule. This is the whole point of widening the ABI to carry vendor/device.
test "virtio device rule beats the generic display rule" {
const csv =
const text =
\\# bus, base, class, prog_if, vendor, device, subsystem, hid, driver
\\pci, 03, 00, 00, *, *, *, *, /system/drivers/display
\\pci, 03, 80, *, 1AF4, 1050, *, *, /system/drivers/virtio-gpu
;
var rules: [8]Rule = undefined;
const parsed = parse(csv, &rules);
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 2), parsed.count);
try testing.expectEqual(@as(usize, 0), parsed.malformed);
@@ -289,9 +284,9 @@ test "virtio device rule beats the generic display rule" {
}
test "no matching row leaves the device unbound" {
const csv = "pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus\n";
const text = "pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus\n";
var rules: [8]Rule = undefined;
const parsed = parse(csv, &rules);
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 1), parsed.count);
// An AHCI controller (mass storage / SATA / AHCI) has no row — unbound.
@@ -302,12 +297,12 @@ test "no matching row leaves the device unbound" {
}
test "acpi rows match on hid" {
const csv =
const text =
\\acpi, *, *, *, *, *, *, PNP0303, /system/drivers/ps2-bus
\\acpi, *, *, *, *, *, *, PNP0F13, /system/drivers/ps2-bus
;
var rules: [8]Rule = undefined;
const parsed = parse(csv, &rules);
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 2), parsed.count);
const keyboard = matchDriver(rules[0..parsed.count], .{ .bus = .acpi, .hid = "PNP0303" }).?;
@@ -317,12 +312,12 @@ test "acpi rows match on hid" {
}
test "equally specific rules flag ambiguity" {
const csv =
const text =
\\pci, 03, 00, 00, *, *, *, *, /system/drivers/display-a
\\pci, 03, 00, 00, *, *, *, *, /system/drivers/display-b
;
var rules: [8]Rule = undefined;
const parsed = parse(csv, &rules);
const parsed = parse(text, &rules);
const hit = matchDriver(rules[0..parsed.count], .{
.bus = .pci, .base = 0x03, .subclass = 0x00, .prog_if = 0x00,
}).?;
@@ -331,7 +326,7 @@ test "equally specific rules flag ambiguity" {
}
test "comments, blanks, and malformed lines" {
const csv =
const text =
\\# a header comment
\\
\\pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus # trailing comment
@@ -340,7 +335,7 @@ test "comments, blanks, and malformed lines" {
\\bogus-bus, *, *, *, *, *, *, *, /system/drivers/x
;
var rules: [8]Rule = undefined;
const parsed = parse(csv, &rules);
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 1), parsed.count); // only the xhci row is valid
try testing.expectEqual(@as(usize, 3), parsed.malformed); // bad hex, empty driver, bad bus
try testing.expectEqualStrings("/system/drivers/usb-xhci-bus", rules[0].driver);