Skip to main content

max / alloy

State the real constraint on applying a usbguard rule, and compose one usb.rs said the join from a screen row to a device usbguard acts on could not be written without a machine where the daemon had run, because allow-device takes only a daemon-assigned id. Measured against the image we ship: allow-device takes <id|rule|p-rule>, and generate-policy runs with no daemon at all, emitting rules keyed on fields this module already parses. The real constraint is a writable /sys. docs/STACK.md already said so, so two shipped files disagreed. Adds the composing layer that was called impossible: Scope, rule_for, and the inverse parse, behind the existing Enforcement gate. Rules are compared hash-free, since hash and parent-hash are SHA-256 over the USB descriptor blob and are not sysfs fields.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-01 15:16 UTC
Signed with PGP, not checked
Commit: fe01327ba69e376fe1de3f7e64dba1b98ec3021b
Parent: 02749a9
3 files changed, +486 insertions, -15 deletions
M docs/CONSOLE.md +25 -7
@@ -230,12 +230,30 @@
230 230 That stays true even though the image carries usbguard. The package is
231 231 installed on both profiles and its daemon is deliberately not enabled, so the
232 232 screen still has nothing to depend on and still reads the same files on a machine
233 - where the daemon never starts. When the action half lands it fronts
234 - `usbguard list-devices`, `allow-device` and `block-device` the way `alloy
235 - bluetooth` fronts `bluetoothctl`, which keeps the log pane's promise intact: the
236 - plug-then-prompt hook is `usbguard watch --exec`, a CLI with an argv, rather than
237 - the D-Bus interface the stock applets use. See docs/STACK.md for why the daemon
238 - ships disarmed.
233 + where the daemon never starts. The acting half fronts `usbguard list-devices`,
234 + `allow-device` and `block-device` the way `alloy bluetooth` fronts
235 + `bluetoothctl`, which keeps the log pane's promise intact: the plug-then-prompt
236 + hook is `usbguard watch --exec`, a CLI with an argv, rather than the D-Bus
237 + interface the stock applets use. See docs/STACK.md for why the daemon ships
238 + disarmed.
239 +
240 + **The rule half of that has landed, and it needs no daemon.** Measured on the
241 + image: `usbguard allow-device` takes `(<id> | <rule> | <partial-rule>)`, so a
242 + rule composed from sysfs is an accepted argument and not only the device id the
243 + daemon hands out, and `usbguard generate-policy` runs with no daemon at all and
244 + emits rules keyed on the same fields this screen already parses. So the detail
245 + pane shows the rule for the selected device wherever usbguard is installed,
246 + keyed on vendor and product id plus serial and never on the port, since
247 + `DeviceRulesWithPort=false` is the shipped default and a device that moves
248 + sockets has to keep matching. A device reporting no usable serial gets the
249 + model-wide rule under a label that says so, because a permanent rule keyed on
250 + sixteen `0xFF` bytes matches every device that ships the same filler. What still
251 + wants a machine is applying a rule, and what it wants there is a writable `/sys`
252 + rather than a daemon that has run before: with `/sys` read-only the daemon starts
253 + and then rejects every device with `SysFSDevice: authorized: Permission denied`.
254 + The `hash` and `parent-hash` a generated rule carries are over the USB descriptor
255 + blob and are not sysfs, so the composed form omits them, which is a rule usbguard
256 + takes because a rule matches on the attributes it states.
239 257
240 258 **Two tabs, because a charger and a keyboard are answered by different
241 259 subsystems.** The bus knows what enumerated and what each interface claims to be;
@@ -383,7 +401,7 @@
383 401 - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback, and `schemas/sway.schema` is the header that declares it). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, **shipped**, which is also what puts a schema-less app in the list at all: the catalog is built from `.schema` files, so sway reaches it through a header-only schema declaring `syntax = "sway"` rather than through a second catalog; (10) `AlloyTable` read-only, on the path to nothing: list-of-tables routes to the text pane.
384 402 - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. Neither `wlr-randr` nor kanshi is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode).
385 403 - **Shipped, and the only screen that is not a question.** `alloy install`'s credits page. It names each project Alloy ships, its SPDX identifier and its URL, off `crates/alloy/credits.toml` embedded in the binary. The install starts from this screen rather than from the summary: the last screen before a disk is repartitioned should be the one that says whose work is about to be written to it. Curated rather than generated, so it drifts by design and keeping it current is a release step (see [IMAGE.md](IMAGE.md)). Not a source offer and not a license-text viewer: the full texts stay in `/usr/share/licenses` on the installed system.
386 - - **Shipped as a view, with its enforcement half still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. usbguard itself is in both profiles and its daemon is not enabled, which is the deliberate state rather than an oversight: the package's stock policy is an empty rule file plus `ImplicitPolicyTarget=block`, so arming it before the policy exists deauthorizes the keyboard at boot. The keyboard gate is the part that makes deny-unknown safe: `usr/bin/alloy-usb-gate` drops enforcement whenever the machine has zero usable keyboards, continuously rather than at boot, on the client profile only. It counts `ID_INPUT_KEYBOARD` and not `ID_INPUT_KEY`, which is what keeps a power button from reading as a keyboard, and it reads the input subsystem rather than the USB bus, which is what makes the Framework's i8042 keyboard count without a special case. What is left is the policy itself: deny-unknown, with an activation the user performs from this screen.
404 + - **Shipped as a view, with the applying half of enforcement still ahead of it.** `alloy usb`, two tabs over sysfs: the bus, and the Type-C connectors. No usbguard dependency, no CLI fronted, and it works on an image that carries neither. usbguard itself is in both profiles and its daemon is not enabled, which is the deliberate state rather than an oversight: the package's stock policy is an empty rule file plus `ImplicitPolicyTarget=block`, so arming it before the policy exists deauthorizes the keyboard at boot. The keyboard gate is the part that makes deny-unknown safe: `usr/bin/alloy-usb-gate` drops enforcement whenever the machine has zero usable keyboards, continuously rather than at boot, on the client profile only. It counts `ID_INPUT_KEYBOARD` and not `ID_INPUT_KEY`, which is what keeps a power button from reading as a keyboard, and it reads the input subsystem rather than the USB bus, which is what makes the Framework's i8042 keyboard count without a special case. The rule half of enforcement has landed: `alloy usb` composes the usbguard rule for the selected device, keyed on vendor and product id plus serial and never on the port, wherever usbguard is installed. What is left is applying it and the policy itself: deny-unknown, with an activation the user performs from this screen.
387 405 - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). The first-boot flow (see [CONTINUITY.md](CONTINUITY.md)) shipped as `alloy setup`: two rows over the same `mesh` and `sync` backend seams, so there is no third enrollment path to keep in agreement with them. It survives the teardown mesh enrollment costs by refreshing on the first tick after a suspend rather than on its poll counter. The shell's post-suspend refresh arrives as one `tick` call, and a counter would swallow four out of five of them, leaving the row the user just enrolled reading "not enrolled" for several seconds. The screen appears once: the session autostart runs `alloy setup --if-first-boot`, and the console records that it asked in the same config store the theme lives in.
388 406 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows.
389 407 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
@@ -463,6 +463,8 @@
463 463
464 464 **The verbs are all in the CLI, so there is nothing to front but a CLI.** `usbguard list-devices`, `allow-device`, `block-device`, `append-rule`, and `generate-policy` cover the whole of it, and `allow-device --permanent` is exactly the "save this device" action the ruling describes. `usbguard watch --exec <path>` runs a program per plug event and is the plug-then-prompt hook, which means the console needs no D-Bus client: the same correction already made for `alloy bluetooth`, and it holds here for the same reason, that the log pane can only show an argv if there was one. `DeviceRulesWithPort=false` is already the default, so a permanent rule is keyed on the device rather than the socket it was in, which is what the ruling asks for and what a device that moves ports requires.
465 465
466 + Measured against the image rather than read off the manual. `usbguard allow-device --help` states `(<id> | <rule> | <partial-rule>)`, so a rule composed from sysfs is an accepted argument and not only the device id the daemon hands out, and `usbguard generate-policy` runs in a container with no daemon at all, exits 0, and emits rules keyed on id, serial, name, via-port and with-interface. The join from a console row to a device usbguard acts on is therefore a pure function over fields the console already reads, and `alloy usb` composes it today. What needs a machine is applying a rule, and what it needs there is a writable `/sys`: with `/sys` mounted read-only the daemon starts and then rejects every device with `SysFSDevice: authorized: Permission denied`, and the verbs that answer from the device list fail behind it. Two fields of a generated rule are not composable from sysfs, `hash` and `parent-hash`, which are over the USB descriptor blob. A rule matches on the attributes it states, so the hash-free form is a rule usbguard takes.
467 +
466 468 Rejected: **shipping the package on `client` only.** A server-profile machine has a USB bus, and the ruling gives that profile the stricter half of the policy precisely because it has no keyboard to lock out and its recovery path is a provider console. It needs this more than a laptop does, not less.
467 469
468 470 Rejected: **enabling the unit now and writing the policy later.** It is one line and it would work on the bench for as long as the bench machine has a PS/2 keyboard, then take out the first machine that does not. There is no partial credit available here: an image that boots armed with an empty policy is worse than one with no usbguard at all, because the second is merely unprotected and the first is bricked.
@@ -71,14 +71,28 @@
71 71 //! than silence. A reader who can see that the image carries usbguard, and is
72 72 //! told nothing about it, can reasonably conclude the bus is being policed.
73 73 //!
74 - //! The acting half is not here yet, and the reason is a measurement rather than
75 - //! a plan. On usbguard-1.1.4 every verb except `generate-policy` goes over the
76 - //! daemon's IPC socket, and `allow-device` is addressed by the device id the
77 - //! daemon assigns in `list-devices` — not by anything in sysfs. So the join from
78 - //! a row on this screen to a device usbguard will act on cannot be written
79 - //! against a machine where the daemon has never run, which is every machine
80 - //! this has been developed on. It ships with the bench tests, against a real
81 - //! armed daemon, rather than as a guess that reads as a fact.
74 + //! The acting half is a rule and a daemon, and only the second of the two needs
75 + //! a live machine. On usbguard-1.1.4 `allow-device` takes `<id|rule|p-rule>`, so
76 + //! a rule composed from sysfs is an accepted argument and not only the device id
77 + //! the daemon hands out in `list-devices`. Measured against the alloy image:
78 + //! `usbguard generate-policy` runs with no daemon at all, exits 0, and emits
79 + //! rules keyed on id, serial, name, via-port and with-interface, every one of
80 + //! which this module already parses out of sysfs. So [`rule_for`] composes the
81 + //! rule here as a pure function and `listing::parse_listed` reads the same
82 + //! grammar back.
83 + //!
84 + //! What wants a machine is applying one, and the constraint there is a writable
85 + //! `/sys` rather than a daemon that has run before. In a privileged container
86 + //! with `/sys` mounted read-only the daemon starts and then rejects every device
87 + //! with `SysFSDevice: authorized: Permission denied`, and the verbs that answer
88 + //! from the device list fail behind it. That is a write permission, so applying
89 + //! a rule is bench work against a real armed machine and composing one is not.
90 + //!
91 + //! Two fields of a `generate-policy` line are not composed here. `hash` and
92 + //! `parent-hash` are over the USB descriptor blob, which this module never
93 + //! reads, and a usbguard rule matches on the attributes it states rather than
94 + //! demanding the ones it omits. So [`rule_for`] emits the hash-free form, and
95 + //! anything comparing it against a generated line compares the hash-free part.
82 96 //!
83 97 //! The keyboard gate is `usr/bin/alloy-usb-gate`, which is the clause that makes deny-unknown safe to arm at all: it drops
84 98 //! enforcement whenever the machine has zero usable keyboards. It is not this
@@ -90,6 +104,7 @@
90 104 //!
91 105 //! <!-- wiki: alloy-console -->
92 106
107 + use std::fmt::Write as _;
93 108 use std::path::Path;
94 109
95 110 use alloy_tui::keys::Action;
@@ -821,6 +836,16 @@
821 836 }
822 837
823 838 impl Enforcement {
839 + /// Whether a composed rule has anywhere to go on this machine.
840 + ///
841 + /// False only where usbguard is absent. Armed, a rule goes in over IPC;
842 + /// unarmed, the rule set is a file root can append to, so both installed
843 + /// states can take one. Offering a string that nothing on the machine can
844 + /// consume is worse than offering nothing.
845 + pub(crate) const fn takes_rules(self) -> bool {
846 + !matches!(self, Self::Absent)
847 + }
848 +
824 849 /// The one-line answer for the status bar.
825 850 pub(crate) fn line(self) -> (Severity, &'static str) {
826 851 match self {
@@ -890,6 +915,288 @@
890 915 })
891 916 }
892 917
918 + // ---- composing rules ----
919 +
920 + /// How wide a rule composed from an [`Attachment`] is allowed to reach.
921 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
922 + pub(crate) enum Scope {
923 + /// This device and no other: id plus serial. The only form a permanent rule
924 + /// takes, because it is the only one that names one piece of hardware.
925 + Device,
926 + /// Every device of this model: id and interfaces, no serial. What is left
927 + /// for a device that reports no usable serial, and wider than the reader is
928 + /// likely to assume, which is why it is a separate word rather than a
929 + /// silent fallback.
930 + Model,
931 + }
932 +
933 + impl Scope {
934 + /// The widest scope this device can be keyed at.
935 + pub(crate) const fn for_device(device: &Attachment) -> Self {
936 + if device.serial.is_some() {
937 + Self::Device
938 + } else {
939 + Self::Model
940 + }
941 + }
942 +
943 + /// The detail-pane label, which is where the width has to be said out loud.
944 + pub(crate) const fn label(self) -> &'static str {
945 + match self {
946 + Self::Device => "rule",
947 + Self::Model => "rule (model)",
948 + }
949 + }
950 + }
951 +
952 + /// The usbguard rule for an attachment, or `None` when the scope has nothing to
953 + /// key on.
954 + ///
955 + /// Keyed on vendor and product id plus the serial, never on the port. usbguard
956 + /// ships `DeviceRulesWithPort=false`, and a rule carrying `via-port` stops
957 + /// matching the moment the device is moved to another socket, which is the
958 + /// opposite of what remembering a device promises.
959 + ///
960 + /// [`Scope::Device`] refuses a device with no serial rather than widening to the
961 + /// model behind the user's back. What counts as a serial is [`serial_of`]:
962 + /// sixteen `0xFF` bytes are the filler on this desk, and a permanent rule keyed
963 + /// on filler matches every device that ships the same filler.
964 + pub(crate) fn rule_for(device: &Attachment, scope: Scope) -> Option<String> {
965 + let serial = match scope {
966 + Scope::Device => Some(device.serial.as_deref()?),
967 + Scope::Model => None,
968 + };
969 + let interfaces: Vec<String> = device.interfaces.iter().map(Interface::triple).collect();
970 + Some(rule_line(
971 + "allow",
972 + &device.vendor_id,
973 + &device.product_id,
974 + serial,
975 + &device.product,
976 + &interfaces,
977 + ))
978 + }
979 +
980 + impl Interface {
981 + /// The `class:subclass:protocol` triple usbguard writes.
982 + ///
983 + /// Re-formatted rather than passed through: sysfs is the source of all
984 + /// three and prints them as two hex digits today, and a rule that renders
985 + /// one of them as `1` matches nothing.
986 + fn triple(&self) -> String {
987 + format!(
988 + "{:02x}:{:02x}:{:02x}",
989 + self.class.0,
990 + class_of(&self.subclass).0,
991 + class_of(&self.protocol).0
992 + )
993 + }
994 + }
995 +
996 + /// The one place a rule string is spelled.
997 + ///
998 + /// Attribute order follows `usbguard generate-policy` so a composed rule and a
999 + /// generated one can be compared as text. An attribute with nothing to say is
1000 + /// left out rather than written empty, because a rule matches on what it states
1001 + /// and `serial ""` is a demand for an empty serial rather than a wildcard.
1002 + fn rule_line(
1003 + target: &str,
1004 + vendor_id: &str,
1005 + product_id: &str,
1006 + serial: Option<&str>,
1007 + name: &str,
1008 + interfaces: &[String],
1009 + ) -> String {
1010 + let mut rule = format!("{target} id {vendor_id}:{product_id}");
1011 + if let Some(serial) = serial {
1012 + let _ = write!(rule, " serial {}", quoted(serial));
1013 + }
1014 + if !name.is_empty() {
1015 + let _ = write!(rule, " name {}", quoted(name));
1016 + }
1017 + match interfaces {
1018 + [] => {}
1019 + [only] => {
1020 + let _ = write!(rule, " with-interface {only}");
1021 + }
1022 + many => {
1023 + let _ = write!(rule, " with-interface {{ {} }}", many.join(" "));
1024 + }
1025 + }
1026 + rule
1027 + }
1028 +
1029 + /// A rule-language string literal.
1030 + ///
1031 + /// The grammar quotes with `"` and escapes with a backslash, so a product name
1032 + /// carrying either has to be escaped or the rule ends early and usbguard reads
1033 + /// the rest of the name as attributes.
1034 + fn quoted(value: &str) -> String {
1035 + let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
1036 + format!("\"{escaped}\"")
1037 + }
1038 +
1039 + /// Reading `usbguard list-devices` back.
1040 + ///
1041 + /// The inverse of [`rule_for`]: the daemon prints its own device id and then the
1042 + /// same rule grammar `generate-policy` writes, and this reads it into the fields
1043 + /// this screen is keyed on. Nothing here spawns usbguard yet, since every verb
1044 + /// but `generate-policy` needs the daemon and no machine has run one, so the
1045 + /// allow comes off with the verb that calls it.
1046 + mod listing {
1047 + #![allow(dead_code)]
1048 +
1049 + use super::{rule_line, serial_of};
1050 +
1051 + /// One line of `usbguard list-devices`.
1052 + ///
1053 + /// The daemon prints its own device id, then the same rule grammar
1054 + /// `generate-policy` writes. Both halves are wanted: the id is what the other
1055 + /// verbs take, and the rule body is how a listed device is matched back to a row
1056 + /// on this screen.
1057 + #[derive(Debug, Clone, PartialEq, Eq)]
1058 + pub(crate) struct Listed {
1059 + /// The daemon's device id, which is what `allow-device <id>` addresses.
1060 + pub(crate) id: u32,
1061 + /// `allow`, `block` or `reject`.
1062 + pub(crate) target: String,
1063 + pub(crate) vendor_id: String,
1064 + pub(crate) product_id: String,
1065 + /// Held through [`serial_of`], so the `serial ""` a hub reports arrives as
1066 + /// `None` here and cannot key a permanent rule by accident.
1067 + pub(crate) serial: Option<String>,
1068 + pub(crate) name: String,
1069 + /// `class:subclass:protocol`, one entry per interface.
1070 + pub(crate) interfaces: Vec<String>,
1071 + }
1072 +
1073 + impl Listed {
1074 + /// The hash-free rule for this device, in the form [`rule_for`] emits.
1075 + pub(crate) fn rule(&self) -> String {
1076 + rule_line(
1077 + &self.target,
1078 + &self.vendor_id,
1079 + &self.product_id,
1080 + self.serial.as_deref(),
1081 + &self.name,
1082 + &self.interfaces,
1083 + )
1084 + }
1085 + }
1086 +
1087 + /// Parse `usbguard list-devices` output.
1088 + ///
1089 + /// A line that does not parse is dropped rather than reported. The daemon
1090 + /// prefixes warnings to the same stream, so a strict parse would turn a noisy
1091 + /// but working daemon into an empty list.
1092 + pub(crate) fn parse_listed(output: &str) -> Vec<Listed> {
1093 + output.lines().filter_map(parse_listed_line).collect()
1094 + }
1095 +
1096 + fn parse_listed_line(line: &str) -> Option<Listed> {
1097 + let (id, rule) = line.split_once(':')?;
1098 + let id = id.trim().parse().ok()?;
1099 + let mut scan = Scan::new(rule);
1100 + let target = scan.word()?.to_string();
1101 + if !matches!(target.as_str(), "allow" | "block" | "reject") {
1102 + return None;
1103 + }
1104 + let mut listed = Listed {
1105 + id,
1106 + target,
1107 + vendor_id: String::new(),
1108 + product_id: String::new(),
1109 + serial: None,
1110 + name: String::new(),
1111 + interfaces: Vec::new(),
1112 + };
1113 + while let Some(attribute) = scan.word() {
1114 + match attribute {
1115 + "id" => {
1116 + let (vendor, product) = scan.word()?.split_once(':')?;
1117 + listed.vendor_id = vendor.to_string();
1118 + listed.product_id = product.to_string();
1119 + }
1120 + "serial" => listed.serial = scan.quoted().as_deref().and_then(serial_of),
1121 + "name" => listed.name = scan.quoted().unwrap_or_default(),
1122 + "with-interface" => listed.interfaces = scan.interfaces(),
1123 + // `hash`, `parent-hash`, `via-port` and `with-connect-type` are the
1124 + // rest of the grammar. Their values are consumed so the scan stays
1125 + // aligned on attribute boundaries, and dropped because nothing on
1126 + // this screen is keyed on them.
1127 + _ => {
1128 + let _ = scan.quoted();
1129 + }
1130 + }
1131 + }
1132 + if listed.vendor_id.is_empty() {
1133 + return None;
1134 + }
1135 + Some(listed)
1136 + }
1137 +
1138 + /// A cursor over one rule's text.
1139 + ///
1140 + /// Whitespace splitting is not enough on its own: a name is a quoted string
1141 + /// with spaces in it, and an interface list is braced.
1142 + struct Scan<'a> {
1143 + rest: &'a str,
1144 + }
1145 +
1146 + impl<'a> Scan<'a> {
1147 + fn new(rest: &'a str) -> Self {
1148 + Self {
1149 + rest: rest.trim_start(),
1150 + }
1151 + }
1152 +
1153 + /// The next bare word.
1154 + fn word(&mut self) -> Option<&'a str> {
1155 + if self.rest.is_empty() {
1156 + return None;
1157 + }
1158 + let end = self.rest.find(' ').unwrap_or(self.rest.len());
1159 + let (word, rest) = self.rest.split_at(end);
1160 + self.rest = rest.trim_start();
1161 + Some(word)
1162 + }
1163 +
1164 + /// The next quoted string, with its escapes resolved.
1165 + fn quoted(&mut self) -> Option<String> {
1166 + let body = self.rest.strip_prefix('"')?;
1167 + let mut value = String::new();
1168 + let mut chars = body.char_indices();
1169 + while let Some((at, c)) = chars.next() {
1170 + match c {
1171 + '\\' => value.push(chars.next()?.1),
1172 + '"' => {
1173 + self.rest = body[at + 1..].trim_start();
1174 + return Some(value);
1175 + }
1176 + _ => value.push(c),
1177 + }
1178 + }
1179 + None
1180 + }
1181 +
1182 + /// A `with-interface` value, which is one triple or a braced list of them.
1183 + fn interfaces(&mut self) -> Vec<String> {
1184 + let Some(body) = self.rest.strip_prefix('{') else {
1185 + return self
1186 + .word()
1187 + .map(|one| vec![one.to_string()])
1188 + .unwrap_or_default();
1189 + };
1190 + let Some(end) = body.find('}') else {
1191 + return Vec::new();
1192 + };
1193 + let list = body[..end].split_whitespace().map(str::to_string).collect();
1194 + self.rest = body[end + 1..].trim_start();
1195 + list
1196 + }
1197 + }
1198 + }
1199 +
893 1200 // ---- the view ----
894 1201
895 1202 /// Which list `alloy usb` is showing.
@@ -1102,6 +1409,16 @@
1102 1409 ),
1103 1410 ),
1104 1411 ];
1412 + // The rule the enforcement half would write for this device, shown
1413 + // only where something could take it. It is the whole of what this
1414 + // screen can say about a device without a daemon, and the scope is in
1415 + // the label because a model-wide rule reads as a device rule otherwise.
1416 + if self.enforcement.takes_rules() {
1417 + let scope = Scope::for_device(device);
1418 + if let Some(rule) = rule_for(device, scope) {
1419 + lines.push(detail(theme, scope.label(), rule));
1420 + }
1421 + }
1105 1422 if device.unclaimed() > 0 {
1106 1423 lines.push(detail(
1107 1424 theme,
@@ -1823,4 +2140,230 @@
1823 2140 );
1824 2141 }
1825 2142 }
2143 +
2144 + // ---- composing rules ----
2145 +
2146 + // The webcam as `usbguard generate-policy` writes it on fw13, measured in a
2147 + // container with no daemon running. What is compared is the hash-free part:
2148 + // `hash` and `parent-hash` are over the descriptor blob and no sysfs file
2149 + // carries them, so a test demanding them would fail on every machine.
2150 + const WEBCAM_RULE: &str = concat!(
2151 + r#"allow id 32ac:001c serial "FRANJBCHA1430206MK" "#,
2152 + r#"name "Laptop Webcam Module (2nd Gen)" "#,
2153 + "with-interface { 0e:01:01 0e:02:01 0e:02:01 0e:02:01 0e:02:01 ",
2154 + "0e:02:01 0e:02:01 0e:02:01 0e:02:01 fe:01:01 }",
2155 + );
2156 +
2157 + fn webcam(case: &str) -> Attachment {
2158 + let root = scratch(case);
2159 + dir(
2160 + &root,
2161 + "3-1.5",
2162 + &[
2163 + ("idVendor", "32ac"),
2164 + ("idProduct", "001c"),
2165 + ("manufacturer", "Framework"),
2166 + ("product", "Laptop Webcam Module (2nd Gen)"),
2167 + ("serial", "FRANJBCHA1430206MK"),
2168 + ("speed", "480"),
2169 + ("rx_lanes", "1"),
2170 + ("tx_lanes", "1"),
2171 + ("bDeviceClass", "ef"),
2172 + ("removable", "fixed"),
2173 + ("authorized", "1"),
2174 + ("bMaxPower", "500mA"),
2175 + ],
2176 + );
2177 + let classes = [
2178 + ("0e", "01", "01"),
2179 + ("0e", "02", "01"),
2180 + ("0e", "02", "01"),
2181 + ("0e", "02", "01"),
2182 + ("0e", "02", "01"),
2183 + ("0e", "02", "01"),
2184 + ("0e", "02", "01"),
2185 + ("0e", "02", "01"),
2186 + ("0e", "02", "01"),
2187 + ("fe", "01", "01"),
2188 + ];
2189 + for (number, (class, subclass, protocol)) in classes.iter().enumerate() {
2190 + dir(
2191 + &root,
2192 + &format!("3-1.5:1.{number}"),
2193 + &[
2194 + ("bInterfaceClass", class),
2195 + ("bInterfaceSubClass", subclass),
2196 + ("bInterfaceProtocol", protocol),
2197 + ],
2198 + );
2199 + }
2200 + attachments_in(&root).pop().unwrap()
2201 + }
2202 +
2203 + // The measurement the module note rests on: a rule built from sysfs alone is
2204 + // the rule usbguard would have generated, minus the two fields sysfs does
2205 + // not hold.
2206 + #[test]
2207 + fn a_rule_built_from_sysfs_matches_the_hash_free_part_of_a_generated_one() {
2208 + let device = webcam("rule-webcam");
2209 + assert_eq!(
2210 + rule_for(&device, Scope::Device).as_deref(),
2211 + Some(WEBCAM_RULE)
2212 + );
2213 + }
2214 +
2215 + // `DeviceRulesWithPort=false` is the shipped default and the ruling asks for
2216 + // the device rather than the socket, so a device that moves ports keeps
2217 + // matching.
2218 + #[test]
2219 + fn a_permanent_rule_is_never_keyed_on_the_port() {
2220 + let device = webcam("rule-port");
2221 + let rule = rule_for(&device, Scope::Device).unwrap();
2222 + assert!(!rule.contains("via-port"), "{rule}");
2223 + assert!(!rule.contains(&device.address), "{rule}");
2224 + }
2225 +
2226 + // The garbage serial again, one layer up. The MiniFuse reports sixteen
2227 + // `0xFF` bytes, so it has nothing to key one device on, and the answer is
2228 + // no permanent rule rather than a rule that matches every MiniFuse ever
2229 + // made without saying so.
2230 + #[test]
2231 + fn a_device_with_no_usable_serial_gets_no_permanent_rule() {
2232 + let root = scratch("rule-filler");
2233 + dir(
2234 + &root,
2235 + "3-1.1.2",
2236 + &[
2237 + ("idVendor", "1c75"),
2238 + ("idProduct", "af90"),
2239 + ("manufacturer", "ARTURIA"),
2240 + ("product", "MiniFuse 2"),
2241 + ("serial", &"\u{ff}".repeat(16)),
2242 + ("speed", "480"),
2243 + ("bDeviceClass", "ef"),
2244 + ],
2245 + );
2246 + dir(
2247 + &root,
2248 + "3-1.1.2:1.0",
2249 + &[
2250 + ("bInterfaceClass", "01"),
2251 + ("bInterfaceSubClass", "01"),
2252 + ("bInterfaceProtocol", "20"),
2253 + ],
2254 + );
2255 + let device = attachments_in(&root).pop().unwrap();
2256 +
2257 + assert_eq!(Scope::for_device(&device), Scope::Model);
2258 + assert_eq!(rule_for(&device, Scope::Device), None);
2259 + assert_eq!(
2260 + rule_for(&device, Scope::Model).as_deref(),
2261 + Some(r#"allow id 1c75:af90 name "MiniFuse 2" with-interface 01:01:20"#)
2262 + );
2263 + // And the wider scope says so on the row rather than in a doc comment.
2264 + assert_eq!(Scope::Model.label(), "rule (model)");
2265 + }
2266 +
2267 + // A name carrying the grammar's own punctuation. Unescaped, the rule ends
2268 + // at the first inner quote and usbguard reads the rest of the name as
2269 + // attributes.
2270 + #[test]
2271 + fn a_name_carrying_a_quote_is_escaped_rather_than_ending_the_rule() {
2272 + let root = scratch("rule-quote");
2273 + dir(
2274 + &root,
2275 + "3-2",
2276 + &[
Lines truncated