| 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 |
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 |
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 |
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 |
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 |
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 |
+ |
&[
|