| 9 |
9 |
|
use std::path::Path;
|
| 10 |
10 |
|
|
| 11 |
11 |
|
use alloy_tui::Severity;
|
|
12 |
+ |
use anyhow::{Result, bail};
|
| 12 |
13 |
|
|
| 13 |
14 |
|
use super::model::{Attachment, Interface, class_of};
|
| 14 |
15 |
|
use crate::cli::{CommandLog, Invocation};
|
| 254 |
255 |
|
///
|
| 255 |
256 |
|
/// The inverse of [`rule_for`]: the daemon prints its own device id and then the
|
| 256 |
257 |
|
/// same rule grammar `generate-policy` writes, and this reads it into the fields
|
| 257 |
|
- |
/// this screen is keyed on. Nothing here spawns usbguard yet, since every verb
|
| 258 |
|
- |
/// but `generate-policy` needs the daemon and no machine has run one, so the
|
| 259 |
|
- |
/// allow comes off with the verb that calls it.
|
|
258 |
+ |
/// this screen is keyed on. The id is the whole reason this exists — it is what
|
|
259 |
+ |
/// [`act`] addresses, and the daemon is the only thing that knows it.
|
| 260 |
260 |
|
mod listing {
|
| 261 |
|
- |
#![allow(dead_code)]
|
| 262 |
|
- |
|
|
261 |
+ |
// Only [`Listed::rule`] wants it, and that is a test-only assertion.
|
|
262 |
+ |
#[cfg(test)]
|
| 263 |
263 |
|
use super::rule_line;
|
| 264 |
264 |
|
use crate::usb::bus::serial_of;
|
| 265 |
265 |
|
|
| 272 |
272 |
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 273 |
273 |
|
pub(super) struct Listed {
|
| 274 |
274 |
|
/// The daemon's device id, which is what `allow-device <id>` addresses.
|
|
275 |
+ |
///
|
|
276 |
+ |
/// Assigned by the running daemon and meaningful only to it. Measured on
|
|
277 |
+ |
/// fw12: a freshly started daemon numbered seven devices `8` through
|
|
278 |
+ |
/// `14`, so the id is neither one-based nor derivable from anything on
|
|
279 |
+ |
/// this side, and it does not survive the daemon restarting. Every
|
|
280 |
+ |
/// action therefore re-reads the listing immediately before it acts
|
|
281 |
+ |
/// rather than remembering an id from the last refresh.
|
| 275 |
282 |
|
pub(super) id: u32,
|
| 276 |
283 |
|
/// `allow`, `block` or `reject`.
|
| 277 |
284 |
|
pub(super) target: String,
|
| 283 |
290 |
|
pub(super) name: String,
|
| 284 |
291 |
|
/// `class:subclass:protocol`, one entry per interface.
|
| 285 |
292 |
|
pub(super) interfaces: Vec<String>,
|
|
293 |
+ |
/// The kernel address the daemon saw this device at, which is the join
|
|
294 |
+ |
/// back to a row on this screen. See [`super::listed_for`].
|
|
295 |
+ |
pub(super) via_port: Option<String>,
|
| 286 |
296 |
|
}
|
| 287 |
297 |
|
|
| 288 |
298 |
|
impl Listed {
|
| 289 |
299 |
|
/// The hash-free rule for this device, in the form [`rule_for`](super::rule_for) emits.
|
|
300 |
+ |
///
|
|
301 |
+ |
/// Test-only. Nothing on the screen shows the daemon's rule — the
|
|
302 |
+ |
/// detail pane composes its own from sysfs, which is what lets it say
|
|
303 |
+ |
/// something on a machine with no daemon at all. What this is for is
|
|
304 |
+ |
/// the assertion that the parse keeps every field that rule is built
|
|
305 |
+ |
/// from, so a listing and a composed rule can be compared as text.
|
|
306 |
+ |
#[cfg(test)]
|
| 290 |
307 |
|
pub(super) fn rule(&self) -> String {
|
| 291 |
308 |
|
rule_line(
|
| 292 |
309 |
|
&self.target,
|
| 324 |
341 |
|
serial: None,
|
| 325 |
342 |
|
name: String::new(),
|
| 326 |
343 |
|
interfaces: Vec::new(),
|
|
344 |
+ |
via_port: None,
|
| 327 |
345 |
|
};
|
| 328 |
346 |
|
while let Some(attribute) = scan.word() {
|
| 329 |
347 |
|
match attribute {
|
| 335 |
353 |
|
"serial" => listed.serial = scan.quoted().as_deref().and_then(serial_of),
|
| 336 |
354 |
|
"name" => listed.name = scan.quoted().unwrap_or_default(),
|
| 337 |
355 |
|
"with-interface" => listed.interfaces = scan.interfaces(),
|
| 338 |
|
- |
// `hash`, `parent-hash`, `via-port` and `with-connect-type` are the
|
| 339 |
|
- |
// rest of the grammar. Their values are consumed so the scan stays
|
|
356 |
+ |
"via-port" => listed.via_port = scan.quoted(),
|
|
357 |
+ |
// `hash`, `parent-hash` and `with-connect-type` are the rest of
|
|
358 |
+ |
// the grammar. Their values are consumed so the scan stays
|
| 340 |
359 |
|
// aligned on attribute boundaries, and dropped because nothing on
|
| 341 |
360 |
|
// this screen is keyed on them.
|
| 342 |
361 |
|
_ => {
|
| 412 |
431 |
|
}
|
| 413 |
432 |
|
}
|
| 414 |
433 |
|
|
|
434 |
+ |
// ---- acting on a device ----
|
|
435 |
+ |
|
|
436 |
+ |
/// What the console can do to a device the daemon is holding.
|
|
437 |
+ |
///
|
|
438 |
+ |
/// Three verbs rather than two plus a flag, because "save this device" is a
|
|
439 |
+ |
/// different sentence from "allow it now" to the person pressing the key, and
|
|
440 |
+ |
/// the difference is exactly the one that outlives the reboot.
|
|
441 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
442 |
+ |
pub(super) enum Act {
|
|
443 |
+ |
/// Authorize it for this session. Forgotten when the daemon restarts.
|
|
444 |
+ |
Allow,
|
|
445 |
+ |
/// Deauthorize it now. Also only for this session.
|
|
446 |
+ |
Block,
|
|
447 |
+ |
/// Allow it and write that into the rule set, so it survives a reboot.
|
|
448 |
+ |
///
|
|
449 |
+ |
/// Measured on usbguard-1.1.4, and not what the name suggests: this does
|
|
450 |
+ |
/// **not** append a rule. The daemon finds the rule already matching the
|
|
451 |
+ |
/// device and rewrites its target in place, dropping `parent-hash` as it
|
|
452 |
+ |
/// goes, and does nothing at all when a rule with that target is already
|
|
453 |
+ |
/// there. So a policy generated by `generate-policy` does not grow a
|
|
454 |
+ |
/// duplicate line every time someone presses the key.
|
|
455 |
+ |
Remember,
|
|
456 |
+ |
}
|
|
457 |
+ |
|
|
458 |
+ |
impl Act {
|
|
459 |
+ |
/// The usbguard subcommand.
|
|
460 |
+ |
const fn verb(self) -> &'static str {
|
|
461 |
+ |
match self {
|
|
462 |
+ |
Self::Allow | Self::Remember => "allow-device",
|
|
463 |
+ |
Self::Block => "block-device",
|
|
464 |
+ |
}
|
|
465 |
+ |
}
|
|
466 |
+ |
|
|
467 |
+ |
/// Whether the rule set is written as well as the running state.
|
|
468 |
+ |
const fn permanent(self) -> bool {
|
|
469 |
+ |
matches!(self, Self::Remember)
|
|
470 |
+ |
}
|
|
471 |
+ |
|
|
472 |
+ |
/// What the key does, for the hint row and the `?` overlay.
|
|
473 |
+ |
pub(super) const fn label(self) -> &'static str {
|
|
474 |
+ |
match self {
|
|
475 |
+ |
Self::Allow => "allow",
|
|
476 |
+ |
Self::Block => "block",
|
|
477 |
+ |
Self::Remember => "save this device",
|
|
478 |
+ |
}
|
|
479 |
+ |
}
|
|
480 |
+ |
}
|
|
481 |
+ |
|
|
482 |
+ |
/// The listing entry for a row on this screen, joined on the kernel address.
|
|
483 |
+ |
///
|
|
484 |
+ |
/// `via-port` is the join and it is the measured surprise here: usbguard ships
|
|
485 |
+ |
/// `DeviceRulesWithPort=false`, which reads like the port is not available, and
|
|
486 |
+ |
/// it governs `generate-policy` only. `list-devices` prints `via-port` for every
|
|
487 |
+ |
/// device regardless — measured on fw12 against usbguard-1.1.4 — and the value
|
|
488 |
+ |
/// is the sysfs directory name verbatim, `2-4` for an attachment and `usb1` for
|
|
489 |
+ |
/// a root hub, which is exactly [`Attachment::address`].
|
|
490 |
+ |
///
|
|
491 |
+ |
/// Nothing else in the listing is a key. Two of the same dongle share id, name
|
|
492 |
+ |
/// and an empty serial; the Bluetooth radio on fw12 reports `serial ""` and
|
|
493 |
+ |
/// `name ""` together, so a join on those matches it to anything else equally
|
|
494 |
+ |
/// anonymous. The address is the one field that is unique by construction.
|
|
495 |
+ |
fn listed_for<'a>(
|
|
496 |
+ |
listing: &'a [listing::Listed],
|
|
497 |
+ |
device: &Attachment,
|
|
498 |
+ |
) -> Option<&'a listing::Listed> {
|
|
499 |
+ |
listing
|
|
500 |
+ |
.iter()
|
|
501 |
+ |
.find(|listed| listed.via_port.as_deref() == Some(device.address.as_str()))
|
|
502 |
+ |
}
|
|
503 |
+ |
|
|
504 |
+ |
/// Ask the daemon what it is holding.
|
|
505 |
+ |
///
|
|
506 |
+ |
/// Quiet: this runs as the first half of an action the user did ask for, and
|
|
507 |
+ |
/// the pane's promise is about the verb they pressed a key for. Logging the
|
|
508 |
+ |
/// lookup as well would push the `allow-device` line off a two-row pane with
|
|
509 |
+ |
/// the command nobody typed.
|
|
510 |
+ |
fn list_devices(log: &mut CommandLog) -> Result<Vec<listing::Listed>> {
|
|
511 |
+ |
let output = log.quiet(|log| Invocation::new("usbguard").arg("list-devices").run(log))?;
|
|
512 |
+ |
Ok(listing::parse_listed(&output))
|
|
513 |
+ |
}
|
|
514 |
+ |
|
|
515 |
+ |
/// The argv for one action, spelled in one place so a test can read it.
|
|
516 |
+ |
fn act_invocation(id: u32, act: Act) -> Invocation {
|
|
517 |
+ |
let invocation = Invocation::new("usbguard").args([act.verb(), &id.to_string()]);
|
|
518 |
+ |
if act.permanent() {
|
|
519 |
+ |
invocation.arg("--permanent")
|
|
520 |
+ |
} else {
|
|
521 |
+ |
invocation
|
|
522 |
+ |
}
|
|
523 |
+ |
}
|
|
524 |
+ |
|
|
525 |
+ |
/// Do `act` to `device`.
|
|
526 |
+ |
///
|
|
527 |
+ |
/// The id is looked up here rather than carried on the row, because it belongs
|
|
528 |
+ |
/// to the running daemon and to nothing else: see [`listing::Listed::id`]. A
|
|
529 |
+ |
/// cached id survives a daemon restart as a number that now addresses a
|
|
530 |
+ |
/// different device, and the action this module exists to perform is the one
|
|
531 |
+ |
/// where that matters.
|
|
532 |
+ |
///
|
|
533 |
+ |
/// No privilege escalation. usbguard ships `IPCAllowedGroups=wheel` and the
|
|
534 |
+ |
/// console user is in `wheel`, so the IPC call goes through as uid 1000 with no
|
|
535 |
+ |
/// sudo and no polkit — measured on fw12, where `allow-device` and
|
|
536 |
+ |
/// `block-device` both returned 0 unprivileged.
|
|
537 |
+ |
pub(super) fn act(device: &Attachment, act: Act, log: &mut CommandLog) -> Result<()> {
|
|
538 |
+ |
let listing = list_devices(log)?;
|
|
539 |
+ |
let Some(listed) = listed_for(&listing, device) else {
|
|
540 |
+ |
// The daemon is running (nothing else gets this far) and does not have
|
|
541 |
+ |
// this device. Worth saying rather than swallowing: it is the shape a
|
|
542 |
+ |
// device unplugged between the refresh and the keypress leaves. No
|
|
543 |
+ |
// command ran, so the pane gets the note form the mock backends already
|
|
544 |
+ |
// use, rather than nothing at all for a key that visibly did something.
|
|
545 |
+ |
log.record(
|
|
546 |
+ |
format!("# usbguard is not holding {}", device.address),
|
|
547 |
+ |
Severity::Warn,
|
|
548 |
+ |
);
|
|
549 |
+ |
bail!(
|
|
550 |
+ |
"usbguard is not holding anything at {}; refresh and try again",
|
|
551 |
+ |
device.address
|
|
552 |
+ |
);
|
|
553 |
+ |
};
|
|
554 |
+ |
act_invocation(listed.id, act).run(log)?;
|
|
555 |
+ |
Ok(())
|
|
556 |
+ |
}
|
|
557 |
+ |
|
| 415 |
558 |
|
#[cfg(test)]
|
| 416 |
559 |
|
mod tests {
|
| 417 |
560 |
|
use std::path::PathBuf;
|
| 741 |
884 |
|
assert!(Enforcement::Unarmed { policy: true }.takes_rules());
|
| 742 |
885 |
|
assert!(Enforcement::Armed.takes_rules());
|
| 743 |
886 |
|
}
|
|
887 |
+ |
|
|
888 |
+ |
// ---- the daemon's own listing, and the join ----
|
|
889 |
+ |
|
|
890 |
+ |
/// `usbguard list-devices` on fw12, captured 2026-09-08 against
|
|
891 |
+ |
/// usbguard-1.1.4 with a policy seeded by `generate-policy`.
|
|
892 |
+ |
///
|
|
893 |
+ |
/// The first listing this project has ever had, and the reason step 3 waited
|
|
894 |
+ |
/// for hardware: every verb but `generate-policy` needs a running daemon, so
|
|
895 |
+ |
/// nothing about the id or the framing could be had by reading. Kept
|
|
896 |
+ |
/// verbatim, hashes and all, because the parts this side drops are as much
|
|
897 |
+ |
/// of the measurement as the parts it keeps.
|
|
898 |
+ |
///
|
|
899 |
+ |
/// Trimmed to the four devices that carry a distinct shape. The two other
|
|
900 |
+ |
/// root hubs are the same shape as the two here.
|
|
901 |
+ |
const FW12_LISTING: &str = concat!(
|
|
902 |
+ |
r#"8: allow id 1d6b:0002 serial "0000:00:0d.0" name "xHCI Host Controller" "#,
|
|
903 |
+ |
r#"hash "d3YN7OD60Ggqc9hClW0/al6tlFEshidDnQKzZRRk410=" "#,
|
|
904 |
+ |
r#"parent-hash "Y1kBdG1uWQr5CjULQs7uh2F6pHgFb6VDHcWLk83v+tE=" "#,
|
|
905 |
+ |
r#"via-port "usb1" with-interface 09:00:00 with-connect-type """#,
|
|
906 |
+ |
"\n",
|
|
907 |
+ |
r#"12: allow id 0781:55a9 serial "03007220051326104554" name " SanDisk 3.2Gen1" "#,
|
|
908 |
+ |
r#"hash "WppRVx7NtRHjXdX24AvOKi/KAcNIO8kicRwZP2F+OKk=" "#,
|
|
909 |
+ |
r#"parent-hash "G+G3Mro8zBWJavFOAQUtoNiOsZSfBCt2XqHfOufYFis=" "#,
|
|
910 |
+ |
r#"via-port "2-4" with-interface { 08:06:50 08:06:62 } with-connect-type "hotplug""#,
|
|
911 |
+ |
"\n",
|
|
912 |
+ |
r#"13: allow id 32ac:001d serial "FRAPABCHA1521303LF" "#,
|
|
913 |
+ |
r#"name "Framework Laptop 12 Webcam Module" "#,
|
|
914 |
+ |
r#"hash "p7038Qb1XdPOqJYikYcQRJ/QCdI8nv+vWGyNcOhyyas=" "#,
|
|
915 |
+ |
r#"parent-hash "jEP/6WzviqdJ5VSeTUY8PatCNBKeaREvo2OqdplND/o=" "#,
|
|
916 |
+ |
"via-port \"3-7\" with-interface { 0e:01:01 0e:02:01 0e:02:01 0e:02:01 0e:02:01 ",
|
|
917 |
+ |
"0e:02:01 0e:02:01 0e:02:01 0e:02:01 fe:01:01 } with-connect-type \"hardwired\"",
|
|
918 |
+ |
"\n",
|
|
919 |
+ |
r#"14: allow id 8087:0033 serial "" name "" "#,
|
|
920 |
+ |
r#"hash "ciwwGozaSw4maEXfs4NdvETeMt6bnFEK6f4vmCqfud0=" "#,
|
|
921 |
+ |
r#"parent-hash "jEP/6WzviqdJ5VSeTUY8PatCNBKeaREvo2OqdplND/o=" "#,
|
|
922 |
+ |
"via-port \"3-10\" with-interface { e0:01:01 e0:01:01 e0:01:01 e0:01:01 ",
|
|
923 |
+ |
"e0:01:01 e0:01:01 e0:01:01 e0:01:01 } with-connect-type \"hardwired\"",
|
|
924 |
+ |
);
|
|
925 |
+ |
|
|
926 |
+ |
/// A sysfs tree matching the fw12 capture, so the join is asserted against
|
|
927 |
+ |
/// the two sides it actually has to join.
|
|
928 |
+ |
fn fw12_bus(case: &str) -> Vec<Attachment> {
|
|
929 |
+ |
let root = scratch(case);
|
|
930 |
+ |
dir(
|
|
931 |
+ |
&root,
|
|
932 |
+ |
"usb1",
|
|
933 |
+ |
&[
|
|
934 |
+ |
("idVendor", "1d6b"),
|
|
935 |
+ |
("idProduct", "0002"),
|
|
936 |
+ |
("product", "xHCI Host Controller"),
|
|
937 |
+ |
("serial", "0000:00:0d.0"),
|
|
938 |
+ |
("speed", "480"),
|
|
939 |
+ |
],
|
|
940 |
+ |
);
|
|
941 |
+ |
dir(
|
|
942 |
+ |
&root,
|
|
943 |
+ |
"2-4",
|
|
944 |
+ |
&[
|
|
945 |
+ |
("idVendor", "0781"),
|
|
946 |
+ |
("idProduct", "55a9"),
|
|
947 |
+ |
("product", " SanDisk 3.2Gen1"),
|
|
948 |
+ |
("serial", "03007220051326104554"),
|
|
949 |
+ |
("speed", "5000"),
|
|
950 |
+ |
],
|
|
951 |
+ |
);
|
|
952 |
+ |
dir(
|
|
953 |
+ |
&root,
|
|
954 |
+ |
"3-7",
|
|
955 |
+ |
&[
|
|
956 |
+ |
("idVendor", "32ac"),
|
|
957 |
+ |
("idProduct", "001d"),
|
|
958 |
+ |
("product", "Framework Laptop 12 Webcam Module"),
|
|
959 |
+ |
("serial", "FRAPABCHA1521303LF"),
|
|
960 |
+ |
("speed", "480"),
|
|
961 |
+ |
],
|
|
962 |
+ |
);
|
|
963 |
+ |
// The Bluetooth radio, which reports neither a serial nor a name.
|
|
964 |
+ |
dir(
|
|
965 |
+ |
&root,
|
|
966 |
+ |
"3-10",
|
|
967 |
+ |
&[
|
|
968 |
+ |
("idVendor", "8087"),
|
|
969 |
+ |
("idProduct", "0033"),
|
|
970 |
+ |
("product", ""),
|
|
971 |
+ |
("serial", ""),
|
|
972 |
+ |
("speed", "12"),
|
|
973 |
+ |
],
|
|
974 |
+ |
);
|
|
975 |
+ |
attachments_in(&root)
|
|
976 |
+ |
}
|
|
977 |
+ |
|
|
978 |
+ |
// The measurement the whole acting half rests on. Ids come from the daemon
|
|
979 |
+ |
// and start wherever it decides: this listing, from a daemon started
|
|
980 |
+ |
// seconds earlier on a machine with seven devices, numbers them 8 through
|
|
981 |
+ |
// 14. Anything that derived an id on this side, or cached one across a
|
|
982 |
+ |
// daemon restart, would address a different device.
|
|
983 |
+ |
#[test]
|
|
984 |
+ |
fn device_ids_come_from_the_daemon_and_are_not_one_based() {
|
|
985 |
+ |
let listed = listing::parse_listed(FW12_LISTING);
|
|
986 |
+ |
assert_eq!(listed.len(), 4, "{listed:#?}");
|
|
987 |
+ |
let ids: Vec<u32> = listed.iter().map(|device| device.id).collect();
|
|
988 |
+ |
assert_eq!(ids, [8, 12, 13, 14]);
|
|
989 |
+ |
}
|
|
990 |
+ |
|
|
991 |
+ |
// `DeviceRulesWithPort=false` is the shipped default and reads like the port
|
|
992 |
+ |
// is unavailable. It governs `generate-policy` only: `list-devices` prints
|
|
993 |
+ |
// `via-port` for every device, which is what makes the join exact.
|
|
994 |
+ |
#[test]
|
|
995 |
+ |
fn every_listed_device_carries_the_port_a_rule_would_not() {
|
|
996 |
+ |
let listed = listing::parse_listed(FW12_LISTING);
|
|
997 |
+ |
for device in &listed {
|
|
998 |
+ |
assert!(
|
|
999 |
+ |
device.via_port.is_some(),
|
|
1000 |
+ |
"no via-port to join on: {device:#?}",
|
|
1001 |
+ |
);
|
|
1002 |
+ |
assert!(
|
|
1003 |
+ |
!device.rule().contains("via-port"),
|
|
1004 |
+ |
"the composed rule must stay port-free: {}",
|
|
1005 |
+ |
device.rule(),
|
|
1006 |
+ |
);
|
|
1007 |
+ |
}
|
|
1008 |
+ |
}
|
|
1009 |
+ |
|
|
1010 |
+ |
// The join, both shapes at once: a root hub addressed `usb1` and an
|
|
1011 |
+ |
// attachment addressed `2-4`. `via-port` is the sysfs directory name
|
|
1012 |
+ |
// verbatim in both cases, which is why one comparison covers them.
|
|
1013 |
+ |
#[test]
|
|
1014 |
+ |
fn a_row_joins_to_its_listing_entry_on_the_kernel_address() {
|
|
1015 |
+ |
let listed = listing::parse_listed(FW12_LISTING);
|
|
1016 |
+ |
let bus = fw12_bus("join-fw12");
|
|
1017 |
+ |
let found: Vec<(String, u32)> = bus
|
|
1018 |
+ |
.iter()
|
|
1019 |
+ |
.filter_map(|device| {
|
|
1020 |
+ |
listed_for(&listed, device).map(|entry| (device.address.clone(), entry.id))
|
|
1021 |
+ |
})
|
|
1022 |
+ |
.collect();
|
|
1023 |
+ |
assert_eq!(
|
|
1024 |
+ |
found,
|
|
1025 |
+ |
[
|
|
1026 |
+ |
("usb1".to_string(), 8),
|
|
1027 |
+ |
("2-4".to_string(), 12),
|
|
1028 |
+ |
("3-7".to_string(), 13),
|
|
1029 |
+ |
("3-10".to_string(), 14),
|
|
1030 |
+ |
],
|
|
1031 |
+ |
);
|
|
1032 |
+ |
}
|
|
1033 |
+ |
|
|
1034 |
+ |
// Why the join is not on id, serial or name. fw12's Bluetooth radio reports
|
|
1035 |
+ |
// an empty serial and an empty name together, so every field but the
|
|
1036 |
+ |
// address is shared with anything else equally anonymous -- and two of the
|
|
1037 |
+ |
// same dongle would share the vendor and product ids as well.
|
|
1038 |
+ |
#[test]
|
|
1039 |
+ |
fn an_anonymous_device_still_joins_because_the_address_is_unique() {
|
|
1040 |
+ |
let listed = listing::parse_listed(FW12_LISTING);
|
|
1041 |
+ |
let radio = listed.iter().find(|device| device.id == 14).unwrap();
|
|
1042 |
+ |
assert_eq!(radio.serial, None);
|
|
1043 |
+ |
assert_eq!(radio.name, "");
|
|
1044 |
+ |
|
|
1045 |
+ |
let bus = fw12_bus("join-anonymous");
|
|
1046 |
+ |
let row = bus.iter().find(|device| device.address == "3-10").unwrap();
|
|
1047 |
+ |
assert_eq!(listed_for(&listed, row).map(|entry| entry.id), Some(14));
|
|
1048 |
+ |
}
|
|
1049 |
+ |
|
|
1050 |
+ |
// A device unplugged between the refresh and the keypress. The listing has
|
|
1051 |
+ |
// no entry at that address, and the answer is nothing rather than the
|
|
1052 |
+ |
// nearest match.
|
|
1053 |
+ |
#[test]
|
|
1054 |
+ |
fn a_row_the_daemon_is_not_holding_joins_to_nothing() {
|
|
1055 |
+ |
let listed = listing::parse_listed(FW12_LISTING);
|
|
1056 |
+ |
let root = scratch("join-missing");
|
|
1057 |
+ |
dir(
|
|
1058 |
+ |
&root,
|
|
1059 |
+ |
"9-9",
|
|
1060 |
+ |
&[
|
|
1061 |
+ |
("idVendor", "0781"),
|
|
1062 |
+ |
("idProduct", "55a9"),
|
|
1063 |
+ |
("product", " SanDisk 3.2Gen1"),
|
|
1064 |
+ |
("serial", "03007220051326104554"),
|
|
1065 |
+ |
("speed", "5000"),
|
|
1066 |
+ |
],
|
|
1067 |
+ |
);
|
|
1068 |
+ |
let gone = attachments_in(&root).pop().unwrap();
|
|
1069 |
+ |
// Same vendor, product and serial as the stick at `2-4`, and a
|
|
1070 |
+ |
// different port. A join on identity would have allowed the wrong row.
|
|
1071 |
+ |
assert_eq!(listed_for(&listed, &gone), None);
|
|
1072 |
+ |
}
|
|
1073 |
+ |
|
|
1074 |
+ |
// ---- what each key runs ----
|
|
1075 |
+ |
|
|
1076 |
+ |
// The log pane's promise is that every action shows its argv, so the argv is
|
|
1077 |
+ |
// worth asserting rather than trusting. `--permanent` is the only difference
|
|
1078 |
+ |
// between allowing for this session and remembering the device.
|
|
1079 |
+ |
#[test]
|
|
1080 |
+ |
fn each_action_spells_the_command_the_log_pane_shows() {
|
|
1081 |
+ |
assert_eq!(
|
|
1082 |
+ |
act_invocation(13, Act::Allow).display(),
|
|
1083 |
+ |
"usbguard allow-device 13",
|
|
1084 |
+ |
);
|
|
1085 |
+ |
assert_eq!(
|
|
1086 |
+ |
act_invocation(13, Act::Block).display(),
|
|
1087 |
+ |
"usbguard block-device 13",
|
|
1088 |
+ |
);
|
|
1089 |
+ |
assert_eq!(
|
|
1090 |
+ |
act_invocation(13, Act::Remember).display(),
|
|
1091 |
+ |
"usbguard allow-device 13 --permanent",
|
|
1092 |
+ |
);
|
|
1093 |
+ |
}
|
|
1094 |
+ |
|
|
1095 |
+ |
// Remembering is an allow. The pair that differs is the target, not the
|
|
1096 |
+ |
// verb, and a `block --permanent` is deliberately not offered: the screen's
|
|
1097 |
+ |
// permanent action is the one that gets a device back, and a rule set that
|
|
1098 |
+ |
// grows permanent blocks from a keypress is a machine someone has to edit
|
|
1099 |
+ |
// 0600 root-owned files to recover.
|
|
1100 |
+ |
#[test]
|
|
1101 |
+ |
fn only_remembering_writes_the_rule_set() {
|
|
1102 |
+ |
assert!(!Act::Allow.permanent());
|
|
1103 |
+ |
assert!(!Act::Block.permanent());
|
|
1104 |
+ |
assert!(Act::Remember.permanent());
|
|
1105 |
+ |
assert_eq!(Act::Remember.verb(), Act::Allow.verb());
|
|
1106 |
+ |
assert_eq!(Act::Remember.label(), "save this device");
|
|
1107 |
+ |
}
|
| 744 |
1108 |
|
}
|