1use 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#[derive(Debug, Clone, Eq, PartialEq)]
18pub enum UdevRuleSelection {
19 All,
21 Devices(
26 Vec<DeviceType>,
28 ),
29}
30
31#[derive(Debug)]
33pub enum UdevInstallError {
34 Io {
36 action: &'static str,
38 path: PathBuf,
40 source: io::Error,
42 },
43 CommandSpawn {
45 program: String,
47 source: io::Error,
49 },
50 CommandFailed {
52 program: String,
54 args: Vec<String>,
56 code: Option<i32>,
58 },
59 MissingUser,
61}
62
63impl UdevInstallError {
64 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
115pub 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
138pub 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}