Skip to main content

bhwi_async_transport/
udev.rs

1//! Bundled Linux udev rules and system installation helpers.
2
3use std::{
4    env,
5    error::Error,
6    fmt, fs, io,
7    path::{Path, PathBuf},
8    process::Command,
9};
10
11#[cfg(unix)]
12use std::os::unix::fs::PermissionsExt;
13
14use crate::DeviceType;
15
16/// The bundled rule files to install, independent of compiled device support.
17#[derive(Debug, Clone, Eq, PartialEq)]
18pub enum UdevRuleSelection {
19    /// All bundled rules, including legacy Digital Bitbox and BitBox02 rules.
20    All,
21    /// Rules explicitly associated with the selected device types.
22    ///
23    /// Bundled BitBox rules have no device-type association and require
24    /// [`Self::All`]. Types without associated rules contribute no files.
25    Devices(
26        /// The device types whose associated rules are selected.
27        Vec<DeviceType>,
28    ),
29}
30
31/// A filesystem, environment, or system-command failure during rule installation.
32#[derive(Debug)]
33pub enum UdevInstallError {
34    /// Writing a rule file or setting its permissions failed.
35    Io {
36        /// The filesystem operation that failed.
37        action: &'static str,
38        /// The rule-file path.
39        path: PathBuf,
40        /// The underlying filesystem error.
41        source: io::Error,
42    },
43    /// A required system command could not be started.
44    CommandSpawn {
45        /// The program that could not be started.
46        program: String,
47        /// The underlying process-spawn error.
48        source: io::Error,
49    },
50    /// A system command returned an unsuccessful status.
51    CommandFailed {
52        /// The program that failed.
53        program: String,
54        /// The arguments passed to the program.
55        args: Vec<String>,
56        /// The exit code, or `None` if the process ended without one.
57        code: Option<i32>,
58    },
59    /// No nonempty user name was found in `SUDO_USER`, `USER`, or `LOGNAME`.
60    MissingUser,
61}
62
63impl UdevInstallError {
64    /// Returns whether elevated privileges may resolve this error.
65    ///
66    /// This is a hint: permission-denied I/O errors and all unsuccessful command
67    /// statuses return `true`, even if privileges were not the cause.
68    pub fn needs_root(&self) -> bool {
69        match self {
70            Self::Io { source, .. } => source.kind() == io::ErrorKind::PermissionDenied,
71            Self::CommandFailed { .. } => true,
72            Self::CommandSpawn { .. } | Self::MissingUser => false,
73        }
74    }
75}
76
77impl fmt::Display for UdevInstallError {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Self::Io {
81                action,
82                path,
83                source,
84            } => write!(f, "failed to {action} {}: {source}", path.display()),
85            Self::CommandSpawn { program, source } => {
86                write!(f, "failed to run {program}: {source}")
87            }
88            Self::CommandFailed {
89                program,
90                args,
91                code,
92            } => {
93                write!(
94                    f,
95                    "{} {} failed with status {}",
96                    program,
97                    args.join(" "),
98                    code.map_or_else(|| "unknown".to_owned(), |code| code.to_string())
99                )
100            }
101            Self::MissingUser => write!(f, "could not determine current user"),
102        }
103    }
104}
105
106impl Error for UdevInstallError {
107    fn source(&self) -> Option<&(dyn Error + 'static)> {
108        match self {
109            Self::Io { source, .. } | Self::CommandSpawn { source, .. } => Some(source),
110            Self::CommandFailed { .. } | Self::MissingUser => None,
111        }
112    }
113}
114
115/// Installs selected rules and updates udev and the user's device-access group.
116///
117/// `location` must be an existing directory. Selected files are created or
118/// overwritten with mode `0o644`. The function then runs `udevadm trigger`,
119/// `udevadm control --reload-rules`, `groupadd plugdev` (accepting an existing
120/// group), and `usermod -aG plugdev USER`, even if no rule files were selected.
121/// The user name is the first nonempty value of `SUDO_USER`, `USER`, or `LOGNAME`.
122/// Installation generally requires elevated privileges.
123///
124/// # Errors
125///
126/// Returns an error if the user cannot be determined or a filesystem operation
127/// or system command fails. Earlier file writes and system changes are not
128/// rolled back, so installation can be partial.
129pub fn install_udev_rules(
130    location: &Path,
131    selection: UdevRuleSelection,
132) -> Result<(), UdevInstallError> {
133    let user = current_user()?;
134    let mut runner = SystemCommandRunner;
135    install_udev_rules_with_runner(location, &selection, &mut runner, &user)
136}
137
138/// Returns the filenames selected from the bundled rules without performing I/O.
139pub fn udev_rule_names(selection: &UdevRuleSelection) -> Vec<&'static str> {
140    rules_for_selection(selection)
141        .into_iter()
142        .map(|rule| rule.name)
143        .collect()
144}
145
146fn install_udev_rules_with_runner(
147    location: &Path,
148    selection: &UdevRuleSelection,
149    runner: &mut dyn CommandRunner,
150    user: &str,
151) -> Result<(), UdevInstallError> {
152    copy_udev_rule_files(location, selection)?;
153    runner.run("udevadm", &["trigger"])?;
154    runner.run("udevadm", &["control", "--reload-rules"])?;
155    match runner.run("groupadd", &["plugdev"]) {
156        Ok(()) => {}
157        Err(UdevInstallError::CommandFailed { code: Some(9), .. }) => {}
158        Err(err) => return Err(err),
159    }
160    runner.run("usermod", &["-aG", "plugdev", user])?;
161    Ok(())
162}
163
164fn copy_udev_rule_files(
165    location: &Path,
166    selection: &UdevRuleSelection,
167) -> Result<(), UdevInstallError> {
168    for rule in rules_for_selection(selection) {
169        let path = location.join(rule.name);
170        fs::write(&path, rule.contents).map_err(|source| UdevInstallError::Io {
171            action: "write",
172            path: path.clone(),
173            source,
174        })?;
175
176        #[cfg(unix)]
177        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).map_err(|source| {
178            UdevInstallError::Io {
179                action: "chmod",
180                path: path.clone(),
181                source,
182            }
183        })?;
184    }
185    Ok(())
186}
187
188fn current_user() -> Result<String, UdevInstallError> {
189    ["SUDO_USER", "USER", "LOGNAME"]
190        .into_iter()
191        .find_map(|name| env::var(name).ok().filter(|value| !value.is_empty()))
192        .ok_or(UdevInstallError::MissingUser)
193}
194
195trait CommandRunner {
196    fn run(&mut self, program: &str, args: &[&str]) -> Result<(), UdevInstallError>;
197}
198
199struct SystemCommandRunner;
200
201impl CommandRunner for SystemCommandRunner {
202    fn run(&mut self, program: &str, args: &[&str]) -> Result<(), UdevInstallError> {
203        let status = Command::new(program)
204            .args(args)
205            .status()
206            .map_err(|source| UdevInstallError::CommandSpawn {
207                program: program.to_owned(),
208                source,
209            })?;
210        if status.success() {
211            Ok(())
212        } else {
213            Err(UdevInstallError::CommandFailed {
214                program: program.to_owned(),
215                args: args.iter().map(|arg| (*arg).to_owned()).collect(),
216                code: status.code(),
217            })
218        }
219    }
220}
221
222#[derive(Debug)]
223struct UdevRule {
224    name: &'static str,
225    contents: &'static str,
226    device_type: Option<DeviceType>,
227}
228
229fn rules_for_selection(selection: &UdevRuleSelection) -> Vec<&'static UdevRule> {
230    match selection {
231        UdevRuleSelection::All => UDEV_RULES.iter().collect(),
232        UdevRuleSelection::Devices(device_types) => UDEV_RULES
233            .iter()
234            .filter(|rule| {
235                rule.device_type
236                    .is_some_and(|device_type| device_types.contains(&device_type))
237            })
238            .collect(),
239    }
240}
241
242const UDEV_RULES: &[UdevRule] = &[
243    UdevRule {
244        name: "20-hw1.rules",
245        contents: include_str!("udev/20-hw1.rules"),
246        device_type: Some(DeviceType::Ledger),
247    },
248    UdevRule {
249        name: "51-coinkite.rules",
250        contents: include_str!("udev/51-coinkite.rules"),
251        device_type: Some(DeviceType::Coldcard),
252    },
253    UdevRule {
254        name: "51-hid-digitalbitbox.rules",
255        contents: include_str!("udev/51-hid-digitalbitbox.rules"),
256        device_type: None,
257    },
258    UdevRule {
259        name: "51-trezor.rules",
260        contents: include_str!("udev/51-trezor.rules"),
261        device_type: Some(DeviceType::Trezor),
262    },
263    UdevRule {
264        name: "51-usb-keepkey.rules",
265        contents: include_str!("udev/51-usb-keepkey.rules"),
266        device_type: Some(DeviceType::KeepKey),
267    },
268    UdevRule {
269        name: "52-hid-digitalbitbox.rules",
270        contents: include_str!("udev/52-hid-digitalbitbox.rules"),
271        device_type: None,
272    },
273    UdevRule {
274        name: "53-hid-bitbox02.rules",
275        contents: include_str!("udev/53-hid-bitbox02.rules"),
276        device_type: None,
277    },
278    UdevRule {
279        name: "54-hid-bitbox02.rules",
280        contents: include_str!("udev/54-hid-bitbox02.rules"),
281        device_type: None,
282    },
283    UdevRule {
284        name: "55-usb-jade.rules",
285        contents: include_str!("udev/55-usb-jade.rules"),
286        device_type: Some(DeviceType::Jade),
287    },
288];
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use std::collections::VecDeque;
294
295    #[test]
296    fn selects_native_device_rule_files() {
297        assert_eq!(
298            udev_rule_names(&UdevRuleSelection::Devices(vec![DeviceType::Ledger])),
299            vec!["20-hw1.rules"]
300        );
301        assert_eq!(
302            udev_rule_names(&UdevRuleSelection::Devices(vec![DeviceType::Coldcard])),
303            vec!["51-coinkite.rules"]
304        );
305        assert_eq!(
306            udev_rule_names(&UdevRuleSelection::Devices(vec![DeviceType::Jade])),
307            vec!["55-usb-jade.rules"]
308        );
309        assert_eq!(
310            udev_rule_names(&UdevRuleSelection::Devices(vec![DeviceType::KeepKey])),
311            vec!["51-usb-keepkey.rules"]
312        );
313    }
314
315    #[test]
316    fn explicit_keepkey_selection_copies_only_its_rule() {
317        let temp = test_dir("explicit_keepkey_selection_copies_only_its_rule");
318        fs::create_dir_all(&temp).expect("temp dir");
319        copy_udev_rule_files(
320            &temp,
321            &UdevRuleSelection::Devices(vec![DeviceType::KeepKey]),
322        )
323        .expect("copy KeepKey rule");
324
325        assert!(temp.join("51-usb-keepkey.rules").exists());
326        assert_eq!(fs::read_dir(&temp).unwrap().count(), 1);
327        fs::remove_dir_all(temp).ok();
328    }
329
330    #[test]
331    fn selects_all_hwi_rule_files() {
332        assert_eq!(
333            udev_rule_names(&UdevRuleSelection::All),
334            vec![
335                "20-hw1.rules",
336                "51-coinkite.rules",
337                "51-hid-digitalbitbox.rules",
338                "51-trezor.rules",
339                "51-usb-keepkey.rules",
340                "52-hid-digitalbitbox.rules",
341                "53-hid-bitbox02.rules",
342                "54-hid-bitbox02.rules",
343                "55-usb-jade.rules",
344            ]
345        );
346    }
347
348    #[test]
349    fn copies_files_and_runs_install_commands() {
350        let temp = test_dir("copies_files_and_runs_install_commands");
351        fs::create_dir_all(&temp).expect("temp dir");
352        let mut runner = FakeCommandRunner::default();
353
354        install_udev_rules_with_runner(
355            &temp,
356            &UdevRuleSelection::Devices(vec![DeviceType::Ledger, DeviceType::Jade]),
357            &mut runner,
358            "alice",
359        )
360        .expect("install rules");
361
362        assert!(temp.join("20-hw1.rules").exists());
363        assert!(temp.join("55-usb-jade.rules").exists());
364        assert!(!temp.join("51-coinkite.rules").exists());
365        assert_eq!(
366            runner.commands,
367            vec![
368                command("udevadm", &["trigger"]),
369                command("udevadm", &["control", "--reload-rules"]),
370                command("groupadd", &["plugdev"]),
371                command("usermod", &["-aG", "plugdev", "alice"]),
372            ]
373        );
374        fs::remove_dir_all(temp).ok();
375    }
376
377    #[test]
378    fn ignores_existing_plugdev_group() {
379        let temp = test_dir("ignores_existing_plugdev_group");
380        fs::create_dir_all(&temp).expect("temp dir");
381        let mut runner = FakeCommandRunner {
382            failures: VecDeque::from([None, None, Some(9)]),
383            ..FakeCommandRunner::default()
384        };
385
386        install_udev_rules_with_runner(
387            &temp,
388            &UdevRuleSelection::Devices(vec![DeviceType::Coldcard]),
389            &mut runner,
390            "alice",
391        )
392        .expect("install rules");
393
394        assert_eq!(
395            runner.commands.last(),
396            Some(&command("usermod", &["-aG", "plugdev", "alice"]))
397        );
398        fs::remove_dir_all(temp).ok();
399    }
400
401    #[test]
402    fn reports_permission_denied_as_needs_root() {
403        let error = UdevInstallError::Io {
404            action: "write",
405            path: PathBuf::from("/etc/udev/rules.d/20-hw1.rules"),
406            source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
407        };
408        assert!(error.needs_root());
409    }
410
411    #[derive(Default)]
412    struct FakeCommandRunner {
413        commands: Vec<Vec<String>>,
414        failures: VecDeque<Option<i32>>,
415    }
416
417    impl CommandRunner for FakeCommandRunner {
418        fn run(&mut self, program: &str, args: &[&str]) -> Result<(), UdevInstallError> {
419            self.commands.push(command(program, args));
420            if let Some(code) = self.failures.pop_front().flatten() {
421                Err(UdevInstallError::CommandFailed {
422                    program: program.to_owned(),
423                    args: args.iter().map(|arg| (*arg).to_owned()).collect(),
424                    code: Some(code),
425                })
426            } else {
427                Ok(())
428            }
429        }
430    }
431
432    fn command(program: &str, args: &[&str]) -> Vec<String> {
433        std::iter::once(program.to_owned())
434            .chain(args.iter().map(|arg| (*arg).to_owned()))
435            .collect()
436    }
437
438    fn test_dir(name: &str) -> PathBuf {
439        env::temp_dir().join(format!("bhwi-udev-{name}-{}", std::process::id()))
440    }
441}