danos/library/device/registry/device-registry.zig

344 lines
14 KiB
Zig

//! The device registry: parse `/system/configuration/devices.csv` into match rules and bind a
//! reported device to a driver. This is the data-driven replacement for the
//! device manager's three hand-written `switch` tables (`pciDriverForIdentity`,
//! `hidDriverFor`, `usbDriverForIdentity`); the registry is now **authoritative**
//! — a device that no row matches goes unbound (logged), never guessed.
//!
//! Pure logic: no hardware access, no syscalls, no allocator. `parse` fills a
//! caller-provided `[]Rule` whose string fields (`hid`, `driver`) are slices
//! *into the CSV source*, so the source buffer must outlive the rules (the
//! manager holds it in a static buffer for the life of the process — zero-copy).
//! That keeps this module freestanding and unit-testable with plain `zig test`.
//!
//! The file format (docs/device-driver-development/device-manager.md, and the
//! `/system/configuration/devices.csv` header itself): one rule per line, nine comma-separated
//! fields, `#` starts a comment (whole-line or trailing), blank lines ignored.
//!
//! bus, base, class, prog_if, vendor, device, subsystem, hid, driver
//!
//! `bus` is `pci`/`usb`/`acpi`; the numeric fields are hex (with or without a
//! `0x` prefix); `*` or an empty field is a wildcard (matches anything). For PCI
//! the class triple is base/subclass/prog-IF; for USB it is class/subclass/
//! protocol with vendor/device the idVendor/idProduct; ACPI matches on `hid`
//! (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
/// equals no real device's), so a typo fails safe rather than binding wrongly.
pub const Bus = enum {
pci,
usb,
acpi,
unknown,
pub fn fromToken(token: []const u8) Bus {
if (std.mem.eql(u8, token, "pci")) return .pci;
if (std.mem.eql(u8, token, "usb")) return .usb;
if (std.mem.eql(u8, token, "acpi")) return .acpi;
return .unknown;
}
};
/// A reported device's full identity, as the manager assembles it from a
/// `child_added`: the bus-native class triple plus the numeric ids the widened
/// ABI now carries, or the ACPI `_HID` string. Fields a given bus does not have
/// are zero / empty (a PCI function has no `hid`; an ACPI device has no vendor).
pub const Identity = struct {
bus: Bus,
base: u8 = 0,
subclass: u8 = 0,
prog_if: u8 = 0,
vendor: u16 = 0,
device: u16 = 0,
subsystem: u32 = 0,
hid: []const u8 = "",
};
/// One parsed registry row. A `null` field is a wildcard — it matches any value
/// and contributes nothing to specificity. String fields point into the CSV
/// source that was parsed (see the module doc).
pub const Rule = struct {
bus: Bus,
base: ?u8 = null,
subclass: ?u8 = null,
prog_if: ?u8 = null,
vendor: ?u16 = null,
device: ?u16 = null,
subsystem: ?u32 = null,
hid: ?[]const u8 = null,
driver: []const u8,
};
/// Specificity weights: how much each pinned field counts toward "most specific
/// wins". Doubling from the coarsest (`base`) so that each level outweighs *all*
/// coarser levels combined (1+2+4+8+16 = 31 < 32) — a rule that pins `device`
/// always beats any rule that does not, no matter how many coarse fields the
/// latter pins. `hid` and `device` share the top tier (the user's "hid and
/// device weigh heaviest"); they never co-occur, since `hid` is ACPI-only and
/// `device` is a PCI/USB numeric id.
const weight_base: u32 = 1;
const weight_subclass: u32 = 2;
const weight_prog_if: u32 = 4;
const weight_vendor: u32 = 8;
const weight_subsystem: u32 = 16;
const weight_device: u32 = 32;
const weight_hid: u32 = 32;
/// The outcome of `matchDriver`: the winning rule's driver path, its specificity,
/// and whether another rule tied it at that specificity. `ambiguous` is a
/// registry authoring error (two equally-specific rules claiming one device); the
/// manager logs it loudly and binds the first, so a shadowed rule is visible
/// rather than silently dropped.
pub const Match = struct {
driver: []const u8,
specificity: u32,
ambiguous: bool,
};
/// Whether `rule` matches `id`: same bus, and every pinned (non-wildcard) field
/// equal. `hid` compares as a string; the rest as integers.
fn matches(rule: Rule, id: Identity) bool {
if (rule.bus != id.bus) return false;
if (rule.base) |b| if (b != id.base) return false;
if (rule.subclass) |s| if (s != id.subclass) return false;
if (rule.prog_if) |p| if (p != id.prog_if) return false;
if (rule.vendor) |v| if (v != id.vendor) return false;
if (rule.device) |d| if (d != id.device) return false;
if (rule.subsystem) |s| if (s != id.subsystem) return false;
if (rule.hid) |h| if (!std.mem.eql(u8, h, id.hid)) return false;
return true;
}
/// The specificity score of a rule — the sum of the weights of its pinned fields.
fn specificity(rule: Rule) u32 {
var score: u32 = 0;
if (rule.base != null) score += weight_base;
if (rule.subclass != null) score += weight_subclass;
if (rule.prog_if != null) score += weight_prog_if;
if (rule.vendor != null) score += weight_vendor;
if (rule.device != null) score += weight_device;
if (rule.subsystem != null) score += weight_subsystem;
if (rule.hid != null) score += weight_hid;
return score;
}
/// Bind a reported device to a driver: of every rule that matches `id`, return
/// the most specific. `null` when nothing matches (the device goes unbound —
/// the authoritative registry does not guess). On an exact specificity tie the
/// first such rule in file order wins and `ambiguous` is set.
pub fn matchDriver(rules: []const Rule, id: Identity) ?Match {
var best: ?Match = null;
for (rules) |rule| {
if (!matches(rule, id)) continue;
const score = specificity(rule);
if (best) |current| {
if (score > current.specificity) {
best = .{ .driver = rule.driver, .specificity = score, .ambiguous = false };
} else if (score == current.specificity) {
// Two equally-specific rules claim this device — keep the first,
// flag the ambiguity for the manager to log.
best.?.ambiguous = true;
}
} else {
best = .{ .driver = rule.driver, .specificity = score, .ambiguous = false };
}
}
return best;
}
// --- parsing -----------------------------------------------------------------
/// What one CSV line parsed to. `malformed` is a non-comment, non-blank line the
/// parser could not read (wrong field count, unparsable number, empty driver) —
/// the manager counts these and logs, so a broken registry is loud, not silent.
const Line = union(enum) {
rule: Rule,
ignorable, // blank or comment
malformed,
};
/// The result of `parse`: how many rules landed in the caller's buffer, and how
/// many non-ignorable lines were malformed (for the manager to log). `truncated`
/// is set if there were more valid rules than the buffer could hold.
pub const ParseResult = struct {
count: usize,
malformed: usize,
truncated: bool,
};
/// 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.
fn parseHexField(comptime T: type, field: []const u8) !?T {
const token = std.mem.trim(u8, field, " \t");
if (token.len == 0 or std.mem.eql(u8, token, "*")) return null;
const digits = if (std.mem.startsWith(u8, token, "0x") or std.mem.startsWith(u8, token, "0X"))
token[2..]
else
token;
return try std.fmt.parseInt(T, digits, 16);
}
/// Parse a wildcard-or-string field (the `hid` column): `*`/empty → wildcard.
fn parseStringField(field: []const u8) ?[]const u8 {
const token = std.mem.trim(u8, field, " \t");
if (token.len == 0 or std.mem.eql(u8, token, "*")) return null;
return token;
}
/// 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 = csv.stripComment(line);
if (body.len == 0) return .ignorable;
// 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 = csv.fields(body);
while (it.next()) |field| {
if (count >= cols.len) return .malformed; // too many columns
cols[count] = field;
count += 1;
}
if (count != cols.len) return .malformed; // too few columns
const bus = Bus.fromToken(cols[0]);
if (bus == .unknown) return .malformed;
const driver = cols[8];
if (driver.len == 0) return .malformed;
return .{ .rule = .{
.bus = bus,
.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,
} };
}
/// Parse a whole `/system/configuration/devices.csv` into `out_rules`. The string fields of the
/// returned rules point into `source`, which must outlive them.
pub fn parse(source: []const u8, out_rules: []Rule) ParseResult {
var result: ParseResult = .{ .count = 0, .malformed = 0, .truncated = false };
var lines = std.mem.splitScalar(u8, source, '\n');
while (lines.next()) |line| {
switch (parseLine(line)) {
.ignorable => {},
.malformed => result.malformed += 1,
.rule => |rule| {
if (result.count >= out_rules.len) {
result.truncated = true;
continue;
}
out_rules[result.count] = rule;
result.count += 1;
},
}
}
return result;
}
// --- tests -------------------------------------------------------------------
const testing = std.testing;
// The worked example from the design: a specific virtio-gpu rule (pins vendor +
// device) and a generic display rule (class only) both match the virtio card;
// 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 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(text, &rules);
try testing.expectEqual(@as(usize, 2), parsed.count);
try testing.expectEqual(@as(usize, 0), parsed.malformed);
// The virtio-gpu function: display / other, vendor 1AF4 device 1050.
const virtio = matchDriver(rules[0..parsed.count], .{
.bus = .pci, .base = 0x03, .subclass = 0x80, .prog_if = 0x00,
.vendor = 0x1AF4, .device = 0x1050,
}).?;
try testing.expect(!virtio.ambiguous);
try testing.expectEqualStrings("/system/drivers/virtio-gpu", virtio.driver);
// A plain VGA adapter (display / VGA) still binds the generic display driver.
const vga = matchDriver(rules[0..parsed.count], .{
.bus = .pci, .base = 0x03, .subclass = 0x00, .prog_if = 0x00,
.vendor = 0x1234, .device = 0x1111,
}).?;
try testing.expectEqualStrings("/system/drivers/display", vga.driver);
}
test "no matching row leaves the device unbound" {
const text = "pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus\n";
var rules: [8]Rule = undefined;
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 1), parsed.count);
// An AHCI controller (mass storage / SATA / AHCI) has no row — unbound.
const unmatched = matchDriver(rules[0..parsed.count], .{
.bus = .pci, .base = 0x01, .subclass = 0x06, .prog_if = 0x01,
});
try testing.expect(unmatched == null);
}
test "acpi rows match on hid" {
const text =
\\acpi, *, *, *, *, *, *, PNP0303, /system/drivers/ps2-bus
\\acpi, *, *, *, *, *, *, PNP0F13, /system/drivers/ps2-bus
;
var rules: [8]Rule = undefined;
const parsed = parse(text, &rules);
try testing.expectEqual(@as(usize, 2), parsed.count);
const keyboard = matchDriver(rules[0..parsed.count], .{ .bus = .acpi, .hid = "PNP0303" }).?;
try testing.expectEqualStrings("/system/drivers/ps2-bus", keyboard.driver);
const nothing = matchDriver(rules[0..parsed.count], .{ .bus = .acpi, .hid = "PNP0A03" });
try testing.expect(nothing == null);
}
test "equally specific rules flag ambiguity" {
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(text, &rules);
const hit = matchDriver(rules[0..parsed.count], .{
.bus = .pci, .base = 0x03, .subclass = 0x00, .prog_if = 0x00,
}).?;
try testing.expect(hit.ambiguous);
try testing.expectEqualStrings("/system/drivers/display-a", hit.driver); // first wins
}
test "comments, blanks, and malformed lines" {
const text =
\\# a header comment
\\
\\pci, 0C, 03, 30, *, *, *, *, /system/drivers/usb-xhci-bus # trailing comment
\\pci, ZZ, 03, 30, *, *, *, *, /system/drivers/broken
\\pci, 03, 00, 00, *, *, *, *,
\\bogus-bus, *, *, *, *, *, *, *, /system/drivers/x
;
var rules: [8]Rule = undefined;
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);
try testing.expect(rules[0].hid == null); // trailing comment stripped, hid still wildcard
}