From 15c62cc97486bc1b7cd7d0f9dc97629acbc6b916 Mon Sep 17 00:00:00 2001 From: sisungo Date: Wed, 22 Jul 2026 19:26:25 +0800 Subject: [PATCH] Initial commit Signed-off-by: sisungo --- .gitignore | 2 + Cargo.toml | 3 + LICENSE | 24 + README.md | 3 + lib/udev-core/Cargo.toml | 13 + lib/udev-core/src/builtins/blkid.rs | 138 ++ lib/udev-core/src/builtins/hwdb.rs | 51 + lib/udev-core/src/builtins/mod.rs | 28 + lib/udev-core/src/builtins/net_driver.rs | 19 + lib/udev-core/src/builtins/path_id.rs | 38 + lib/udev-core/src/builtins/usb_id.rs | 41 + lib/udev-core/src/config.rs | 140 ++ lib/udev-core/src/device.rs | 277 ++++ lib/udev-core/src/hwdb_parser.rs | 663 ++++++++++ lib/udev-core/src/lib.rs | 13 + lib/udev-core/src/rule_parser.rs | 512 ++++++++ lib/udev-core/src/rules.rs | 1123 +++++++++++++++++ lib/udev-core/src/runtime/control.rs | 735 +++++++++++ lib/udev-core/src/runtime/mod.rs | 2 + lib/udev-core/src/runtime/udev_db.rs | 270 ++++ lib/udev-core/src/uevent.rs | 453 +++++++ lxdeviced/Cargo.toml | 11 + lxdeviced/src/event.rs | 303 +++++ lxdeviced/src/main.rs | 353 ++++++ lxdeviced/src/runtime/control_daemon.rs | 159 +++ lxdeviced/src/runtime/mod.rs | 2 + misc/lxdeviced.airs | 13 + misc/rules/50-udev-default.rules | 129 ++ misc/rules/60-autosuspend.rules | 22 + misc/rules/60-block.rules | 24 + misc/rules/60-cdrom_id.rules | 29 + misc/rules/60-dmi-id.rules | 29 + misc/rules/60-drm.rules | 11 + misc/rules/60-evdev.rules | 30 + misc/rules/60-fido-id.rules | 14 + misc/rules/60-gpiochip.rules | 17 + misc/rules/60-infiniband.rules | 12 + misc/rules/60-input-id.rules | 19 + misc/rules/60-persistent-alsa.rules | 15 + misc/rules/60-persistent-hidraw.rules | 26 + misc/rules/60-persistent-input.rules | 55 + .../60-persistent-media-controller.rules | 13 + misc/rules/60-persistent-storage-mtd.rules | 12 + misc/rules/60-persistent-storage-tape.rules | 45 + misc/rules/60-persistent-storage.rules | 177 +++ misc/rules/60-persistent-v4l.rules | 22 + misc/rules/60-sensor.rules | 34 + misc/rules/60-serial.rules | 28 + misc/rules/60-tpm2-id.rules | 10 + misc/rules/64-btrfs.rules | 17 + misc/rules/65-integration.rules | 27 + misc/rules/70-camera.rules | 9 + misc/rules/70-joystick.rules | 11 + misc/rules/70-memory.rules | 8 + misc/rules/70-mouse.rules | 18 + misc/rules/70-power-switch.rules | 15 + misc/rules/70-touchpad.rules | 16 + misc/rules/70-uaccess.rules | 136 ++ misc/rules/71-seat.rules | 82 ++ misc/rules/73-seat-late.rules | 20 + misc/rules/75-net-description.rules | 17 + misc/rules/75-probe_mtd.rules | 7 + misc/rules/78-sound-card.rules | 96 ++ misc/rules/80-drivers.rules | 13 + misc/rules/80-net-setup-link.rules | 13 + misc/rules/81-net-bridge.rules | 16 + misc/rules/81-net-dhcp.rules | 14 + misc/rules/82-net-auto-link-local.rules | 15 + misc/rules/90-image-dissect.rules | 56 + misc/rules/90-iocost.rules | 22 + misc/rules/90-vconsole.rules | 12 + misc/rules/LICENSE.LGPL2.1 | 502 ++++++++ udevadm/Cargo.toml | 11 + udevadm/src/coldplug.rs | 114 ++ udevadm/src/control.rs | 88 ++ udevadm/src/hwdb.rs | 87 ++ udevadm/src/info.rs | 205 +++ udevadm/src/main.rs | 61 + udevadm/src/monitor.rs | 73 ++ udevadm/src/settle.rs | 74 ++ udevadm/src/test.rs | 117 ++ 81 files changed, 8134 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 lib/udev-core/Cargo.toml create mode 100644 lib/udev-core/src/builtins/blkid.rs create mode 100644 lib/udev-core/src/builtins/hwdb.rs create mode 100644 lib/udev-core/src/builtins/mod.rs create mode 100644 lib/udev-core/src/builtins/net_driver.rs create mode 100644 lib/udev-core/src/builtins/path_id.rs create mode 100644 lib/udev-core/src/builtins/usb_id.rs create mode 100644 lib/udev-core/src/config.rs create mode 100644 lib/udev-core/src/device.rs create mode 100644 lib/udev-core/src/hwdb_parser.rs create mode 100644 lib/udev-core/src/lib.rs create mode 100644 lib/udev-core/src/rule_parser.rs create mode 100644 lib/udev-core/src/rules.rs create mode 100644 lib/udev-core/src/runtime/control.rs create mode 100644 lib/udev-core/src/runtime/mod.rs create mode 100644 lib/udev-core/src/runtime/udev_db.rs create mode 100644 lib/udev-core/src/uevent.rs create mode 100644 lxdeviced/Cargo.toml create mode 100644 lxdeviced/src/event.rs create mode 100644 lxdeviced/src/main.rs create mode 100644 lxdeviced/src/runtime/control_daemon.rs create mode 100644 lxdeviced/src/runtime/mod.rs create mode 100644 misc/lxdeviced.airs create mode 100644 misc/rules/50-udev-default.rules create mode 100644 misc/rules/60-autosuspend.rules create mode 100644 misc/rules/60-block.rules create mode 100644 misc/rules/60-cdrom_id.rules create mode 100644 misc/rules/60-dmi-id.rules create mode 100644 misc/rules/60-drm.rules create mode 100644 misc/rules/60-evdev.rules create mode 100644 misc/rules/60-fido-id.rules create mode 100644 misc/rules/60-gpiochip.rules create mode 100644 misc/rules/60-infiniband.rules create mode 100644 misc/rules/60-input-id.rules create mode 100644 misc/rules/60-persistent-alsa.rules create mode 100644 misc/rules/60-persistent-hidraw.rules create mode 100644 misc/rules/60-persistent-input.rules create mode 100644 misc/rules/60-persistent-media-controller.rules create mode 100644 misc/rules/60-persistent-storage-mtd.rules create mode 100644 misc/rules/60-persistent-storage-tape.rules create mode 100644 misc/rules/60-persistent-storage.rules create mode 100644 misc/rules/60-persistent-v4l.rules create mode 100644 misc/rules/60-sensor.rules create mode 100644 misc/rules/60-serial.rules create mode 100644 misc/rules/60-tpm2-id.rules create mode 100644 misc/rules/64-btrfs.rules create mode 100644 misc/rules/65-integration.rules create mode 100644 misc/rules/70-camera.rules create mode 100644 misc/rules/70-joystick.rules create mode 100644 misc/rules/70-memory.rules create mode 100644 misc/rules/70-mouse.rules create mode 100644 misc/rules/70-power-switch.rules create mode 100644 misc/rules/70-touchpad.rules create mode 100644 misc/rules/70-uaccess.rules create mode 100644 misc/rules/71-seat.rules create mode 100644 misc/rules/73-seat-late.rules create mode 100644 misc/rules/75-net-description.rules create mode 100644 misc/rules/75-probe_mtd.rules create mode 100644 misc/rules/78-sound-card.rules create mode 100644 misc/rules/80-drivers.rules create mode 100644 misc/rules/80-net-setup-link.rules create mode 100644 misc/rules/81-net-bridge.rules create mode 100644 misc/rules/81-net-dhcp.rules create mode 100644 misc/rules/82-net-auto-link-local.rules create mode 100644 misc/rules/90-image-dissect.rules create mode 100644 misc/rules/90-iocost.rules create mode 100644 misc/rules/90-vconsole.rules create mode 100644 misc/rules/LICENSE.LGPL2.1 create mode 100644 udevadm/Cargo.toml create mode 100644 udevadm/src/coldplug.rs create mode 100644 udevadm/src/control.rs create mode 100644 udevadm/src/hwdb.rs create mode 100644 udevadm/src/info.rs create mode 100644 udevadm/src/main.rs create mode 100644 udevadm/src/monitor.rs create mode 100644 udevadm/src/settle.rs create mode 100644 udevadm/src/test.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b72444 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9603801 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["lxdeviced", "udevadm", "lib/udev-core"] +resolver = "3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..efb9808 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100644 index 0000000..afce301 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# lxdeviced + +lxdeviced (Linux Device Daemon), is a udev-compatible device discovery and management daemon for Linux. diff --git a/lib/udev-core/Cargo.toml b/lib/udev-core/Cargo.toml new file mode 100644 index 0000000..b4f5419 --- /dev/null +++ b/lib/udev-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "udev-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +tokio = { version = "1", features = ["macros", "rt", "net", "io-util", "fs", "signal", "process", "sync", "time"] } +libc = "0.2" +peg = "0.8" +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" diff --git a/lib/udev-core/src/builtins/blkid.rs b/lib/udev-core/src/builtins/blkid.rs new file mode 100644 index 0000000..270fb66 --- /dev/null +++ b/lib/udev-core/src/builtins/blkid.rs @@ -0,0 +1,138 @@ +use crate::hwdb_parser::Hwdb; +use crate::rules::SubstContext; +use std::path::PathBuf; +use std::process::Command; + +fn apply_blkid_output(output: &str, ctx: &mut SubstContext) { + for line in output.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + let env_key = match key { + "UUID" => "ID_FS_UUID".to_string(), + "TYPE" => "ID_FS_TYPE".to_string(), + "LABEL" => "ID_FS_LABEL".to_string(), + other if other.starts_with("ID_FS_") || other.starts_with("ID_") || other.starts_with("BLKID_") => other.to_string(), + other => format!("BLKID_{}", other), + }; + ctx.env.insert(env_key, value.to_string()); + } + } +} + +/// Run blkid builtin: try to identify filesystem on the device node and set ID_FS_* env vars. +pub fn run(_args: &[String], ctx: &mut SubstContext, _hwdb_files: &[(String, Hwdb)]) -> Result<(), std::io::Error> { + // Determine device node path + let devnode = if !ctx.devname.is_empty() { + PathBuf::from(&ctx.devname) + } else { + crate::device::devpath_from_uevent(&ctx.devpath, Some(&ctx.subsystem), None) + }; + + // If path is not absolute, try /dev/ + let mut candidates = Vec::new(); + if devnode.is_absolute() { + candidates.push(devnode.clone()); + } else { + let p = PathBuf::from("/dev").join(devnode); + candidates.push(p); + } + + // Also try /dev/ + if !ctx.kernel_name.is_empty() { + candidates.push(PathBuf::from("/dev").join(&ctx.kernel_name)); + } + + // Try external blkid command first, preferring udev output and falling back to export format. + for cand in &candidates { + if cand.exists() { + if let Ok(output) = Command::new("blkid").arg("-o").arg("udev").arg(cand).output() { + if output.status.success() { + let out = String::from_utf8_lossy(&output.stdout); + apply_blkid_output(&out, ctx); + return Ok(()); + } + } + if let Ok(output) = Command::new("blkid").arg("-o").arg("export").arg(cand).output() { + if output.status.success() { + let out = String::from_utf8_lossy(&output.stdout); + apply_blkid_output(&out, ctx); + return Ok(()); + } + } + } + } + + // Fallback: scan /dev/disk/by-uuid and by-label to find symlink target + use std::fs; + if let Ok(entries) = fs::read_dir("/dev/disk/by-uuid") { + for e in entries.flatten() { + let path = e.path(); + if let Ok(target) = fs::read_link(&path) { + let candidate = PathBuf::from("/dev").join(target); + for cand in &candidates { + if let (Ok(a), Ok(b)) = (fs::canonicalize(cand), fs::canonicalize(&candidate)) { + if a == b { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + ctx.env.insert("ID_FS_UUID".to_string(), name.to_string()); + return Ok(()); + } + } + } + } + } + } + } + + if let Ok(entries) = fs::read_dir("/dev/disk/by-label") { + for e in entries.flatten() { + let path = e.path(); + if let Ok(target) = fs::read_link(&path) { + let candidate = PathBuf::from("/dev").join(target); + for cand in &candidates { + if let (Ok(a), Ok(b)) = (fs::canonicalize(cand), fs::canonicalize(&candidate)) { + if a == b { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + ctx.env.insert("ID_FS_LABEL".to_string(), name.to_string()); + return Ok(()); + } + } + } + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::apply_blkid_output; + use crate::rules::SubstContext; + use crate::uevent::Uevent; + use std::collections::HashMap; + + fn make_ctx() -> SubstContext { + let mut props = HashMap::new(); + props.insert("DEVPATH".to_string(), "/devices/test".to_string()); + let uevent = Uevent::from_map(&props).unwrap(); + SubstContext::from_uevent(&uevent) + } + + #[test] + fn parses_udev_and_export_output() { + let mut ctx = make_ctx(); + apply_blkid_output("ID_FS_UUID=1234\nID_FS_TYPE=ext4\n", &mut ctx); + assert_eq!(ctx.env.get("ID_FS_UUID"), Some(&"1234".to_string())); + assert_eq!(ctx.env.get("ID_FS_TYPE"), Some(&"ext4".to_string())); + + let mut ctx = make_ctx(); + apply_blkid_output("UUID=abcd\nTYPE=ext4\nLABEL=boot\n", &mut ctx); + assert_eq!(ctx.env.get("ID_FS_UUID"), Some(&"abcd".to_string())); + assert_eq!(ctx.env.get("ID_FS_TYPE"), Some(&"ext4".to_string())); + assert_eq!(ctx.env.get("ID_FS_LABEL"), Some(&"boot".to_string())); + } +} diff --git a/lib/udev-core/src/builtins/hwdb.rs b/lib/udev-core/src/builtins/hwdb.rs new file mode 100644 index 0000000..17e7339 --- /dev/null +++ b/lib/udev-core/src/builtins/hwdb.rs @@ -0,0 +1,51 @@ +use crate::hwdb_parser::Hwdb; +use crate::rules::SubstContext; + +pub fn run(args: &[String], ctx: &mut SubstContext, hwdb_files: &[(String, Hwdb)]) -> Result<(), std::io::Error> { + // Parse optional --subsystem and search key + let mut subsystem_filter: Option = None; + let mut key_opt: Option = None; + let mut i = 0; + while i < args.len() { + let a = &args[i]; + if let Some(rest) = a.strip_prefix("--subsystem=") { + subsystem_filter = Some(rest.to_string()); + } else if a == "--subsystem" { + if i + 1 < args.len() { + subsystem_filter = Some(args[i + 1].clone()); + i += 1; + } + } else { + if key_opt.is_none() { key_opt = Some(a.clone()); } + } + i += 1; + } + + let key = if let Some(k) = key_opt { + crate::rules::substitute(&k, ctx) + } else if let Some(m) = ctx.env.get("MODALIAS") { + m.clone() + } else { + ctx.kernel_name.clone() + }; + + if key.is_empty() { return Ok(()); } + + for (_name, hwdb) in hwdb_files { + for record in hwdb.records_matching(&key) { + if let Some(ref subs) = subsystem_filter { + let mut ok = false; + for m in &record.matches { + if m.pattern.starts_with(&format!("{}:", subs)) { + ok = true; break; + } + } + if !ok { continue; } + } + for prop in &record.properties { + ctx.env.insert(prop.key.clone(), prop.value.clone()); + } + } + } + Ok(()) +} diff --git a/lib/udev-core/src/builtins/mod.rs b/lib/udev-core/src/builtins/mod.rs new file mode 100644 index 0000000..84d334c --- /dev/null +++ b/lib/udev-core/src/builtins/mod.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; +use crate::hwdb_parser::Hwdb; +use crate::rules::SubstContext; + +pub type BuiltinFn = fn(&[String], &mut SubstContext, &[(String, Hwdb)]) -> Result<(), std::io::Error>; + +mod hwdb; +mod usb_id; +mod path_id; +mod net_driver; +mod blkid; + +pub use hwdb::run as hwdb_run; +pub use usb_id::run as usb_id_run; +pub use path_id::run as path_id_run; +pub use net_driver::run as net_driver_run; +pub use blkid::run as blkid_run; + +/// Register builtin handlers into a HashMap. +pub fn register_builtins() -> HashMap { + let mut m: HashMap = HashMap::new(); + m.insert("hwdb".to_string(), hwdb_run as BuiltinFn); + m.insert("usb_id".to_string(), usb_id_run as BuiltinFn); + m.insert("path_id".to_string(), path_id_run as BuiltinFn); + m.insert("net_driver".to_string(), net_driver_run as BuiltinFn); + m.insert("blkid".to_string(), blkid_run as BuiltinFn); + m +} diff --git a/lib/udev-core/src/builtins/net_driver.rs b/lib/udev-core/src/builtins/net_driver.rs new file mode 100644 index 0000000..ad92e63 --- /dev/null +++ b/lib/udev-core/src/builtins/net_driver.rs @@ -0,0 +1,19 @@ +use crate::hwdb_parser::Hwdb; +use crate::rules::SubstContext; + +pub fn run(_args: &[String], ctx: &mut SubstContext, _hwdb_files: &[(String, Hwdb)]) -> Result<(), std::io::Error> { + let sysfs = crate::device::sysfs_path(&ctx.devpath); + let driver_path = sysfs.join("driver"); + if driver_path.exists() { + if let Ok(canon) = driver_path.canonicalize() { + if let Some(name) = canon.file_name().map(|n| n.to_string_lossy().to_string()) { + ctx.env.insert("ID_NET_DRIVER".to_string(), name); + } + } + } + if let Some(mac) = crate::device::read_sysfs_attr(&ctx.devpath, "address") { + let mac_norm = mac.trim().to_lowercase(); + ctx.env.insert("ID_NET_NAME_MAC".to_string(), mac_norm); + } + Ok(()) +} diff --git a/lib/udev-core/src/builtins/path_id.rs b/lib/udev-core/src/builtins/path_id.rs new file mode 100644 index 0000000..50ccd5a --- /dev/null +++ b/lib/udev-core/src/builtins/path_id.rs @@ -0,0 +1,38 @@ +use crate::hwdb_parser::Hwdb; +use crate::rules::SubstContext; +use std::path::Path; + +pub fn run(_args: &[String], ctx: &mut SubstContext, _hwdb_files: &[(String, Hwdb)]) -> Result<(), std::io::Error> { + let sysfs = crate::device::sysfs_path(&ctx.devpath); + + if let Ok(ue) = std::fs::read_to_string(sysfs.join("uevent")) { + for line in ue.lines() { + if let Some(val) = line.strip_prefix("PCI_SLOT_NAME=") { + ctx.env.insert("ID_PATH".to_string(), format!("pci-{}", val.trim())); + return Ok(()); + } + } + } + + if let Some(modalias) = crate::device::read_sysfs_attr(&ctx.devpath, "modalias") { + let token = modalias.split(':').next().unwrap_or(&modalias).to_string(); + ctx.env.insert("ID_PATH".to_string(), token); + return Ok(()); + } + + let mut p = sysfs.clone(); + while let Some(parent) = p.parent() { + if parent.join("busnum").exists() || parent.join("devnum").exists() { + let rel = parent.strip_prefix("/sys").unwrap_or(&parent).to_string_lossy().trim_start_matches('/').replace('/', "-"); + ctx.env.insert("ID_PATH".to_string(), format!("usb-{}", rel)); + return Ok(()); + } + p = parent.to_path_buf(); + if p == Path::new("/sys") { break; } + } + + let encoded = ctx.devpath.trim_start_matches('/').replace('/', "-"); + let path_id = if !ctx.subsystem.is_empty() { format!("{}-{}", ctx.subsystem, encoded) } else { encoded }; + if !path_id.is_empty() { ctx.env.insert("ID_PATH".to_string(), path_id); } + Ok(()) +} diff --git a/lib/udev-core/src/builtins/usb_id.rs b/lib/udev-core/src/builtins/usb_id.rs new file mode 100644 index 0000000..f0ec7c2 --- /dev/null +++ b/lib/udev-core/src/builtins/usb_id.rs @@ -0,0 +1,41 @@ +use crate::hwdb_parser::Hwdb; +use crate::rules::SubstContext; + +pub fn run(_args: &[String], ctx: &mut SubstContext, hwdb_files: &[(String, Hwdb)]) -> Result<(), std::io::Error> { + let sysfs = crate::device::sysfs_path(&ctx.devpath); + let vendor = crate::device::read_sysfs_attr(&ctx.devpath, "idVendor").or_else(|| crate::device::read_parent_attr(&sysfs, "idVendor")); + let product = crate::device::read_sysfs_attr(&ctx.devpath, "idProduct").or_else(|| crate::device::read_parent_attr(&sysfs, "idProduct")); + let manufacturer = crate::device::read_sysfs_attr(&ctx.devpath, "manufacturer").or_else(|| crate::device::read_parent_attr(&sysfs, "manufacturer")); + let product_name = crate::device::read_sysfs_attr(&ctx.devpath, "product").or_else(|| crate::device::read_parent_attr(&sysfs, "product")); + let serial = crate::device::read_sysfs_attr(&ctx.devpath, "serial").or_else(|| crate::device::read_parent_attr(&sysfs, "serial")); + let bcd = crate::device::read_sysfs_attr(&ctx.devpath, "bcdDevice").or_else(|| crate::device::read_parent_attr(&sysfs, "bcdDevice")); + + if let Some(v) = vendor.clone() { ctx.env.insert("ID_VENDOR_ID".to_string(), v.clone()); } + if let Some(p) = product.clone() { ctx.env.insert("ID_MODEL_ID".to_string(), p.clone()); } + + if let (Some(v), Some(p)) = (vendor.clone(), product.clone()) { + let pattern = format!("usb:v{}p{}*", v, p); + let parts = vec![pattern]; + let _ = crate::builtins::hwdb_run(&parts, ctx, hwdb_files); + } + + if ctx.env.get("ID_VENDOR").is_none() { + if let Some(m) = manufacturer { ctx.env.insert("ID_VENDOR".to_string(), m); } + } + if ctx.env.get("ID_MODEL").is_none() { + if let Some(n) = product_name { ctx.env.insert("ID_MODEL".to_string(), n); } + } + + if let Some(s) = serial { + ctx.env.insert("ID_SERIAL_SHORT".to_string(), s.clone()); + if let (Some(vid), Some(mid)) = (ctx.env.get("ID_VENDOR_ID"), ctx.env.get("ID_MODEL_ID")) { + let composed = format!("{}:{}:{}", vid, mid, s); + ctx.env.insert("ID_SERIAL".to_string(), composed); + } + } + + if let Some(rv) = bcd { ctx.env.insert("ID_REVISION".to_string(), rv); } + ctx.env.insert("ID_BUS".to_string(), "usb".to_string()); + + Ok(()) +} diff --git a/lib/udev-core/src/config.rs b/lib/udev-core/src/config.rs new file mode 100644 index 0000000..94da6eb --- /dev/null +++ b/lib/udev-core/src/config.rs @@ -0,0 +1,140 @@ +//! Configuration loading — scans standard udev directories for rules and hwdb files. + +use std::fs; +use std::path::{Path, PathBuf}; +use crate::hwdb_parser::{self, Hwdb}; +use crate::rule_parser::{self, RulesFile}; + +/// Standard udev rules directories (in order of precedence, higher=stronger). +const RULES_DIRS: &[&str] = &[ + "/etc/udev/rules.d", + "/run/udev/rules.d", + "/usr/lib/udev/rules.d", +]; + +/// Standard udev hwdb directories (in order of precedence). +const HWDB_DIRS: &[&str] = &[ + "/etc/udev/hwdb.d", + "/usr/lib/udev/hwdb.d", +]; + +/// Runtime udev directory. +pub const RUN_UDEV_DIR: &str = "/run/udev"; + +// ── Loaded configuration ──────────────────────────────────────────────── + +/// All loaded configuration state. +pub struct Config { + /// Parsed rules from all directories (name, RulesFile). + pub rules: Vec<(String, RulesFile)>, + /// Parsed hwdb entries (name, Hwdb). + pub hwdb: Vec<(String, Hwdb)>, + /// Environment variables for udev rules. + pub environment: std::collections::HashMap, +} + +impl Config { + /// Load rules and hwdb from all standard directories. + pub fn load() -> Self { + let rules = Self::load_rules(); + let hwdb = Self::load_hwdb(); + tracing::info!( + "config loaded: {} rule files, {} hwdb files", + rules.len(), + hwdb.len() + ); + Config { + rules, + hwdb, + environment: std::collections::HashMap::new(), + } + } + + fn load_rules() -> Vec<(String, RulesFile)> { + let mut all_rules = Vec::new(); + for dir in RULES_DIRS { + let dir_path = Path::new(dir); + if !dir_path.is_dir() { + continue; + } + let mut entries: Vec = match fs::read_dir(dir_path) { + Ok(rd) => rd.filter_map(|e| e.ok().map(|e| e.path())).collect(), + Err(_) => continue, + }; + entries.sort(); + for entry in &entries { + if entry.extension().and_then(|s| s.to_str()) != Some("rules") { + continue; + } + let content = match fs::read_to_string(entry) { + Ok(c) => c, + Err(e) => { + tracing::warn!("cannot read {:?}: {}", entry, e); + continue; + } + }; + match rule_parser::parse_rules(&content) { + Ok(rf) => { + let name = entry.file_name().unwrap().to_string_lossy().to_string(); + tracing::debug!("loaded rules: {} ({} rules)", name, rf.rules.len()); + all_rules.push((name, rf)); + } + Err(e) => { + tracing::warn!("parse error in {:?}: {}", entry, e); + } + } + } + } + all_rules + } + + fn load_hwdb() -> Vec<(String, Hwdb)> { + let mut all_hwdb = Vec::new(); + for dir in HWDB_DIRS { + let dir_path = Path::new(dir); + if !dir_path.is_dir() { + continue; + } + let mut entries: Vec = match fs::read_dir(dir_path) { + Ok(rd) => rd.filter_map(|e| e.ok().map(|e| e.path())).collect(), + Err(_) => continue, + }; + entries.sort(); + for entry in &entries { + if entry.extension().and_then(|s| s.to_str()) != Some("hwdb") { + continue; + } + let content = match fs::read_to_string(entry) { + Ok(c) => c, + Err(e) => { + tracing::warn!("cannot read {:?}: {}", entry, e); + continue; + } + }; + match hwdb_parser::parse_hwdb(&content) { + Ok(hwdb) => { + let name = entry.file_name().unwrap().to_string_lossy().to_string(); + tracing::debug!("loaded hwdb: {} ({} records)", name, hwdb.records.len()); + all_hwdb.push((name, hwdb)); + } + Err(e) => { + tracing::warn!("parse error in {:?}: {}", entry, e); + } + } + } + } + all_hwdb + } + + /// Reload configuration from disk. + pub fn reload(&mut self) { + tracing::info!("reloading configuration..."); + self.rules = Self::load_rules(); + self.hwdb = Self::load_hwdb(); + tracing::info!( + "reload complete: {} rule files, {} hwdb files", + self.rules.len(), + self.hwdb.len() + ); + } +} diff --git a/lib/udev-core/src/device.rs b/lib/udev-core/src/device.rs new file mode 100644 index 0000000..5d8feae --- /dev/null +++ b/lib/udev-core/src/device.rs @@ -0,0 +1,277 @@ +//! Device node management. +//! +//! Creates and removes device nodes in `/dev`, sets permissions, ownership, +//! and manages symlinks — mirroring systemd-udevd behaviour. + +use std::ffi::CString; +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use thiserror::Error; + +// ── Error type ────────────────────────────────────────────────────────── + +/// Errors from device node operations. +#[derive(Error, Debug)] +pub enum DevNodeError { + #[error("failed to create device node {path}: {source}")] + Create { path: String, #[source] source: io::Error }, + + #[error("failed to remove {path}: {source}")] + Remove { path: String, #[source] source: io::Error }, + + #[error("failed to set permissions on {path}: {source}")] + Chmod { path: String, #[source] source: io::Error }, + + #[error("failed to chown {path}: {source}")] + Chown { path: String, #[source] source: io::Error }, + + #[error("failed to create symlink {link} -> {target}: {source}")] + Symlink { link: String, target: String, #[source] source: io::Error }, + + #[error("failed to read sysfs attr {path}: {source}")] + SysfsRead { path: String, #[source] source: io::Error }, + + #[error("failed to create directory {path}: {source}")] + Mkdir { path: String, #[source] source: io::Error }, + + #[error("syscall error: {0}")] + Syscall(#[source] io::Error), +} + +// ── Device type ───────────────────────────────────────────────────────── + +/// Device type for `mknod`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DevType { + Block, + Char, +} + +impl FromStr for DevType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "block" => Ok(DevType::Block), + _ => Ok(DevType::Char), + } + } +} + +// ── Sysfs helpers ─────────────────────────────────────────────────────── + +/// Read a single sysfs attribute value, trimmed. +pub fn read_sysfs_attr(devpath: &str, attr: &str) -> Option { + let p = Path::new("/sys") + .join(devpath.trim_start_matches('/')) + .join(attr); + fs::read_to_string(&p).ok().map(|s| s.trim().to_string()) +} + +/// Walk up device parents to find a sysfs attribute (for `ATTRS{…}` matches). +pub fn read_parent_attr(mut sysfs_path: &Path, attr: &str) -> Option { + loop { + let attr_path = sysfs_path.join(attr); + if let Ok(val) = fs::read_to_string(&attr_path) { + return Some(val.trim().to_string()); + } + sysfs_path = sysfs_path.parent()?; + if sysfs_path == Path::new("/sys") || sysfs_path == Path::new("/") { + return None; + } + } +} + +/// Build an absolute sysfs path from a DEVPATH. +pub fn sysfs_path(devpath: &str) -> PathBuf { + Path::new("/sys").join(devpath.trim_start_matches('/')) +} + +// ── Device node operations ─────────────────────────────────────────────── + +/// Create a device node via `mknod(2)`. +/// +/// Removes any existing file at `path` first. Creates parent directories as +/// needed. +pub fn mknod(path: &Path, dev_type: DevType, major: u32, minor: u32, mode: u32) -> Result<(), DevNodeError> { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| DevNodeError::Mkdir { + path: parent.display().to_string(), + source: e, + })?; + } + // Remove stale node + let _ = fs::remove_file(path); + + let dev = libc::makedev(major, minor); + let libc_mode = mode + | match dev_type { + DevType::Block => libc::S_IFBLK, + DevType::Char => libc::S_IFCHR, + }; + + let cpath = CString::new(path.to_string_lossy().as_bytes()).map_err(|_| { + DevNodeError::Create { + path: path.display().to_string(), + source: io::Error::new(io::ErrorKind::InvalidInput, "embedded NUL"), + } + })?; + + let ret = unsafe { libc::mknod(cpath.as_ptr(), libc_mode as libc::mode_t, dev) }; + if ret < 0 { + return Err(DevNodeError::Create { + path: path.display().to_string(), + source: io::Error::last_os_error(), + }); + } + Ok(()) +} + +/// Set file permissions (chmod). +pub fn chmod(path: &Path, mode: u32) -> Result<(), DevNodeError> { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|e| DevNodeError::Chmod { + path: path.display().to_string(), + source: e, + }) +} + +/// Change file ownership (chown). +pub fn chown(path: &Path, uid: u32, gid: u32) -> Result<(), DevNodeError> { + let cpath = CString::new(path.to_string_lossy().as_bytes()).map_err(|_| { + DevNodeError::Chown { + path: path.display().to_string(), + source: io::Error::new(io::ErrorKind::InvalidInput, "embedded NUL"), + } + })?; + let ret = unsafe { libc::chown(cpath.as_ptr(), uid, gid) }; + if ret < 0 { + return Err(DevNodeError::Chown { + path: path.display().to_string(), + source: io::Error::last_os_error(), + }); + } + Ok(()) +} + +/// Create a symbolic link, removing any existing file at `link`. +pub fn symlink(target: &Path, link: &Path) -> Result<(), DevNodeError> { + if let Some(parent) = link.parent() { + fs::create_dir_all(parent).map_err(|e| DevNodeError::Mkdir { + path: parent.display().to_string(), + source: e, + })?; + } + let _ = fs::remove_file(link); + std::os::unix::fs::symlink(target, link).map_err(|e| DevNodeError::Symlink { + link: link.display().to_string(), + target: target.display().to_string(), + source: e, + }) +} + +/// Remove a device node (or symlink). +pub fn remove(path: &Path) -> Result<(), DevNodeError> { + fs::remove_file(path).map_err(|e| DevNodeError::Remove { + path: path.display().to_string(), + source: e, + }) +} + +/// Remove a device node along with all its symlinks. +pub fn remove_device_and_links(devnode: &Path, symlinks: &[PathBuf]) { + let _ = fs::remove_file(devnode); + for link in symlinks { + let _ = fs::remove_file(link); + } +} + +// ── User/group resolution ─────────────────────────────────────────────── + +/// Look up a username or numeric UID → uid. +pub fn resolve_user(user: &str) -> Option { + if let Ok(uid) = user.parse::() { + return Some(uid); + } + let c = CString::new(user).ok()?; + let pwd = unsafe { libc::getpwnam(c.as_ptr()) }; + if pwd.is_null() { + return None; + } + Some(unsafe { (*pwd).pw_uid }) +} + +/// Look up a group name or numeric GID → gid. +pub fn resolve_group(group: &str) -> Option { + if let Ok(gid) = group.parse::() { + return Some(gid); + } + let c = CString::new(group).ok()?; + let grp = unsafe { libc::getgrnam(c.as_ptr()) }; + if grp.is_null() { + return None; + } + Some(unsafe { (*grp).gr_gid }) +} + +// ── /dev path derivation ──────────────────────────────────────────────── + +/// Derive the `/dev/…` path for a device, given its `DEVPATH`, `SUBSYSTEM`, +/// and optional explicit `NAME` override. +pub fn devpath_from_uevent( + devpath: &str, + subsystem: Option<&str>, + name_override: Option<&str>, +) -> PathBuf { + if let Some(name) = name_override { + if name == "/dev/null" { + return PathBuf::from("/dev/null"); + } + if name.starts_with('/') { + return PathBuf::from(name); + } + return PathBuf::from("/dev").join(name); + } + + // Try DEVNAME from uevent file + let sysfs = sysfs_path(devpath); + if let Ok(content) = fs::read_to_string(sysfs.join("uevent")) { + for line in content.lines() { + if let Some(devname) = line.strip_prefix("DEVNAME=") { + let dn = devname.trim(); + if dn.starts_with('/') { + return PathBuf::from(dn); + } + return PathBuf::from("/dev").join(dn); + } + } + } + + let basename = Path::new(devpath) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".into()); + + match subsystem { + Some("block") => PathBuf::from("/dev").join(basename), + Some("input") => PathBuf::from("/dev/input").join(basename), + Some("drm") => PathBuf::from("/dev/dri").join(basename), + Some("snd" | "sound") => PathBuf::from("/dev/snd").join(basename), + Some("usb") => PathBuf::from("/dev/bus/usb").join(basename), + Some("tty") => { + if basename.starts_with("tty") { + PathBuf::from("/dev").join(basename) + } else { + PathBuf::from("/dev/").join(basename) + } + } + Some("net") => PathBuf::from("/dev").join(basename), // no device node for net + Some("vc") | Some("vtconsole") => PathBuf::from("/dev").join(basename), + Some("video4linux") => PathBuf::from("/dev").join(basename), + _ => PathBuf::from("/dev").join(basename), + } +} diff --git a/lib/udev-core/src/hwdb_parser.rs b/lib/udev-core/src/hwdb_parser.rs new file mode 100644 index 0000000..f2957eb --- /dev/null +++ b/lib/udev-core/src/hwdb_parser.rs @@ -0,0 +1,663 @@ +//! Udev hwdb (hardware database) file parser — PEG grammar via the [`peg`] crate. +//! +//! Parses `*.hwdb` files with full support for: +//! +//! | Feature | Example | +//! |------------------------|--------------------------------------| +//! | Full‑line comments | `# This is a comment` | +//! | Trailing comments | ` ID_VENDOR=foo # comment` | +//! | Match patterns (globs) | `usb:v*p*d*dc*dsc*dp*ic*isc*ip*` | +//! | Property key‑value | ` ID_VENDOR=Some_Vendor` | +//! | Multi‑value continue | ` KEY=long value` + ` more text` | +//! | Blank‑line separators | (empty lines between records) | +//! +//! Every AST node carries a `span: Range` for diagnostic reporting. + +use std::fmt; +use std::ops::Range; +use thiserror::Error; + +// ========================================================================= +// Error types +// ========================================================================= + +/// Top-level parse error with source location metadata. +#[derive(Error, Debug, Clone, PartialEq, Eq)] +#[error("parse error at line {line}, column {column}: {kind}")] +pub struct HwdbParseError { + pub line: u32, + pub column: usize, + pub offset: usize, + pub kind: HwdbParseErrorKind, + /// ~40 characters around the error site. + pub context: String, +} + +impl HwdbParseError { + /// Convert a PEG parse error into our higher-level error type. + fn from_peg(input: &str, err: peg::error::ParseError) -> Self { + let offset = err.location.offset; + let (line, col) = offset_to_line_column(input, offset); + + let tokens: Vec = + err.expected.tokens().map(|s| format!("`{s}`")).collect(); + let kind = if tokens.is_empty() { + HwdbParseErrorKind::Other("unexpected input".into()) + } else { + HwdbParseErrorKind::Expected { + expected: tokens.join(" or "), + } + }; + + let ctx_start = offset.saturating_sub(20); + let ctx_end = (offset + 20).min(input.len()); + let context = input[ctx_start..ctx_end].to_string(); + + Self { + line, + column: col, + offset, + kind, + context, + } + } +} + +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum HwdbParseErrorKind { + #[error("expected {expected}")] + Expected { expected: String }, + #[error("{0}")] + Other(String), +} + +// ========================================================================= +// AST types +// ========================================================================= + +/// A complete parsed hwdb file: an ordered list of [`Record`]s. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hwdb { + pub records: Vec, +} + +/// One logical hwdb record: one or more match expressions plus zero or more +/// properties. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Record { + /// Shell‑glob patterns that select which devices this record applies to. + pub matches: Vec, + /// Key‑value properties associated with this record. + pub properties: Vec, + /// Byte‑offset span covering the entire record (from first match to end + /// of last property). + pub span: Range, +} + +/// A single match pattern line (shell glob). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchEntry { + /// The glob pattern, e.g. `"usb:v*p*d*dc*dsc*dp*ic*isc*ip*"`. + pub pattern: String, + pub span: Range, +} + +/// A single property key‑value pair. +/// +/// When a record uses continuation lines (indented lines without `=`), they +/// are folded into the preceding [`Property`]'s `value` field, joined by a +/// single space. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Property { + /// Property key, e.g. `"ID_VENDOR"`. + pub key: String, + /// Property value (with continuations joined). + pub value: String, + pub span: Range, + /// Whether this was originally a continuation line (no `=` sign). + /// This is `false` for normal `KEY=VALUE` lines. + pub is_continuation: bool, +} + +impl fmt::Display for Property { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_continuation { + write!(f, " {}", self.value) + } else { + write!(f, " {}={}", self.key, self.value) + } + } +} + +// ========================================================================= +// Helper: strip trailing `#` comments (same semantics as systemd) +// ========================================================================= + +/// Strip everything from the first `#` character onward (the `#` itself is +/// **not** kept). Returns a sub‑slice of the input. +/// +/// This mirrors systemd's behaviour in `hwdb-util.c`: +/// ```c +/// pos = strchr(line, '#'); +/// if (pos) +/// pos[0] = '\0'; +/// ``` +pub(crate) fn strip_trailing_comment(s: &str) -> &str { + if let Some(pos) = s.find('#') { + &s[..pos] + } else { + s + } +} + +// ========================================================================= +// Helper: byte-offset → (line, column) +// ========================================================================= + +pub(crate) fn offset_to_line_column(input: &str, offset: usize) -> (u32, usize) { + let offset = offset.min(input.len()); + let preceding = &input[..offset]; + let line = preceding.chars().filter(|c| *c == '\n').count() as u32 + 1; + let last_newline = preceding.rfind('\n').map(|i| i + 1).unwrap_or(0); + let column = offset - last_newline + 1; + (line, column) +} + +// ========================================================================= +// PEG grammar (inlined via `peg::parser!`) +// ========================================================================= +// +// Grammar follows the same semantics as systemd's hwdb-util.c: +// +// - `#` at the beginning of a line → full-line comment, skipped entirely +// - `#` anywhere else → trailing comment, stripped +// - blank lines → record separators +// - lines NOT starting with ws → match patterns (shell globs) +// - lines starting with ws → property KEY=VALUE +// - indented lines without `=` → value continuation of previous property +// +// Reference: https://github.com/systemd/systemd/blob/main/src/shared/hwdb-util.c + +peg::parser! { + grammar hwdb_parser_impl() for str { + // ── Whitespace & newlines ──────────────────────────────────────── + rule ws() = quiet!{[' ' | '\t']*} + + rule ws1() = quiet!{[' ' | '\t']+} + + rule newline() = "\n" / "\r\n" / "\r" + + /// Non-newline characters (zero or more, also at EOF). + rule rest_of_line() -> &'input str + = $( (!newline() [_] )* ) + + /// Non-newline characters, at least one (for match patterns). + rule match_body() -> &'input str + = $( (!newline() [_] )+ ) + + // ── Structural tokens ──────────────────────────────────────────── + rule blank_line() = ws() newline() + + rule comment_line() = "#" rest_of_line()? newline() + + rule separator() = blank_line() / comment_line() + + rule skip_sep() = separator()* + + // ── Match line ─────────────────────────────────────────────────── + rule match_line() -> MatchEntry + = !([' ' | '\t']) !(['#']) !(newline()) + start:position!() + raw:match_body() + end:position!() + newline() + { + let trimmed = strip_trailing_comment(raw).trim_end(); + MatchEntry { + pattern: trimmed.to_string(), + span: start..end, + } + } + + // ── Property line ──────────────────────────────────────────────── + rule key_char() -> char + = quiet!{[^ '=' | ' ' | '\t' | '\n' | '\r']} + + rule property_line() -> Property + = ws() + start:position!() + key:$( key_char()+ ) + "=" + raw_val:rest_of_line() + end:position!() + newline()? + { + let value = strip_trailing_comment(raw_val).trim_end().to_string(); + Property { + key: key.to_string(), + value, + span: start..end, + is_continuation: false, + } + } + + // ── Continuation line ──────────────────────────────────────────── + rule continuation_line() -> Property + = ws1() + start:position!() + val:rest_of_line() + end:position!() + newline()? + { + let value = strip_trailing_comment(val).trim_end().to_string(); + Property { + key: String::new(), + value, + span: start..end, + is_continuation: true, + } + } + + // ── Record ─────────────────────────────────────────────────────── + pub rule record() -> Record + = start:position!() + matches:match_line()+ + lines:(property_line() / continuation_line())* + end:position!() + { + let mut properties: Vec = Vec::new(); + for line in lines { + if line.is_continuation { + if let Some(last) = properties.last_mut() { + if !last.value.is_empty() && !line.value.is_empty() { + last.value.push(' '); + } + last.value.push_str(&line.value); + last.span = last.span.start .. line.span.end; + } + } else { + properties.push(line); + } + } + Record { matches, properties, span: start..end } + } + + // ── Top-level file ─────────────────────────────────────────────── + pub rule hwdb_file() -> Hwdb + = skip_sep() + records:(r:record() skip_sep() { r })* + ws() + ![_] + { + Hwdb { records } + } + } +} + +// ========================================================================= +// Public API +// ========================================================================= + +/// Parse a complete hwdb file from its text content. +/// +/// Returns `Ok(Hwdb)` on success, or `Err(HwdbParseError)` with detailed +/// location information on failure. +pub fn parse_hwdb(input: &str) -> Result { + hwdb_parser_impl::hwdb_file(input).map_err(|e| HwdbParseError::from_peg(input, e)) +} + +// ========================================================================= +// Convenience: query helpers +// ========================================================================= + +impl Hwdb { + /// Iterate over all properties across all records that have the given key. + pub fn properties_for_key<'a>(&'a self, key: &'a str) -> impl Iterator + 'a { + self.records + .iter() + .flat_map(|r| r.properties.iter()) + .filter(move |p| p.key == key) + } + + /// Find all records whose match patterns contain the given substring. + pub fn records_matching<'a>(&'a self, substr: &'a str) -> impl Iterator + 'a { + self.records.iter().filter(move |r| { + r.matches.iter().any(|m| m.pattern.contains(substr)) + }) + } +} + +impl Record { + /// Look up the value of a property by key. + /// + /// If multiple properties have the same key, the *last* one wins (matching + /// systemd's override semantics: later files/entries override earlier). + pub fn get(&self, key: &str) -> Option<&str> { + self.properties + .iter() + .rev() + .find(|p| p.key == key) + .map(|p| p.value.as_str()) + } + + /// Check whether a property with the given key exists. + pub fn contains_key(&self, key: &str) -> bool { + self.properties.iter().any(|p| p.key == key) + } +} + +// ========================================================================= +// Tests +// ========================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------- + // Edge cases: empty / trivial inputs + // ----------------------------------------------------------------- + + #[test] + fn test_parse_empty_input() { + let h = parse_hwdb("").unwrap(); + assert!(h.records.is_empty()); + } + + #[test] + fn test_parse_only_whitespace() { + let h = parse_hwdb(" \n \n ").unwrap(); + assert!(h.records.is_empty()); + } + + #[test] + fn test_parse_only_comments() { + let h = parse_hwdb("# just a comment\n# another\n").unwrap(); + assert!(h.records.is_empty()); + } + + #[test] + fn test_parse_mixed_blank_and_comments() { + let h = parse_hwdb("# comment\n\n \n# more\n").unwrap(); + assert!(h.records.is_empty()); + } + + // ----------------------------------------------------------------- + // Single record + // ----------------------------------------------------------------- + + #[test] + fn test_single_match_no_properties() { + // systemd warns about records without properties; we still accept + // them in the AST. + let h = parse_hwdb("usb:v1234p5678*\n").unwrap(); + assert_eq!(h.records.len(), 1); + assert_eq!(h.records[0].matches.len(), 1); + assert_eq!(h.records[0].matches[0].pattern, "usb:v1234p5678*"); + assert!(h.records[0].properties.is_empty()); + } + + #[test] + fn test_single_match_with_property() { + let input = "usb:v*p*d*\n ID_VENDOR=Some_Vendor\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records.len(), 1); + assert_eq!(h.records[0].matches.len(), 1); + assert_eq!(h.records[0].properties.len(), 1); + assert_eq!(h.records[0].properties[0].key, "ID_VENDOR"); + assert_eq!(h.records[0].properties[0].value, "Some_Vendor"); + } + + #[test] + fn test_multiple_matches_single_record() { + let input = "usb:v1234*\nusb:v5678*\n ID_MODEL=Test\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records.len(), 1); + assert_eq!(h.records[0].matches.len(), 2); + assert_eq!(h.records[0].matches[0].pattern, "usb:v1234*"); + assert_eq!(h.records[0].matches[1].pattern, "usb:v5678*"); + assert_eq!(h.records[0].properties.len(), 1); + } + + #[test] + fn test_multiple_properties() { + let input = "usb:v1234*\n ID_VENDOR=Foo\n ID_MODEL=Bar\n ID_REVISION=1.0\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].properties.len(), 3); + assert_eq!(h.records[0].properties[0].key, "ID_VENDOR"); + assert_eq!(h.records[0].properties[1].key, "ID_MODEL"); + assert_eq!(h.records[0].properties[2].key, "ID_REVISION"); + } + + // ----------------------------------------------------------------- + // Multiple records + // ----------------------------------------------------------------- + + #[test] + fn test_two_records() { + let input = "usb:v1234*\n KEY=A\n\nusb:v5678*\n KEY=B\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records.len(), 2); + assert_eq!(h.records[0].matches[0].pattern, "usb:v1234*"); + assert_eq!(h.records[0].get("KEY"), Some("A")); + assert_eq!(h.records[1].matches[0].pattern, "usb:v5678*"); + assert_eq!(h.records[1].get("KEY"), Some("B")); + } + + #[test] + fn test_records_separated_by_comments() { + let input = "usb:v1*\n K=V1\n# separator comment\nusb:v2*\n K=V2\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records.len(), 2); + assert_eq!(h.records[0].get("K"), Some("V1")); + assert_eq!(h.records[1].get("K"), Some("V2")); + } + + #[test] + fn test_records_no_trailing_newline() { + let input = "usb:v1*\n K=V1\n\nusb:v2*\n K=V2"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records.len(), 2); + } + + // ----------------------------------------------------------------- + // Trailing comments + // ----------------------------------------------------------------- + + #[test] + fn test_trailing_comment_on_match() { + let input = "usb:v1234* # my comment\n KEY=val\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].matches[0].pattern, "usb:v1234*"); + } + + #[test] + fn test_trailing_comment_on_property() { + let input = "usb:v1*\n KEY=val # inline comment\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].properties[0].value, "val"); + } + + #[test] + fn test_hash_in_value_is_treated_as_comment() { + // systemd strips everything after `#`, so a value containing `#` is + // truncated. This matches the reference implementation. + let input = "usb:v1*\n KEY=val#42\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].properties[0].value, "val"); + } + + // ----------------------------------------------------------------- + // Continuation lines (non-standard but commonly used) + // ----------------------------------------------------------------- + + #[test] + fn test_continuation_line_folded_into_previous_property() { + let input = "usb:v1*\n KEY=hello\n world\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].properties.len(), 1); + assert_eq!(h.records[0].properties[0].key, "KEY"); + assert_eq!(h.records[0].properties[0].value, "hello world"); + } + + #[test] + fn test_continuation_empty_after_comment_strip() { + // A continuation line that becomes empty after comment stripping + // should still be handled gracefully (folded as empty string → no-op). + let input = "usb:v1*\n KEY=hello\n # comment only\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].properties.len(), 1); + assert_eq!(h.records[0].properties[0].value, "hello"); + } + + #[test] + fn test_continuation_without_prior_property_dropped() { + // An indented line without `=` before any property is dropped + // (systemd warns about its syntax and discards it). + let input = "usb:v1*\n orphan continuation\n KEY=val\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].properties.len(), 1); + assert_eq!(h.records[0].properties[0].key, "KEY"); + } + + // ----------------------------------------------------------------- + // Property value contains `=` (e.g. EV=120013) + // ----------------------------------------------------------------- + + #[test] + fn test_value_containing_equals() { + let input = "usb:v1*\n KEY=val=ue\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].properties[0].key, "KEY"); + assert_eq!(h.records[0].properties[0].value, "val=ue"); + } + + // ----------------------------------------------------------------- + // Real-world inspired examples + // ----------------------------------------------------------------- + + #[test] + fn test_keyboard_hwdb_entry() { + let input = "evdev:input:b*v*\nevdev:input:b*v0002*\n KEYBOARD_KEY_100=abc\n KEYBOARD_KEY_101=xyz\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].matches.len(), 2); + assert_eq!(h.records[0].properties.len(), 2); + assert_eq!(h.records[0].properties[0].key, "KEYBOARD_KEY_100"); + assert_eq!(h.records[0].properties[0].value, "abc"); + } + + #[test] + fn test_usb_hwdb_entry() { + let input = "usb:v*p*d*dc*dsc*dp*ic*isc*ip*\nusb:v1234p5678*\n ID_VENDOR=ExampleCorp\n ID_MODEL=ExampleDevice\n ID_MODEL_ID=5678\n ID_VENDOR_ID=1234\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].matches.len(), 2); + assert_eq!(h.records[0].properties.len(), 4); + assert_eq!(h.records[0].get("ID_VENDOR"), Some("ExampleCorp")); + } + + // ----------------------------------------------------------------- + // Query helpers + // ----------------------------------------------------------------- + + #[test] + fn test_properties_for_key() { + let input = "m1*\n KEY=val1\n\nm2*\n KEY=val2\n OTHER=abc\n"; + let h = parse_hwdb(input).unwrap(); + let props: Vec<&Property> = h.properties_for_key("KEY").collect(); + assert_eq!(props.len(), 2); + assert_eq!(props[0].value, "val1"); + assert_eq!(props[1].value, "val2"); + } + + #[test] + fn test_records_matching() { + let input = "usb:v1*\n K=V\n\npci:v2*\n K=V\n"; + let h = parse_hwdb(input).unwrap(); + let recs: Vec<&Record> = h.records_matching("usb").collect(); + assert_eq!(recs.len(), 1); + assert_eq!(recs[0].matches[0].pattern, "usb:v1*"); + } + + #[test] + fn test_contains_key() { + let input = "m1*\n FOO=bar\n BAZ=qux\n"; + let h = parse_hwdb(input).unwrap(); + assert!(h.records[0].contains_key("FOO")); + assert!(h.records[0].contains_key("BAZ")); + assert!(!h.records[0].contains_key("NONEXISTENT")); + } + + #[test] + fn test_get_returns_last_value() { + let input = "m1*\n KEY=first\n KEY=second\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].get("KEY"), Some("second")); + } + + // ----------------------------------------------------------------- + // Span sanity checks + // ----------------------------------------------------------------- + + #[test] + fn test_spans_are_non_empty() { + let input = "usb:v1*\n KEY=val\n"; + let h = parse_hwdb(input).unwrap(); + assert!(!h.records[0].span.is_empty()); + assert!(!h.records[0].matches[0].span.is_empty()); + assert!(!h.records[0].properties[0].span.is_empty()); + } + + #[test] + fn test_spans_cover_content() { + let input = "usb:v1*\n KEY=val\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(&input[h.records[0].span.clone()], "usb:v1*\n KEY=val\n"); + } + + // ----------------------------------------------------------------- + // Error handling + // ----------------------------------------------------------------- + + #[test] + fn test_parse_error_has_metadata() { + // This input is deliberately malformed: a property without a match. + // The grammar should fail in a way that we can report. + let input = " KEY=val\n"; + let err = parse_hwdb(input).unwrap_err(); + // The error should have some location info. + assert!(err.line >= 1); + assert!(err.column >= 1); + assert!(!err.context.is_empty()); + } + + // ----------------------------------------------------------------- + // Trailing whitespace handling + // ----------------------------------------------------------------- + + #[test] + fn test_match_with_trailing_spaces() { + let input = "usb:v1* \n KEY=val\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].matches[0].pattern, "usb:v1*"); + } + + #[test] + fn test_property_with_trailing_spaces() { + let input = "usb:v1*\n KEY=val \n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records[0].properties[0].value, "val"); + } + + // ----------------------------------------------------------------- + // Windows line endings + // ----------------------------------------------------------------- + + #[test] + fn test_windows_line_endings() { + let input = "usb:v1*\r\n KEY=val\r\n\r\nusb:v2*\r\n KEY=val2\r\n"; + let h = parse_hwdb(input).unwrap(); + assert_eq!(h.records.len(), 2); + assert_eq!(h.records[0].get("KEY"), Some("val")); + assert_eq!(h.records[1].get("KEY"), Some("val2")); + } +} diff --git a/lib/udev-core/src/lib.rs b/lib/udev-core/src/lib.rs new file mode 100644 index 0000000..8a64fbd --- /dev/null +++ b/lib/udev-core/src/lib.rs @@ -0,0 +1,13 @@ +//! udev-core — shared types and utilities for udev device management. +//! +//! This crate contains the common types, parsers, and logic shared between +//! the `lxdeviced` daemon and the `udevadm` CLI tool. + +pub mod uevent; +pub mod hwdb_parser; +pub mod rule_parser; +pub mod runtime; +pub mod config; +pub mod device; +pub mod rules; +pub mod builtins; diff --git a/lib/udev-core/src/rule_parser.rs b/lib/udev-core/src/rule_parser.rs new file mode 100644 index 0000000..ae552f6 --- /dev/null +++ b/lib/udev-core/src/rule_parser.rs @@ -0,0 +1,512 @@ +//! Udev rule file parser — PEG grammar via the [`peg`] crate. +//! +//! Supports the full udev rule syntax: +//! +//! | Category | Example | +//! |--------------------|------------------------------------------------| +//! | Match equality | `SUBSYSTEM=="usb"` | +//! | Match inequality | `ACTION!="remove"` | +//! | Assign | `MODE="0660"`, `GOTO=end` | +//! | Append | `SYMLINK+="modem"` | +//! | Final assign | `NAME:="eth0"` | +//! | Remove | `TAG-="systemd"`, `GOTO-=` | +//! | Key with attribute | `ATTR{idVendor}=="1234"`, `ENV{MY_VAR}="val"` | +//! | Line continuation | `\` at end of line | +//! | Comments | `#` through end of line | +//! +//! Every AST node carries a `span: Range` for diagnostic reporting. + +use std::fmt; +use std::ops::Range; +use thiserror::Error; + +// ========================================================================= +// Error types +// ========================================================================= + +/// Top-level parse error with source location metadata. +#[derive(Error, Debug, Clone, PartialEq, Eq)] +#[error("parse error at line {line}, column {column}: {kind}")] +pub struct ParseError { + pub line: u32, + pub column: usize, + pub offset: usize, + pub kind: ParseErrorKind, + /// ~40 characters around the error site. + pub context: String, +} + +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum ParseErrorKind { + #[error("expected {expected}")] + Expected { expected: String }, + #[error("{0}")] + Other(String), +} + +// ========================================================================= +// AST types +// ========================================================================= + +/// A parsed rules file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RulesFile { + pub rules: Vec, +} + +/// One logical udev rule (may span multiple physical lines via `\`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rule { + pub items: Vec, + pub span: Range, +} + +/// One `KEY OP "value"` fragment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleItem { + pub key: Key, + pub op: Operator, + pub value: Option, + pub span: Range, +} + +/// A key, e.g. `SUBSYSTEM` or `ATTR{idVendor}`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Key { + pub name: String, + pub attribute: Option, + pub span: Range, +} + +/// Operator between key and value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Operator { + MatchEqual, + MatchNotEqual, + Assign, + AssignAppend, + AssignFinal, + AssignRemove, +} + +impl fmt::Display for Operator { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Operator::MatchEqual => write!(f, "=="), + Operator::MatchNotEqual => write!(f, "!="), + Operator::Assign => write!(f, "="), + Operator::AssignAppend => write!(f, "+="), + Operator::AssignFinal => write!(f, ":="), + Operator::AssignRemove => write!(f, "-="), + } + } +} + +/// Right-hand-side value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + Quoted(String), + Bare(String), +} + +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::Quoted(s) => write!(f, "\"{s}\""), + Value::Bare(s) => write!(f, "{s}"), + } + } +} + +// ========================================================================= +// PEG grammar +// ========================================================================= + +peg::parser! { + grammar rule_grammar() for str { + + // ----- whitespace & structural ----- + /// Match horizontal whitespace (spaces, tabs). + rule __() = quiet!{[' ' | '\t']*} + + rule newline() = "\n" / "\r\n" / "\r" + + rule continuation() = __ ("\\" __ newline())* __ + + // ----- key ----- + rule key_name() -> &'input str + = $( ([ 'A'..='Z' | '_' ]+) ) + + rule attr_contents() -> &'input str + = $( ([^ '}' ]+) ) + + rule key() -> Key + = start:position!() + name:key_name() + attr:("{" a:attr_contents() "}" { a })? + end:position!() + { + Key { + name: name.to_string(), + attribute: attr.map(|s: &str| s.to_string()), + span: start..end, + } + } + + // ----- operator ----- + rule operator() -> Operator + = "!=" { Operator::MatchNotEqual } + / "==" { Operator::MatchEqual } + / "+=" { Operator::AssignAppend } + / ":=" { Operator::AssignFinal } + / "-=" { Operator::AssignRemove } + / "=" { Operator::Assign } + / expected!("operator (==, !=, =, +=, :=, -=)") + + // ----- values ----- + rule escape_seq() -> char + = "\\n" { '\n' } + / "\\t" { '\t' } + / "\\r" { '\r' } + / "\\\\" { '\\' } + / "\\\"" { '"' } + / "\\$" { '$' } + / "\\%" { '%' } + / "\\" c:quiet!{[_]} { c } + + rule quoted_fragment() -> char + = c:escape_seq() { c } + / c:quiet!{[^ '"' | '\\']} { c } + + rule quoted_string() -> Value + = "\"" frags:quoted_fragment()* "\"" + { Value::Quoted(frags.into_iter().collect()) } + / expected!("closing `\"`") + + rule bare_char() -> char + = quiet!{[^ ' ' | '\t' | ',' | '"' | '#' | '\\' | '\n' | '\r']} + + rule bare_value() -> Value + = s:$( (bare_char()+) ) { Value::Bare(s.to_string()) } + + rule value() -> Value + = quoted_string() / bare_value() + / expected!("value (quoted string or bare token)") + + rule opt_value() -> Option + = v:value() { Some(v) } + / continuation() &((",") / newline() / ![_]) { None } + + // ----- rule item ----- + rule item_with_value() -> RuleItem + = start:position!() + k:key() __ op:operator() __ v:value() + end:position!() + { RuleItem { key: k, op, value: Some(v), span: start..end } } + + rule item_optional_value() -> RuleItem + = start:position!() + k:key() __ "-=" __ v:opt_value() + end:position!() + { RuleItem { key: k, op: Operator::AssignRemove, value: v, span: start..end } } + + rule rule_item() -> RuleItem + = item_optional_value() + / item_with_value() + / expected!("rule item (e.g. KEY==\"value\" or KEY=\"val\")") + + // ----- rule ----- + rule item_sep() + = continuation() "," __ continuation() + + /// Comment text (starting with #) that does NOT consume the trailing newline. + rule comment_text() + = "#" [^ '\n']* + + rule udev_rule() -> Rule + = start:position!() + __ + items:(rule_item() ++ item_sep()) + __ + comment_text()? + end:position!() + { Rule { items, span: start..end } } + + // ----- file ----- + /// Separator between rules: a newline followed by optional blank/comment-only lines. + rule rule_sep() + = newline() blank_or_comment()* + + /// A blank line or a comment-only line (consumes the trailing newline). + rule blank_or_comment() + = comment_text() newline() + / __ newline() + + rule skip_blanks() + = blank_or_comment()* + + pub rule rules_file() -> RulesFile + = skip_blanks() + head:udev_rule() + tail:(rule_sep() r:udev_rule() { r })* + (rule_sep() / ()) + ![_] + { let mut rules = vec![head]; rules.extend(tail); RulesFile { rules } } + / skip_blanks() + ![_] + { RulesFile { rules: vec![] } } + } +} + +// ========================================================================= +// Error conversion +// ========================================================================= + +fn offset_to_line_column(input: &str, offset: usize) -> (u32, usize) { + let offset = offset.min(input.len()); + let preceding = &input[..offset]; + let line = preceding.chars().filter(|c| *c == '\n').count() as u32 + 1; + let last_newline = preceding.rfind('\n').map(|i| i + 1).unwrap_or(0); + let column = offset - last_newline + 1; + (line, column) +} + +fn peg_error_to_parse_error( + input: &str, + err: peg::error::ParseError, +) -> ParseError { + let offset = err.location.offset; + let (line, col) = offset_to_line_column(input, offset); + + let tokens: Vec = err.expected.tokens().map(|s| format!("`{s}`")).collect(); + let kind = if tokens.is_empty() { + ParseErrorKind::Other("unexpected input".into()) + } else { + ParseErrorKind::Expected { expected: tokens.join(" or ") } + }; + + let ctx_start = offset.saturating_sub(20); + let ctx_end = (offset + 20).min(input.len()); + let context = input[ctx_start..ctx_end].to_string(); + + ParseError { line, column: col, offset, kind, context } +} + +// ========================================================================= +// Public API +// ========================================================================= + +/// Parse a complete udev rules file. +pub fn parse_rules(input: &str) -> Result { + rule_grammar::rules_file(input) + .map_err(|e| peg_error_to_parse_error(input, e)) +} + +// ========================================================================= +// Tests +// ========================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + // --- rules file (integration tests via parse_rules()) --- + + #[test] + fn test_parse_empty() { + assert!(parse_rules("").unwrap().rules.is_empty()); + } + #[test] + fn test_parse_comments_only() { + assert!(parse_rules("# some comment\n# another\n").unwrap().rules.is_empty()); + } + + #[test] + fn test_single_rule() { + let f = parse_rules("SUBSYSTEM==\"usb\"\n").unwrap(); + assert_eq!(f.rules.len(), 1); + assert_eq!(f.rules[0].items.len(), 1); + assert_eq!(f.rules[0].items[0].key.name, "SUBSYSTEM"); + assert_eq!(f.rules[0].items[0].op, Operator::MatchEqual); + } + + #[test] + fn test_simple_rule() { + let f = parse_rules("SUBSYSTEM==\"usb\", MODE=\"0660\"\n").unwrap(); + assert_eq!(f.rules[0].items.len(), 2); + } + + #[test] + fn test_rule_with_trailing_comment() { + let f = parse_rules("KERNEL==\"tty*\" # match all ttys\n").unwrap(); + assert_eq!(f.rules[0].items.len(), 1); + } + + #[test] + fn test_rule_line_continuation() { + let input = "SUBSYSTEM==\"usb\", \\\n\tATTR{idVendor}==\"1234\", \\\n\tMODE=\"0660\"\n"; + let f = parse_rules(input).unwrap(); + assert_eq!(f.rules[0].items.len(), 3); + } + + #[test] + fn test_rule_no_trailing_newline() { + let f = parse_rules("SUBSYSTEM==\"usb\", MODE=\"0660\"").unwrap(); + assert_eq!(f.rules[0].items.len(), 2); + } + + #[test] + fn test_key_with_attr() { + let f = parse_rules("ATTR{idVendor}==\"1234\"\n").unwrap(); + assert_eq!(f.rules[0].items[0].key.name, "ATTR"); + assert_eq!(f.rules[0].items[0].key.attribute.as_deref(), Some("idVendor")); + } + + #[test] + fn test_operator_assign() { + let f = parse_rules("MODE=\"0660\"\n").unwrap(); + assert_eq!(f.rules[0].items[0].op, Operator::Assign); + } + + #[test] + fn test_operator_append() { + let f = parse_rules("SYMLINK+=\"modem\"\n").unwrap(); + assert_eq!(f.rules[0].items[0].op, Operator::AssignAppend); + } + + #[test] + fn test_operator_final() { + let f = parse_rules("NAME:=\"eth0\"\n").unwrap(); + assert_eq!(f.rules[0].items[0].op, Operator::AssignFinal); + } + + #[test] + fn test_operator_ne() { + let f = parse_rules("ACTION!=\"remove\"\n").unwrap(); + assert_eq!(f.rules[0].items[0].op, Operator::MatchNotEqual); + } + + #[test] + fn test_bare_value() { + let f = parse_rules("GOTO=end\n").unwrap(); + assert_eq!(f.rules[0].items[0].value, Some(Value::Bare("end".into()))); + } + + #[test] + fn test_remove_no_value() { + let f = parse_rules("GOTO-=\n").unwrap(); + assert_eq!(f.rules[0].items[0].op, Operator::AssignRemove); + assert_eq!(f.rules[0].items[0].value, None); + } + + #[test] + fn test_remove_with_value() { + let f = parse_rules("ENV{foo}-=\"bar\"\n").unwrap(); + assert_eq!(f.rules[0].items[0].key.attribute.as_deref(), Some("foo")); + assert_eq!(f.rules[0].items[0].op, Operator::AssignRemove); + assert_eq!(f.rules[0].items[0].value, Some(Value::Quoted("bar".into()))); + } + + #[test] + fn test_quoted_with_escapes() { + let f = parse_rules("NAME=\"a\\nb\\tc\\\\d\\\"e\"\n").unwrap(); + assert_eq!(f.rules[0].items[0].value, Some(Value::Quoted("a\nb\tc\\d\"e".into()))); + } + + #[test] + fn test_empty_quoted() { + let f = parse_rules("NAME=\"\"\n").unwrap(); + assert_eq!(f.rules[0].items[0].value, Some(Value::Quoted(String::new()))); + } + + #[test] + fn test_parse_multiple_rules() { + let input = "\ +# USB devices +SUBSYSTEM==\"usb\", ATTR{idVendor}==\"1234\", MODE=\"0660\" + +# Serial ports +KERNEL==\"ttyUSB*\", SYMLINK+=\"modem\" +"; + let f = parse_rules(input).unwrap(); + assert_eq!(f.rules.len(), 2); + assert_eq!(f.rules[0].items.len(), 3); + assert_eq!(f.rules[1].items.len(), 2); + } + #[test] + fn test_parse_realistic_rules() { + let input = "\ +SUBSYSTEM==\"usb\", ENV{DEVTYPE}==\"usb_device\", MODE=\"0664\" +SUBSYSTEM==\"net\", ACTION==\"add\", ATTR{address}==\"00:11:22:33:44:55\", NAME=\"eth0\" +KERNEL==\"sd*\", SUBSYSTEMS==\"scsi\", ATTRS{model}==\"MyDevice \", \\\n RUN+=\"/usr/local/bin/my-script.sh\" +TAG-=\"systemd\" +LABEL=\"end\" +GOTO=\"end\" +"; + let f = parse_rules(input).unwrap(); + assert_eq!(f.rules.len(), 6); + assert_eq!(f.rules[0].items.len(), 3); + assert_eq!(f.rules[1].items.len(), 4); + assert_eq!(f.rules[2].items.len(), 4); + assert_eq!(f.rules[2].items[3].key.name, "RUN"); + assert_eq!(f.rules[2].items[3].op, Operator::AssignAppend); + assert_eq!(f.rules[3].items[0].op, Operator::AssignRemove); + assert_eq!(f.rules[4].items[0].key.name, "LABEL"); + assert_eq!(f.rules[5].items[0].key.name, "GOTO"); + } + #[test] + fn test_parse_options_rule() { + let input = "SUBSYSTEM==\"block\", OPTIONS+=\"link_priority=10\"\n"; + let f = parse_rules(input).unwrap(); + assert_eq!(f.rules[0].items[1].key.name, "OPTIONS"); + } + #[test] + fn test_parse_import_rule() { + let f = parse_rules("IMPORT{db}=\"/etc/udev/hwdb.bin\"\n").unwrap(); + assert_eq!(f.rules[0].items[0].key.attribute.as_deref(), Some("db")); + } + + // --- error --- + + #[test] + fn test_error_line_number() { + let err = parse_rules("SUBSYSTEM==\"usb\"\n\"badline\"\n").unwrap_err(); + assert_eq!(err.line, 2); + } + #[test] + fn test_error_context_not_empty() { + let err = parse_rules("\"starts_with_quote\"\n").unwrap_err(); + assert!(!err.context.is_empty()); + } + + // --- span --- + + #[test] + fn test_spans_are_valid() { + let f = parse_rules("SUBSYSTEM==\"usb\", MODE=\"0660\"\n").unwrap(); + let rule = &f.rules[0]; + assert!(rule.span.start < rule.span.end); + for item in &rule.items { + assert!(item.span.start < item.span.end); + assert!(item.key.span.start < item.key.span.end); + } + } + + #[test] + fn test_span_matches_input() { + let input = "SUBSYSTEM==\"usb\", MODE=\"0660\"\n"; + let f = parse_rules(input).unwrap(); + let covered = &input[f.rules[0].span.start..f.rules[0].span.end]; + assert!(covered.contains("SUBSYSTEM")); + assert!(covered.contains("MODE")); + } + + #[test] + fn test_span_continuation() { + let f = parse_rules("KERNEL==\"sd*\", \\\n\tRUN+=\"/bin/true\"\n").unwrap(); + let rule = &f.rules[0]; + assert_eq!(rule.items.len(), 2); + assert!(rule.items[0].span.start < rule.items[0].span.end); + assert!(rule.items[1].span.start < rule.items[1].span.end); + } +} diff --git a/lib/udev-core/src/rules.rs b/lib/udev-core/src/rules.rs new file mode 100644 index 0000000..1ebb181 --- /dev/null +++ b/lib/udev-core/src/rules.rs @@ -0,0 +1,1123 @@ +//! Udev rule engine — evaluates rules against uevents and executes actions. +//! +//! This module implements the core logic of systemd-udevd: +//! +//! 1. Globbing ([`fnmatch`]) for match patterns. +//! 2. Format substitution (`%k`, `$attr{…}`, etc.). +//! 3. Rule evaluation ([`RuleEngine`]). +//! 4. Action execution (device nodes, symlinks, permissions, `RUN`). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::device::{self}; +use crate::rule_parser::{Operator, Rule, RuleItem, RulesFile, Value}; +use crate::hwdb_parser::Hwdb; +use crate::builtins::{register_builtins, BuiltinFn}; +use crate::uevent::Uevent; + +// ═══════════════════════════════════════════════════════════════════════ +// Glob matching (fnmatch-compatible) +// ═══════════════════════════════════════════════════════════════════════ + +/// Simple shell-glob matcher compatible with `fnmatch(3)` (no pathname +/// extensions — just `*`, `?`, `[…]`, `[!…]`). +pub fn fnmatch(pattern: &str, value: &str) -> bool { + let pat = pattern.as_bytes(); + let val = value.as_bytes(); + fnmatch_bytes(pat, val) +} + +fn fnmatch_bytes(pat: &[u8], val: &[u8]) -> bool { + let (mut pi, mut vi) = (0, 0); + let (mut star_pi, mut star_vi): (Option, Option) = (None, None); + + while vi < val.len() { + if pi < pat.len() && (pat[pi] == b'?' || pat[pi] == val[vi]) { + pi += 1; + vi += 1; + } else if pi < pat.len() && pat[pi] == b'*' { + star_pi = Some(pi); + star_vi = Some(vi); + pi += 1; + } else if pi < pat.len() && pat[pi] == b'[' { + // Character class [...] + pi += 1; // skip '[' + let negated = if pi < pat.len() && (pat[pi] == b'!' || pat[pi] == b'^') { + pi += 1; + true + } else { + false + }; + let mut matched = false; + if pi < pat.len() { + loop { + if pi >= pat.len() || pat[pi] == b']' { + break; + } + if pi + 2 < pat.len() && pat[pi + 1] == b'-' && pat[pi + 2] != b']' { + // Range a-z + if val[vi] >= pat[pi] && val[vi] <= pat[pi + 2] { + matched = true; + } + pi += 3; + } else { + if pat[pi] == val[vi] { + matched = true; + } + pi += 1; + } + } + // Skip ']' + if pi < pat.len() && pat[pi] == b']' { + pi += 1; + } + } + if matched == negated { + // Backtrack + if let (Some(sp), Some(sv)) = (star_pi, star_vi) { + pi = sp + 1; + vi = sv + 1; + star_vi = Some(sv + 1); + continue; + } + return false; + } + vi += 1; + } else if let (Some(sp), Some(sv)) = (star_pi, star_vi) { + // Backtrack through '*' + pi = sp + 1; + vi = sv + 1; + star_vi = Some(sv + 1); + } else { + return false; + } + } + + // Consume trailing '*' + while pi < pat.len() && pat[pi] == b'*' { + pi += 1; + } + + pi == pat.len() +} + +// ═══════════════════════════════════════════════════════════════════════ +// Format substitution +// ═══════════════════════════════════════════════════════════════════════ + +/// Context available for format substitution in udev rules. +#[derive(Debug, Clone)] +pub struct SubstContext { + /// Kernel device name (last component of DEVPATH). + pub kernel_name: String, + /// Kernel number (trailing digits, or empty). + pub kernel_number: String, + /// Major device number. + pub major: u32, + /// Minor device number. + pub minor: u32, + /// Device subsystem. + pub subsystem: String, + /// DEVPATH. + pub devpath: String, + /// Device node name (derived). + pub devname: String, + /// Sysfs attributes cache. + pub sysfs: HashMap, + /// Environment variables (ENV{...} assignments). + pub env: HashMap, + /// Result from the most recent PROGRAM match. + pub program_result: Option, +} + +impl SubstContext { + pub fn from_uevent(uevent: &Uevent) -> Self { + let devpath = uevent.devpath().unwrap_or("").to_string(); + let kernel_name = Path::new(&devpath) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + let kernel_number = extract_trailing_digits(&kernel_name); + let major = uevent.major().unwrap_or(0); + let minor = uevent.minor().unwrap_or(0); + let subsystem = uevent.subsystem().unwrap_or("").to_string(); + + SubstContext { + kernel_name, + kernel_number, + major, + minor, + subsystem: subsystem.clone(), + devpath, + devname: String::new(), + sysfs: HashMap::new(), + env: HashMap::new(), + program_result: None, + } + } +} + +/// Extract trailing digits from a string (e.g. `"sda123"` → `"123"`). +fn extract_trailing_digits(s: &str) -> String { + let digits: String = s.chars().rev().take_while(|c| c.is_ascii_digit()).collect(); + digits.chars().rev().collect() +} + +/// Substitute format specifiers in a udev rule value string. +/// +/// Supports: +/// - `%k` / `$kernel` — kernel name +/// - `%n` / `$number` — kernel number +/// - `%M` / `$major` — major +/// - `%m` / `$minor` — minor +/// - `%b` / `$id` — bus ID (subsystem-derived) +/// - `%p` / `$devpath` — DEVPATH +/// - `%d` / `$dev` — /dev path +/// - `%N` / `$name` — device node name +/// - `%s{attr}` / `$attr{attr}` / `$sys{attr}` — sysfs attribute +/// - `%E{var}` / `$env{var}` — environment variable +/// - `%c{N}` / `$result{N}` — Nth word of PROGRAM result +/// - `%%` / `$$` — literal `%` / `$` +pub fn substitute(template: &str, ctx: &SubstContext) -> String { + let mut result = String::with_capacity(template.len()); + let bytes = template.as_bytes(); + let mut i = 0; + + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 1 >= bytes.len() { + result.push('%'); + break; + } + match bytes[i + 1] { + b'%' => { result.push('%'); i += 2; } + b'k' => { result.push_str(&ctx.kernel_name); i += 2; } + b'n' => { result.push_str(&ctx.kernel_number); i += 2; } + b'M' => { result.push_str(&ctx.major.to_string()); i += 2; } + b'm' => { result.push_str(&ctx.minor.to_string()); i += 2; } + b'p' => { result.push_str(&ctx.devpath); i += 2; } + b'd' => { result.push_str("/dev"); i += 2; } + b'N' => { result.push_str(&ctx.devname); i += 2; } + b'b' => { result.push_str(&derive_bus_id(&ctx.subsystem, &ctx.kernel_name)); i += 2; } + b's' | b'S' => { + // %s{attr} / %S{attr} + i += 2; + let attr = extract_braced(&bytes[i..]); + if let Some(attr_name) = attr { + i += attr_name.len() + 2; // skip { } + let path = Path::new("/sys") + .join(ctx.devpath.trim_start_matches('/')) + .join(&attr_name); + if let Ok(val) = std::fs::read_to_string(&path) { + result.push_str(val.trim()); + } + } + } + b'E' => { + i += 2; + let var = extract_braced(&bytes[i..]); + if let Some(v) = var { + i += v.len() + 2; + if let Some(val) = ctx.env.get(&v) { + result.push_str(val); + } + } + } + b'c' => { + // %c{N} — Nth word of program result + i += 2; + let idx = extract_braced(&bytes[i..]); + if let Some(idx_str) = idx { + i += idx_str.len() + 2; + if let Some(ref prog_result) = ctx.program_result { + let n = idx_str.parse::().unwrap_or(1).saturating_sub(1); + let words: Vec<&str> = prog_result.split_whitespace().collect(); + if n < words.len() { + result.push_str(words[n]); + } + } + } + } + _ => { result.push('%'); i += 1; } + } + } else if bytes[i] == b'$' { + if i + 1 >= bytes.len() { + result.push('$'); + break; + } + match bytes[i + 1] { + b'$' => { result.push('$'); i += 2; } + b'E' => { + // $E{var} + i += 2; // skip $E (i now points to {) + let var = extract_braced(&bytes[i..]); + if let Some(v) = var { + i += v.len() + 2; + if let Some(val) = ctx.env.get(&v) { + result.push_str(val); + } + } + } + b'e' => { + // $env{var} + // Verify it's 'env{' following $ + if i + 6 < bytes.len() && bytes[i+2] == b'n' && bytes[i+3] == b'v' && bytes[i+4] == b'{' { + i += 4; // skip $env (i now points to {) + let var = extract_braced(&bytes[i..]); + if let Some(v) = var { + i += v.len() + 2; + if let Some(val) = ctx.env.get(&v) { + result.push_str(val); + } + } + } else { + result.push('$'); + i += 1; + } + } + b'a' => { + // $attr{attr} + i += 2; + let attr = extract_braced(&bytes[i..]); + if let Some(a) = attr { + i += a.len() + 2; + let path = Path::new("/sys") + .join(ctx.devpath.trim_start_matches('/')) + .join(&a); + if let Ok(val) = std::fs::read_to_string(&path) { + result.push_str(val.trim()); + } + } + } + b's' | b'S' => { + // $sys{attr} + i += 2; + let attr = extract_braced(&bytes[i..]); + if let Some(a) = attr { + i += a.len() + 2; + let path = Path::new("/sys") + .join(ctx.devpath.trim_start_matches('/')) + .join(&a); + if let Ok(val) = std::fs::read_to_string(&path) { + result.push_str(val.trim()); + } + } + } + b'k' => { result.push_str(&ctx.kernel_name); i += 2; } + b'n' => { result.push_str(&ctx.kernel_number); i += 2; } + b'M' => { result.push_str(&ctx.major.to_string()); i += 2; } + b'm' => { result.push_str(&ctx.minor.to_string()); i += 2; } + b'd' => { result.push_str("/dev"); i += 2; } + b'N' => { result.push_str(&ctx.devname); i += 2; } + b'b' => { result.push_str(&derive_bus_id(&ctx.subsystem, &ctx.kernel_name)); i += 2; } + b'r' => { + // $result{N} + i += 2; + let idx = extract_braced(&bytes[i..]); + if let Some(idx_str) = idx { + i += idx_str.len() + 2; + if let Some(ref prog_result) = ctx.program_result { + let n = idx_str.parse::().unwrap_or(1).saturating_sub(1); + let words: Vec<&str> = prog_result.split_whitespace().collect(); + if n < words.len() { + result.push_str(words[n]); + } + } + } + } + b'P' => { + // $parent{attr} + i += 2; + let attr = extract_braced(&bytes[i..]); + if let Some(a) = attr { + i += a.len() + 2; + let sysfs = Path::new("/sys").join(ctx.devpath.trim_start_matches('/')); + let val = device::read_parent_attr(&sysfs, &a); + if let Some(v) = val { result.push_str(&v); } + } + } + _ => { result.push('$'); i += 1; } + } + } else { + result.push(bytes[i] as char); + i += 1; + } + } + + result +} + +/// Extract content between `{` and `}`, returning the inner content. +fn extract_braced(input: &[u8]) -> Option { + if input.is_empty() || input[0] != b'{' { + return None; + } + let mut depth = 1u32; + let mut end = 1; + while end < input.len() && depth > 0 { + match input[end] { + b'{' => depth += 1, + b'}' => depth -= 1, + _ => {} + } + if depth > 0 { + end += 1; + } + } + if depth == 0 { + Some(String::from_utf8_lossy(&input[1..end]).to_string()) + } else { + None + } +} + +/// Simple shell-like split: respect single/double quotes +pub fn split_args(s: &str) -> Vec { + let mut args = Vec::new(); + let mut cur = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + ' ' | '\t' => { + if !cur.is_empty() { + args.push(cur.clone()); + cur.clear(); + } + } + '\'' => { + while let Some(nc) = chars.next() { + if nc == '\'' { break; } + cur.push(nc); + } + } + '"' => { + while let Some(nc) = chars.next() { + if nc == '"' { break; } + cur.push(nc); + } + } + _ => cur.push(c), + } + } + if !cur.is_empty() { args.push(cur); } + args +} + +/// Derive a bus-id-like string (simplified). +fn derive_bus_id(subsystem: &str, kernel_name: &str) -> String { + match subsystem { + "usb" | "pci" => kernel_name.replace(':', ""), + _ => kernel_name.to_string(), + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Rule evaluation +// ═══════════════════════════════════════════════════════════════════════ + +/// Result of evaluating a rule against a uevent. +#[derive(Debug, Clone)] +pub struct RuleResult { + /// The device node path (if NAME was assigned). + pub devnode: Option, + /// Symlinks to create, relative or absolute. + pub symlinks: Vec, + /// File mode (octal, e.g. 0o660). + pub mode: Option, + /// Owner UID. + pub uid: Option, + /// Owner GID. + pub gid: Option, + /// Tags to add. + pub tags: Vec, + /// RUN programs (already substituted). + pub run_commands: Vec<(String, Vec)>, + /// Environment changes. + pub env_changes: HashMap>, + /// Whether the database entry should persist after device removal. + pub db_persist: bool, +} + +impl Default for RuleResult { + fn default() -> Self { + RuleResult { + devnode: None, + symlinks: Vec::new(), + mode: Some(0o660), + uid: None, + gid: None, + tags: Vec::new(), + run_commands: Vec::new(), + env_changes: HashMap::new(), + db_persist: false, + } + } +} + +/// The rule engine. Owns the parsed rules and applies them to uevents. +pub struct RuleEngine { + /// All rules from all files (in load order). + pub rules: Vec<(String, Rule)>, + /// Loaded hwdb files (name, Hwdb) for builtin IMPORT{hwdb}. + pub hwdb: Vec<(String, Hwdb)>, + /// Registered builtin handlers. + pub builtins: std::collections::HashMap, +} + +impl RuleEngine { + /// Build a rule engine from parsed rule files. + pub fn new(rule_files: &[(String, RulesFile)], hwdb_files: &[(String, Hwdb)]) -> Self { + let mut rules = Vec::new(); + for (fname, rf) in rule_files { + for rule in &rf.rules { + rules.push((fname.clone(), rule.clone())); + } + } + tracing::info!( + "rule engine: {} rules loaded from {} files", + rules.len(), + rule_files.len() + ); + RuleEngine { rules, hwdb: hwdb_files.to_vec(), builtins: register_builtins() } + } + + /// Reload rules. + pub fn reload(&mut self, rule_files: &[(String, RulesFile)], hwdb_files: &[(String, Hwdb)]) { + let mut rules = Vec::new(); + for (fname, rf) in rule_files { + for rule in &rf.rules { + rules.push((fname.clone(), rule.clone())); + } + } + self.rules = rules; + self.hwdb = hwdb_files.to_vec(); + // Re-register builtins (in case code or available hwdb changed) + self.builtins = register_builtins(); + tracing::info!("rule engine: reloaded {} rules", self.rules.len()); + } + + /// Evaluate all rules against a uevent, returning the accumulated + /// result that should be applied. + /// + /// Returns `None` if no rule matched (no device action needed). + pub fn evaluate(&self, uevent: &Uevent, ctx: &mut SubstContext) -> Option { + let mut result = RuleResult::default(); + let mut matched = false; + + // Build initial env from uevent + for (k, v) in &uevent.properties { + ctx.env.insert(k.clone(), v.clone()); + } + + let mut i = 0; + let mut visited = std::collections::HashSet::new(); + + while i < self.rules.len() { + let (ref _fname, ref rule) = self.rules[i]; + let rule_idx = i; + visited.insert(rule_idx); + + if self.rule_matches(uevent, rule, ctx) { + matched = true; + let skip_rest = self.execute_assignments(rule, &mut result, ctx); + if skip_rest { + break; + } + + // Handle GOTO + if let Some(goto_label) = self.find_goto(rule) { + if let Some(new_i) = self.find_label(&goto_label, rule_idx) { + if visited.contains(&new_i) { + tracing::warn!("GOTO loop detected for label '{}'", goto_label); + break; + } + i = new_i; + continue; + } else { + tracing::warn!("GOTO label '{}' not found", goto_label); + break; + } + } + } + + i += 1; + } + + if matched { Some(result) } else { None } + } + + /// Check whether a rule matches the uevent (all match items must pass). + fn rule_matches(&self, uevent: &Uevent, rule: &Rule, ctx: &SubstContext) -> bool { + for item in &rule.items { + if !self.item_matches(uevent, item, ctx) { + return false; + } + } + true + } + + /// Check a single rule item. + fn item_matches(&self, uevent: &Uevent, item: &RuleItem, ctx: &SubstContext) -> bool { + if item.value.is_none() { + return true; + } + let raw_value = match item.value.as_ref().unwrap() { + Value::Quoted(s) => s.clone(), + Value::Bare(s) => s.clone(), + }; + let value = substitute(&raw_value, ctx); + + // Handle special keys that need custom matching logic + match item.key.name.as_str() { + "ATTR" | "ATTRS" => { + let attr_name = match item.key.attribute.as_ref() { + Some(a) => a, + None => return false, + }; + let devpath = uevent.devpath().unwrap_or(""); + let sysfs_path = Path::new("/sys").join(devpath.trim_start_matches('/')); + + let attr_val = if item.key.name == "ATTRS" { + device::read_parent_attr(&sysfs_path, attr_name) + } else { + std::fs::read_to_string(sysfs_path.join(attr_name)).ok() + .map(|s| s.trim().to_string()) + }; + + return match item.op { + Operator::MatchEqual => { + attr_val.as_ref().is_some_and(|a| fnmatch(&value, a.trim())) + } + Operator::MatchNotEqual => { + !attr_val.as_ref().is_some_and(|a| fnmatch(&value, a.trim())) + } + _ => true, + }; + } + "ENV" => { + let env_name = match item.key.attribute.as_ref() { + Some(a) => a, + None => return false, + }; + let env_val = ctx.env.get(env_name); + return match item.op { + Operator::MatchEqual => { + env_val.is_some_and(|a| fnmatch(&value, a)) + } + Operator::MatchNotEqual => { + !env_val.is_some_and(|a| fnmatch(&value, a)) + } + _ => true, + }; + } + "PROGRAM" => { + let result = self.run_program(&value, ctx); + return match result { + Ok(_output) => { + // PROGRAM success — the rule matches and output + // is stored for subsequent RESULT checks + true + } + Err(_) => { + // PROGRAM failed — rule does not match + // Only fail for MatchEqual, for assignment operators it's ok + matches!(item.op, Operator::Assign | Operator::AssignAppend + | Operator::AssignFinal | Operator::AssignRemove) + } + }; + } + "RESULT" => { + return match &ctx.program_result { + Some(prog_result) => match item.op { + Operator::MatchEqual => fnmatch(&value, prog_result), + Operator::MatchNotEqual => !fnmatch(&value, prog_result), + _ => true, + }, + None => matches!(item.op, Operator::MatchNotEqual), + }; + } + _ => {} + } + + // Standard property match + let uevent_val: Option = match item.key.name.as_str() { + "ACTION" => uevent.get("ACTION").map(|s| s.to_string()), + "SUBSYSTEM" => uevent.get("SUBSYSTEM").map(|s| s.to_string()), + "KERNEL" => Some(ctx.kernel_name.clone()), + "DEVPATH" => uevent.get("DEVPATH").map(|s| s.to_string()), + "DEVTYPE" => uevent.get("DEVTYPE").map(|s| s.to_string()), + "DRIVER" => { + let sysfs = Path::new("/sys") + .join(uevent.devpath().unwrap_or("").trim_start_matches('/')); + let driver_path = sysfs.join("driver"); + if driver_path.exists() { + driver_path.canonicalize().ok() + .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + } else { + None + } + } + "SUBSYSTEMS" => { + let subsystems = self.collect_parent_subsystems(uevent); + subsystems.iter().any(|s| fnmatch(&value, s)).then(|| value.clone()) + } + "TAGS" | "TAG" => None, + // Assignment-only keys — never cause a rule to fail matching + "NAME" | "SYMLINK" | "MODE" | "OWNER" | "GROUP" + | "RUN" | "IMPORT" | "GOTO" | "LABEL" | "OPTIONS" => return true, + _ => return true, + }; + + match item.op { + Operator::MatchEqual => { + match uevent_val { + Some(ref actual) => fnmatch(&value, actual), + None => false, + } + } + Operator::MatchNotEqual => { + match uevent_val { + Some(ref actual) => !fnmatch(&value, actual), + None => true, + } + } + _ => true, + } + } + + /// Execute assignment items from a matched rule. + /// Returns `true` if processing should stop (e.g. GOTO without label found). + fn execute_assignments(&self, rule: &Rule, result: &mut RuleResult, ctx: &mut SubstContext) -> bool { + for item in &rule.items { + match item.op { + Operator::MatchEqual | Operator::MatchNotEqual => continue, + _ => {} + } + + let raw_value = match item.value.as_ref() { + Some(Value::Quoted(s)) => s.clone(), + Some(Value::Bare(s)) => s.clone(), + None => String::new(), + }; + let value = substitute(&raw_value, ctx); + + match item.key.name.as_str() { + "NAME" => { + result.devnode = Some(PathBuf::from(&value)); + } + "SYMLINK" => { + // SYMLINK can contain space-separated multiple links + for link in value.split_whitespace() { + let link = link.trim(); + if !link.is_empty() { + match item.op { + Operator::Assign => result.symlinks.push(link.to_string()), + Operator::AssignAppend => result.symlinks.push(link.to_string()), + Operator::AssignRemove => { + result.symlinks.retain(|x| x != link); + } + _ => {} + } + } + } + } + "MODE" => { + if let Ok(m) = u32::from_str_radix(&value, 8) { + result.mode = Some(m); + } + } + "OWNER" => { + result.uid = device::resolve_user(&value); + } + "GROUP" => { + result.gid = device::resolve_group(&value); + } + "TAG" => { + match item.op { + Operator::Assign | Operator::AssignAppend => { + if !result.tags.contains(&value) { + result.tags.push(value); + } + } + Operator::AssignRemove => { + result.tags.retain(|t| t != &value); + } + _ => {} + } + } + "RUN" => { + match item.op { + Operator::AssignAppend => { + // Parse: the value is the program path + arguments + let parts: Vec<&str> = value.split_whitespace().collect(); + if !parts.is_empty() { + let prog = parts[0].to_string(); + let args: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + result.run_commands.push((prog, args)); + } + } + _ => { + tracing::warn!("RUN with = or := is deprecated, use +="); + } + } + } + "ENV" => { + let env_name = match item.key.attribute.as_ref() { + Some(a) => a, + None => continue, + }; + match item.op { + Operator::Assign | Operator::AssignAppend | Operator::AssignFinal => { + ctx.env.insert(env_name.clone(), value); + } + Operator::AssignRemove => { + ctx.env.remove(env_name); + } + _ => {} + } + } + "ATTR" => { + // Write sysfs attribute (only on add/change) + let attr_name = match item.key.attribute.as_ref() { + Some(a) => a, + None => continue, + }; + let devpath = ctx.devpath.clone(); // borrow issue workaround + let attr_path = Path::new("/sys") + .join(devpath.trim_start_matches('/')) + .join(attr_name); + if let Err(e) = std::fs::write(&attr_path, &value) { + tracing::debug!("failed to write sysfs attr {:?}: {}", attr_path, e); + } + } + "IMPORT" => { + let import_type = item.key.attribute.as_deref().unwrap_or("program"); + match import_type { + "program" | "builtin" => { + if import_type == "program" { + if let Ok(output) = self.run_program(&value, ctx) { + for line in output.lines() { + if let Some(eq_pos) = line.find('=') { + let k = line[..eq_pos].to_string(); + let v = line[eq_pos + 1..].to_string(); + ctx.env.insert(k, v); + } + } + } + } else { + // builtin: parse into name + args and dispatch via registry + let parts = split_args(&value); + if !parts.is_empty() { + let name = parts[0].as_str(); + let args: Vec = parts[1..].to_vec(); + if let Some(f) = self.builtins.get(name) { + let _ = f(&args, ctx, &self.hwdb); + } else { + // fallback to running as external program + if let Ok(output) = self.run_program(&value, ctx) { + for line in output.lines() { + if let Some(eq_pos) = line.find('=') { + let k = line[..eq_pos].to_string(); + let v = line[eq_pos + 1..].to_string(); + ctx.env.insert(k, v); + } + } + } + } + } + } + } + "file" => { + let path = substitute(&raw_value, ctx); + if let Ok(content) = std::fs::read_to_string(&path) { + for line in content.lines() { + if let Some(eq_pos) = line.find('=') { + let k = line[..eq_pos].to_string(); + let v = line[eq_pos + 1..].to_string(); + ctx.env.insert(k, v); + } + } + } + } + "cmdline" => { + if let Ok(content) = std::fs::read_to_string("/proc/cmdline") { + for word in content.split_whitespace() { + if let Some(eq_pos) = word.find('=') { + let k = word[..eq_pos].to_string(); + let v = word[eq_pos + 1..].to_string(); + ctx.env.insert(k, v); + } + } + } + } + "db" => { + // Import from udev database + let db_entry = crate::runtime::udev_db::read_db( + &ctx.subsystem, + ctx.major, + ctx.minor, + &ctx.devpath, + ); + if let Some(entry) = db_entry { + for (k, v) in &entry.properties { + ctx.env.insert(k.clone(), v.clone()); + } + tracing::trace!( + "IMPORT{{db}} for {}: {} properties imported", + ctx.devpath, + entry.properties.len() + ); + } else { + // Fall back: try parent device databases + if let Some((_parent_devpath, parent_entry)) = + crate::runtime::udev_db::read_parent_db( + &ctx.subsystem, + ctx.major, + ctx.minor, + &ctx.devpath, + ) + { + for (k, v) in &parent_entry.properties { + ctx.env.insert(k.clone(), v.clone()); + } + } else { + tracing::trace!("IMPORT{{db}} for {}: no database entry found", ctx.devpath); + } + } + } + _ => { + tracing::debug!("unknown IMPORT type: {}", import_type); + } + } + } + "GOTO" => { + // Handled by the evaluate loop + return false; + } + "LABEL" => { + // Nothing to execute, just a marker + } + "OPTIONS" => { + for opt in value.split_whitespace() { + match opt { + "link_priority=0" | "link_priority=1" | "link_priority=2" | "link_priority=3" | + "link_priority=4" | "link_priority=5" | "link_priority=6" | "link_priority=7" | + "link_priority=8" | "link_priority=9" | "link_priority=10" => { + // priority handling + } + "string_escape=replace" | "string_escape=none" => {} + "static_node" => {} + "watch" => {} + "nowatch" => {} + "db_persist" => { + result.db_persist = true; + } + _ => tracing::debug!("unknown OPTIONS: {}", opt), + } + } + } + _ => { + tracing::debug!("unknown key: {}", item.key.name); + } + } + } + false + } + + /// Find a GOTO in a rule's items. + fn find_goto(&self, rule: &Rule) -> Option { + for item in &rule.items { + if item.key.name == "GOTO" { + if let Some(Value::Quoted(s)) = &item.value { + return Some(s.clone()); + } + if let Some(Value::Bare(s)) = &item.value { + return Some(s.clone()); + } + } + } + None + } + + /// Find the index of a LABEL, starting search from `start_idx`. + fn find_label(&self, label: &str, start_idx: usize) -> Option { + for i in start_idx..self.rules.len() { + for item in &self.rules[i].1.items { + if item.key.name == "LABEL" { + let lbl_val = match item.value.as_ref() { + Some(Value::Quoted(s)) => s, + Some(Value::Bare(s)) => s, + None => continue, + }; + if lbl_val == label { + return Some(i); + } + } + } + } + None + } + + /// Run a PROGRAM or IMPORT{program} and return stdout. + fn run_program(&self, cmdline: &str, _ctx: &SubstContext) -> Result { + let parts: Vec<&str> = cmdline.split_whitespace().collect(); + if parts.is_empty() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "empty command")); + } + let output = Command::new(parts[0]) + .args(&parts[1..]) + .output()?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::debug!("PROGRAM '{}' failed: {}", cmdline, stderr.trim()); + Err(std::io::Error::other(stderr.to_string())) + } + } + + + /// Collect parent subsystems for SUBSYSTEMS matching. + fn collect_parent_subsystems(&self, uevent: &Uevent) -> Vec { + let mut subsystems = Vec::new(); + let devpath = uevent.devpath().unwrap_or(""); + let mut current = Path::new("/sys").join(devpath.trim_start_matches('/')); + + loop { + let uevent_file = current.join("uevent"); + if let Ok(content) = std::fs::read_to_string(&uevent_file) { + for line in content.lines() { + if let Some(sub) = line.strip_prefix("SUBSYSTEM=") { + subsystems.push(sub.trim().to_string()); + } + } + } + current = match current.parent() { + Some(p) if p != Path::new("/sys") => p.to_path_buf(), + _ => break, + }; + } + + subsystems + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════ + +#[cfg(test)] +mod tests { + use super::*; + + // ── fnmatch ───────────────────────────────────────────────────────── + + #[test] + fn test_fnmatch_exact() { + assert!(fnmatch("usb", "usb")); + assert!(!fnmatch("usb", "usb_device")); + } + + #[test] + fn test_fnmatch_wildcard() { + assert!(fnmatch("sd*", "sda")); + assert!(fnmatch("sd*", "sda1")); + assert!(!fnmatch("sd*", "hda")); + } + + #[test] + fn test_fnmatch_question_mark() { + assert!(fnmatch("tty?", "tty0")); + assert!(fnmatch("tty?", "ttyS")); + assert!(!fnmatch("tty?", "ttyUSB")); + } + + #[test] + fn test_fnmatch_charclass() { + assert!(fnmatch("[abc]", "a")); + assert!(!fnmatch("[abc]", "d")); + assert!(fnmatch("[!abc]", "d")); + assert!(fnmatch("tty[0-9]", "tty5")); + assert!(!fnmatch("tty[0-9]", "ttyA")); + } + + #[test] + fn test_fnmatch_complex() { + assert!(fnmatch("ttyUSB[0-9]", "ttyUSB0")); + assert!(fnmatch("sd[a-z][0-9]", "sda1")); + assert!(!fnmatch("sd[a-z][0-9]", "sdaa1")); + } + + // ── Format substitution ───────────────────────────────────────────── + + fn test_ctx() -> SubstContext { + SubstContext { + kernel_name: "sda".into(), + kernel_number: "".into(), + major: 8, + minor: 0, + subsystem: "block".into(), + devpath: "/devices/pci0000:00/0000:00:17.0/ata1/host0/target0:0:0/0:0:0:0/block/sda".into(), + devname: "sda".into(), + sysfs: HashMap::new(), + env: [("ID_FOO".into(), "bar".into())].into(), + program_result: None, + } + } + + #[test] + fn test_substitute_pct_k() { + let ctx = test_ctx(); + assert_eq!(substitute("%k", &ctx), "sda"); + } + + #[test] + fn test_substitute_pct_M_m() { + let ctx = test_ctx(); + assert_eq!(substitute("%M:%m", &ctx), "8:0"); + } + + #[test] + fn test_substitute_dollar_env() { + let ctx = test_ctx(); + assert_eq!(substitute("$env{ID_FOO}", &ctx), "bar"); + } + + #[test] + fn test_substitute_literal_percent() { + let ctx = test_ctx(); + assert_eq!(substitute("%%", &ctx), "%"); + } + + #[test] + fn test_substitute_kernel_number() { + let mut ctx = test_ctx(); + ctx.kernel_name = "sda1".into(); + ctx.kernel_number = "1".into(); + assert_eq!(substitute("%n", &ctx), "1"); + } + + // ── Extract braced ────────────────────────────────────────────────── + + #[test] + fn test_extract_braced_simple() { + assert_eq!(extract_braced(b"{foo}"), Some("foo".into())); + } + + #[test] + fn test_extract_braced_empty() { + assert_eq!(extract_braced(b"{}"), Some("".into())); + } + + #[test] + fn test_extract_braced_nested() { + assert_eq!(extract_braced(b"{a{b}c}"), Some("a{b}c".into())); + } + + #[test] + fn test_extract_braced_no_brace() { + assert_eq!(extract_braced(b"foo"), None); + } +} diff --git a/lib/udev-core/src/runtime/control.rs b/lib/udev-core/src/runtime/control.rs new file mode 100644 index 0000000..4147ba5 --- /dev/null +++ b/lib/udev-core/src/runtime/control.rs @@ -0,0 +1,735 @@ +//! Udev control interface — Varlink protocol over Unix domain socket. +//! +//! Compatible with **systemd ≥ v256** which uses Varlink (JSON + `\0`-framed) +//! over a `SOCK_STREAM` Unix socket. +//! +//! # Varlink wire protocol +//! +//! Each message is a complete JSON object terminated by a NUL (`\0`) byte: +//! +//! ```text +//! {"method": "io.systemd.service.Ping", "parameters": {}}\0 +//! {"parameters": {}}\0 ← reply +//! ``` +//! +//! # Supported methods +//! +//! | Method | Parameters | +//! |------------------------------------------|-------------------------------------| +//! | `io.systemd.service.Ping` | `{}` | +//! | `io.systemd.service.Reload` | `{}` | +//! | `io.systemd.service.SetLogLevel` | `{"level": int\|null}` | +//! | `io.systemd.service.GetEnvironment` | `{}` → returns `{"environment": …}` | +//! | `io.systemd.Udev.SetTrace` | `{"enable": bool}` | +//! | `io.systemd.Udev.SetChildrenMax` | `{"number": uint}` | +//! | `io.systemd.Udev.SetEnvironment` | `{"assignments": ["KEY=val", …]}` | +//! | `io.systemd.Udev.Revert` | `{}` | +//! | `io.systemd.Udev.StartExecQueue` | `{}` | +//! | `io.systemd.Udev.StopExecQueue` | `{}` | +//! | `io.systemd.Udev.Exit` | `{}` | +//! +//! # Socket paths +//! +//! - **Actual** varlink socket: `/run/udev/io.systemd.Udev` +//! - **Compatibility symlink**: `/run/udev/control` → `io.systemd.Udev` +//! +//! # Architecture +//! +//! | Side | Crate | Sync/Async | Transport | +//! |------------|------------|------------|--------------------------| +//! | **Client** | `udevadm` | sync | `std::os::unix::net` | + +use serde::{Deserialize, Serialize}; +use std::io; +use std::os::unix::net::UnixStream as StdUnixStream; +use std::path::Path; +use thiserror::Error; + +// ========================================================================= +// Constants +// ========================================================================= + +/// The actual varlink socket path used by systemd-udevd. +pub const VARLINK_SOCKET: &str = "/run/udev/io.systemd.Udev"; + +/// Compatibility path — systemd-udevd creates this as a symlink to +/// [`VARLINK_SOCKET`]; `lxdeviced` will bind here so that `udevadm control` +/// can find it. +pub const COMPAT_CONTROL_SOCKET: &str = "/run/udev/control"; + +// ========================================================================= +// Varlink method names +// ========================================================================= + +pub mod methods { + //! Varlink method names for the udev control interface. + + /// `io.systemd.service.Ping` — liveness check. + pub const PING: &str = "io.systemd.service.Ping"; + /// `io.systemd.service.Reload` — reload rules+hwdb. + pub const RELOAD: &str = "io.systemd.service.Reload"; + /// `io.systemd.service.SetLogLevel` — change log level. + pub const SET_LOG_LEVEL: &str = "io.systemd.service.SetLogLevel"; + /// `io.systemd.service.GetEnvironment` — query environment. + pub const GET_ENVIRONMENT: &str = "io.systemd.service.GetEnvironment"; + /// `io.systemd.Udev.SetTrace` — enable/disable trace. + pub const SET_TRACE: &str = "io.systemd.Udev.SetTrace"; + /// `io.systemd.Udev.SetChildrenMax` — max worker limit. + pub const SET_CHILDREN_MAX: &str = "io.systemd.Udev.SetChildrenMax"; + /// `io.systemd.Udev.SetEnvironment` — set env vars. + pub const SET_ENVIRONMENT: &str = "io.systemd.Udev.SetEnvironment"; + /// `io.systemd.Udev.Revert` — revert config changes. + pub const REVERT: &str = "io.systemd.Udev.Revert"; + /// `io.systemd.Udev.StartExecQueue` — resume processing. + pub const START_EXEC_QUEUE: &str = "io.systemd.Udev.StartExecQueue"; + /// `io.systemd.Udev.StopExecQueue` — pause processing. + pub const STOP_EXEC_QUEUE: &str = "io.systemd.Udev.StopExecQueue"; + /// `io.systemd.Udev.Exit` — shut down daemon. + pub const EXIT: &str = "io.systemd.Udev.Exit"; +} + +// ========================================================================= +// Error type +// ========================================================================= + +/// Errors that can occur during control‑socket operations. +#[derive(Error, Debug)] +pub enum ControlError { + /// Binding or creating the listener failed. + #[error("failed to bind control socket at {path}: {source}")] + Bind { + path: String, + #[source] + source: io::Error, + }, + + /// Connecting to the daemon failed. + #[error("failed to connect to control socket at {path}: {source}")] + Connect { + path: String, + #[source] + source: io::Error, + }, + + /// Accepting a connection failed. + #[error("failed to accept control connection: {0}")] + Accept(#[source] io::Error), + + /// Sending a command failed. + #[error("failed to send control command: {0}")] + Send(#[source] io::Error), + + /// Receiving a reply failed. + #[error("failed to receive control reply: {0}")] + Receive(#[source] io::Error), + + /// The daemon returned a Varlink error. + #[error("daemon returned error: {method_error}")] + MethodError { + /// The Varlink error name, e.g. `"io.systemd.service.MethodNotFound"`. + method_error: String, + /// Optional human-readable explanation. + explanation: Option, + }, + + /// JSON serialization or parsing failed. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// The received message was malformed. + #[error("invalid control message: {0}")] + Invalid(String), +} + +// ========================================================================= +// Varlink message types +// ========================================================================= + +/// A generic Varlink request frame. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VarlinkRequest { + /// The fully-qualified method name. + pub method: String, + /// Method-specific parameters. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parameters: Option, +} + +/// A generic Varlink response frame. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VarlinkResponse { + /// Error name (absent on success). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Method-specific return data (absent on error). + #[serde(default)] + pub parameters: serde_json::Value, +} + +// ========================================================================= +// High-level command enum +// ========================================================================= + +/// A parsed control command, decoded from a Varlink request. +/// +/// This abstracts away the Varlink method names so that daemon code can +/// match on simple Rust enum variants. +#[derive(Debug, Clone, PartialEq)] +pub enum ControlCommand { + /// `io.systemd.service.Ping` — liveness check. + Ping, + /// `io.systemd.service.Reload` — reload rules and hwdb files. + Reload, + /// `io.systemd.service.SetLogLevel` — change the log level. + SetLogLevel { + /// Numeric log level (0=emerg … 7=debug), or `None` to reset. + level: Option, + }, + /// `io.systemd.service.GetEnvironment` — query environment variables. + GetEnvironment, + /// `io.systemd.Udev.SetTrace` — enable/disable trace logging. + SetTrace { + /// Whether to enable trace. + enable: bool, + }, + /// `io.systemd.Udev.SetChildrenMax` — limit concurrent workers. + SetChildrenMax { + /// Maximum number of worker processes. + number: u32, + }, + /// `io.systemd.Udev.SetEnvironment` — set global udev env vars. + SetEnvironment { + /// List of `KEY=value` assignments. + assignments: Vec, + }, + /// `io.systemd.Udev.Revert` — revert config changes. + Revert, + /// `io.systemd.Udev.StartExecQueue` — resume processing. + StartExecQueue, + /// `io.systemd.Udev.StopExecQueue` — pause processing. + StopExecQueue, + /// `io.systemd.Udev.Exit` — shut down the daemon. + Exit, + /// An unrecognised Varlink method. + Unknown { + /// The raw method name. + method: String, + /// Raw parameters JSON. + parameters: serde_json::Value, + }, +} + +impl ControlCommand { + /// Build the Varlink request frame for this command. + pub fn to_varlink_request(&self) -> VarlinkRequest { + let (method, params) = match self { + ControlCommand::Ping => (methods::PING.to_string(), serde_json::Value::Object(Default::default())), + ControlCommand::Reload => (methods::RELOAD.to_string(), serde_json::Value::Object(Default::default())), + ControlCommand::SetLogLevel { level } => { + let v = match level { + Some(lvl) => serde_json::json!({ "level": lvl }), + None => serde_json::json!({ "level": null }), + }; + (methods::SET_LOG_LEVEL.to_string(), v) + } + ControlCommand::GetEnvironment => (methods::GET_ENVIRONMENT.to_string(), serde_json::Value::Object(Default::default())), + ControlCommand::SetTrace { enable } => { + (methods::SET_TRACE.to_string(), serde_json::json!({ "enable": enable })) + } + ControlCommand::SetChildrenMax { number } => { + (methods::SET_CHILDREN_MAX.to_string(), serde_json::json!({ "number": number })) + } + ControlCommand::SetEnvironment { assignments } => { + (methods::SET_ENVIRONMENT.to_string(), serde_json::json!({ "assignments": assignments })) + } + ControlCommand::Revert => (methods::REVERT.to_string(), serde_json::Value::Object(Default::default())), + ControlCommand::StartExecQueue => (methods::START_EXEC_QUEUE.to_string(), serde_json::Value::Object(Default::default())), + ControlCommand::StopExecQueue => (methods::STOP_EXEC_QUEUE.to_string(), serde_json::Value::Object(Default::default())), + ControlCommand::Exit => (methods::EXIT.to_string(), serde_json::Value::Object(Default::default())), + ControlCommand::Unknown { method, parameters } => (method.clone(), parameters.clone()), + }; + + VarlinkRequest { + method: method.to_string(), + parameters: Some(params), + } + } + + /// Serialise this command into a Varlink wire frame (JSON + trailing NUL). + pub fn to_wire(&self) -> Result, ControlError> { + let req = self.to_varlink_request(); + let mut json = serde_json::to_vec(&req)?; + json.push(b'\0'); + Ok(json) + } + + /// Parse a Varlink request JSON object into a `ControlCommand`. + pub fn from_varlink(value: &serde_json::Value) -> Result { + let method = value + .get("method") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ControlError::Invalid("missing or non-string 'method' field".into()) + })?; + + let params = value.get("parameters").cloned().unwrap_or_default(); + + match method { + methods::PING => Ok(ControlCommand::Ping), + methods::RELOAD => Ok(ControlCommand::Reload), + methods::SET_LOG_LEVEL => { + let level = params + .get("level") + .map(|v| { + if v.is_null() { + Ok(None) + } else { + v.as_i64() + .map(|n| Some(n as i32)) + .ok_or_else(|| { + ControlError::Invalid( + "'level' must be an integer or null".into(), + ) + }) + } + }) + .transpose()? + .flatten(); + Ok(ControlCommand::SetLogLevel { level }) + } + methods::GET_ENVIRONMENT => Ok(ControlCommand::GetEnvironment), + methods::SET_TRACE => { + let enable = params + .get("enable") + .and_then(|v| v.as_bool()) + .ok_or_else(|| { + ControlError::Invalid("missing or non-boolean 'enable' field".into()) + })?; + Ok(ControlCommand::SetTrace { enable }) + } + methods::SET_CHILDREN_MAX => { + let number = params + .get("number") + .and_then(|v| v.as_u64()) + .map(|n| n as u32) + .ok_or_else(|| { + ControlError::Invalid("missing or non-integer 'number' field".into()) + })?; + Ok(ControlCommand::SetChildrenMax { number }) + } + methods::SET_ENVIRONMENT => { + let assignments = params + .get("assignments") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + ControlError::Invalid( + "missing or non-array 'assignments' field".into(), + ) + })? + .iter() + .map(|v| { + v.as_str() + .map(String::from) + .ok_or_else(|| { + ControlError::Invalid( + "non-string entry in 'assignments'".into(), + ) + }) + }) + .collect::, _>>()?; + Ok(ControlCommand::SetEnvironment { assignments }) + } + methods::REVERT => Ok(ControlCommand::Revert), + methods::START_EXEC_QUEUE => Ok(ControlCommand::StartExecQueue), + methods::STOP_EXEC_QUEUE => Ok(ControlCommand::StopExecQueue), + methods::EXIT => Ok(ControlCommand::Exit), + _ => Ok(ControlCommand::Unknown { + method: method.to_string(), + parameters: params, + }), + } + } +} + +impl std::fmt::Display for ControlCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ControlCommand::Ping => write!(f, "Ping"), + ControlCommand::Reload => write!(f, "Reload"), + ControlCommand::SetLogLevel { level } => write!(f, "SetLogLevel({level:?})"), + ControlCommand::GetEnvironment => write!(f, "GetEnvironment"), + ControlCommand::SetTrace { enable } => write!(f, "SetTrace({enable})"), + ControlCommand::SetChildrenMax { number } => write!(f, "SetChildrenMax({number})"), + ControlCommand::SetEnvironment { assignments } => { + write!(f, "SetEnvironment({})", assignments.join(", ")) + } + ControlCommand::Revert => write!(f, "Revert"), + ControlCommand::StartExecQueue => write!(f, "StartExecQueue"), + ControlCommand::StopExecQueue => write!(f, "StopExecQueue"), + ControlCommand::Exit => write!(f, "Exit"), + ControlCommand::Unknown { method, .. } => write!(f, "Unknown({method})"), + } + } +} + +// ========================================================================= +// Varlink frame I/O helpers +// ========================================================================= + +/// Read one Varlink frame (JSON up to the trailing `\0`) from a buffered +/// reader. +/// +/// Returns the JSON bytes **without** the trailing NUL. +fn read_varlink_frame(reader: &mut R) -> Result, io::Error> { + let mut buf = Vec::with_capacity(4096); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + // EOF before NUL — truncated frame + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Varlink frame truncated (missing NUL terminator)", + )); + } + + match available.iter().position(|&b| b == b'\0') { + Some(n) => { + buf.extend_from_slice(&available[..n]); + reader.consume(n + 1); // consume data + NUL + return Ok(buf); + } + None => { + buf.extend_from_slice(available); + let len = available.len(); + reader.consume(len); + // continue looping + } + } + } +} + +// ========================================================================= +// ControlSender — send commands (client / udevadm side, sync) +// ========================================================================= + +/// A synchronous Varlink client for sending commands to lxdeviced (or +/// systemd-udevd) via the control socket. +/// +/// Uses `std::os::unix::net::UnixStream` so no async runtime is needed. +/// +/// # Example +/// +/// ```no_run +/// use udev_core::runtime::control::ControlSender; +/// +/// let mut sender = ControlSender::connect().unwrap(); +/// sender.ping().unwrap(); +/// ``` +pub struct ControlSender { + stream: StdUnixStream, +} + +impl ControlSender { + /// Connect to the default udev control socket at + /// [`VARLINK_SOCKET`] (first tries the actual varlink path, then + /// falls back to the compatibility path). + pub fn connect() -> Result { + Self::connect_with_fallback(VARLINK_SOCKET, COMPAT_CONTROL_SOCKET) + } + + /// Connect to a specific control socket path. + pub fn connect_to(path: impl AsRef) -> Result { + let path = path.as_ref(); + let stream = StdUnixStream::connect(path).map_err(|e| { + ControlError::Connect { + path: path.display().to_string(), + source: e, + } + })?; + Ok(ControlSender { stream }) + } + + /// Try `primary` first, then `fallback`. + fn connect_with_fallback( + primary: &str, + fallback: &str, + ) -> Result { + StdUnixStream::connect(primary) + .or_else(|_| StdUnixStream::connect(fallback)) + .map_err(|e| ControlError::Connect { + path: format!("{primary} or {fallback}"), + source: e, + }) + .map(|stream| ControlSender { stream }) + } + + /// Send a Varlink request and read the response. + /// + /// On success, returns the parsed [`VarlinkResponse`]. If the daemon + /// returned an error frame, `ControlError::MethodError` is returned. + pub fn call(&mut self, cmd: &ControlCommand) -> Result { + use std::io::Write; + + let wire = cmd.to_wire()?; + self.stream.write_all(&wire).map_err(ControlError::Send)?; + self.stream.flush().map_err(ControlError::Send)?; + + let mut reader = std::io::BufReader::new(&self.stream); + let frame = read_varlink_frame(&mut reader).map_err(ControlError::Receive)?; + let resp: VarlinkResponse = serde_json::from_slice(&frame)?; + + // Check for Varlink error + if let Some(ref err) = resp.error { + let explanation = match resp.parameters.as_object() { + Some(obj) => obj + .get("explanation") + .and_then(|v| v.as_str()) + .map(String::from), + None => None, + }; + return Err(ControlError::MethodError { + method_error: err.clone(), + explanation, + }); + } + + Ok(resp) + } + + /// Convenience: send a ping and check that the daemon is alive. + pub fn ping(&mut self) -> Result<(), ControlError> { + self.call(&ControlCommand::Ping)?; + Ok(()) + } +} + +// ========================================================================= +// Convenience: one‑shot helpers +// ========================================================================= + +/// Connect to the default control socket, call a command, and disconnect. +pub fn call_control_command(cmd: ControlCommand) -> Result { + let mut sender = ControlSender::connect()?; + sender.call(&cmd) +} + +/// Convenience: send a ping and return whether the daemon is alive. +pub fn ping_daemon() -> Result { + match ControlSender::connect() { + Ok(mut sender) => sender.ping().map(|()| true), + Err(ControlError::Connect { .. }) => Ok(false), + Err(e) => Err(e), + } +} + +// ========================================================================= +// Tests +// ========================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + // --------------------------------------------------------------- + // VarlinkRequest/Response serialisation + // --------------------------------------------------------------- + + #[test] + fn test_ping_varlink_roundtrip() { + let cmd = ControlCommand::Ping; + let wire = cmd.to_wire().unwrap(); + + // Verify wire format: JSON + NUL + assert!(wire.ends_with(b"\0"), "must end with NUL"); + let json_part = &wire[..wire.len() - 1]; + let parsed: VarlinkRequest = serde_json::from_slice(json_part).unwrap(); + assert_eq!(parsed.method, methods::PING); + + // Verify round-trip through from_varlink + let value: serde_json::Value = serde_json::from_slice(json_part).unwrap(); + let cmd2 = ControlCommand::from_varlink(&value).unwrap(); + assert_eq!(cmd2, ControlCommand::Ping); + } + + #[test] + fn test_start_exec_queue_varlink() { + let cmd = ControlCommand::StartExecQueue; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let parsed: VarlinkRequest = serde_json::from_slice(json_part).unwrap(); + assert_eq!(parsed.method, methods::START_EXEC_QUEUE); + + let value: serde_json::Value = serde_json::from_slice(json_part).unwrap(); + let cmd2 = ControlCommand::from_varlink(&value).unwrap(); + assert_eq!(cmd2, ControlCommand::StartExecQueue); + } + + #[test] + fn test_stop_exec_queue_varlink() { + let cmd = ControlCommand::StopExecQueue; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let parsed: VarlinkRequest = serde_json::from_slice(json_part).unwrap(); + assert_eq!(parsed.method, methods::STOP_EXEC_QUEUE); + + let value: serde_json::Value = serde_json::from_slice(json_part).unwrap(); + let cmd2 = ControlCommand::from_varlink(&value).unwrap(); + assert_eq!(cmd2, ControlCommand::StopExecQueue); + } + + #[test] + fn test_exit_varlink() { + let cmd = ControlCommand::Exit; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let parsed: VarlinkRequest = serde_json::from_slice(json_part).unwrap(); + assert_eq!(parsed.method, methods::EXIT); + } + + #[test] + fn test_reload_varlink() { + let cmd = ControlCommand::Reload; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let parsed: VarlinkRequest = serde_json::from_slice(json_part).unwrap(); + assert_eq!(parsed.method, methods::RELOAD); + } + + #[test] + fn test_set_log_level_varlink() { + let cmd = ControlCommand::SetLogLevel { level: Some(4) }; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let parsed: VarlinkRequest = serde_json::from_slice(json_part).unwrap(); + assert_eq!(parsed.method, methods::SET_LOG_LEVEL); + + let value: serde_json::Value = serde_json::from_slice(json_part).unwrap(); + let cmd2 = ControlCommand::from_varlink(&value).unwrap(); + assert_eq!(cmd2, cmd); + } + + #[test] + fn test_set_log_level_null() { + let cmd = ControlCommand::SetLogLevel { level: None }; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let value: serde_json::Value = serde_json::from_slice(json_part).unwrap(); + let cmd2 = ControlCommand::from_varlink(&value).unwrap(); + assert_eq!(cmd2, cmd); + } + + #[test] + fn test_set_children_max_varlink() { + let cmd = ControlCommand::SetChildrenMax { number: 100 }; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let value: serde_json::Value = serde_json::from_slice(json_part).unwrap(); + let cmd2 = ControlCommand::from_varlink(&value).unwrap(); + assert_eq!(cmd2, cmd); + } + + #[test] + fn test_set_trace_varlink() { + let cmd = ControlCommand::SetTrace { enable: true }; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let value: serde_json::Value = serde_json::from_slice(json_part).unwrap(); + let cmd2 = ControlCommand::from_varlink(&value).unwrap(); + assert_eq!(cmd2, cmd); + } + + #[test] + fn test_set_environment_varlink() { + let cmd = ControlCommand::SetEnvironment { + assignments: vec!["FOO=bar".into(), "BAZ=qux".into()], + }; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let value: serde_json::Value = serde_json::from_slice(json_part).unwrap(); + let cmd2 = ControlCommand::from_varlink(&value).unwrap(); + assert_eq!(cmd2, cmd); + } + + #[test] + fn test_get_environment_varlink() { + let cmd = ControlCommand::GetEnvironment; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let parsed: VarlinkRequest = serde_json::from_slice(json_part).unwrap(); + assert_eq!(parsed.method, methods::GET_ENVIRONMENT); + } + + #[test] + fn test_revert_varlink() { + let cmd = ControlCommand::Revert; + let wire = cmd.to_wire().unwrap(); + let json_part = &wire[..wire.len() - 1]; + let value: serde_json::Value = serde_json::from_slice(json_part).unwrap(); + let cmd2 = ControlCommand::from_varlink(&value).unwrap(); + assert_eq!(cmd2, ControlCommand::Revert); + } + + #[test] + fn test_unknown_method() { + let value = serde_json::json!({ + "method": "io.systemd.Uvre.Nonsense", + "parameters": { "foo": 42 } + }); + let cmd = ControlCommand::from_varlink(&value).unwrap(); + match cmd { + ControlCommand::Unknown { ref method, .. } => { + assert_eq!(method, "io.systemd.Uvre.Nonsense"); + } + other => panic!("expected Unknown, got {other:?}"), + } + } + + #[test] + fn test_varlink_response_ok() { + let frame = b"{\"parameters\":{}}\0"; + let resp: VarlinkResponse = serde_json::from_slice(&frame[..frame.len() - 1]).unwrap(); + assert!(resp.error.is_none()); + } + + #[test] + fn test_varlink_response_error() { + let frame = b"{\"error\":\"io.systemd.service.MethodNotFound\",\"parameters\":{}}\0"; + let resp: VarlinkResponse = serde_json::from_slice(&frame[..frame.len() - 1]).unwrap(); + assert_eq!(resp.error.as_deref(), Some("io.systemd.service.MethodNotFound")); + } + + // --------------------------------------------------------------- + // read_varlink_frame + // --------------------------------------------------------------- + + #[test] + fn test_read_varlink_frame_simple() { + let data = b"{\"method\":\"io.systemd.service.Ping\",\"parameters\":{}}\0extra"; + let mut reader = std::io::BufReader::new(&data[..]); + let frame = read_varlink_frame(&mut reader).unwrap(); + assert_eq!( + String::from_utf8(frame).unwrap(), + r#"{"method":"io.systemd.service.Ping","parameters":{}}"# + ); + } + + #[test] + fn test_read_varlink_frame_multiple_chunks() { + // Simulate fragmented read via a custom reader that yields 1 byte at a time. + struct ChunkByChunk<'a>(&'a [u8], usize); + impl io::Read for ChunkByChunk<'_> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.1 >= self.0.len() { + return Ok(0); + } + buf[0] = self.0[self.1]; + self.1 += 1; + Ok(1) + } + } + let data = b"{\"method\":\"x\"}\0trailing"; + let mut reader = std::io::BufReader::new(ChunkByChunk(data, 0)); + let frame = read_varlink_frame(&mut reader).unwrap(); + assert_eq!(String::from_utf8(frame).unwrap(), r#"{"method":"x"}"#); + } +} diff --git a/lib/udev-core/src/runtime/mod.rs b/lib/udev-core/src/runtime/mod.rs new file mode 100644 index 0000000..e94b9db --- /dev/null +++ b/lib/udev-core/src/runtime/mod.rs @@ -0,0 +1,2 @@ +pub mod control; +pub mod udev_db; diff --git a/lib/udev-core/src/runtime/udev_db.rs b/lib/udev-core/src/runtime/udev_db.rs new file mode 100644 index 0000000..96c9d2b --- /dev/null +++ b/lib/udev-core/src/runtime/udev_db.rs @@ -0,0 +1,270 @@ +//! Udev database — persistent device property store at `/run/udev/data/`. +//! +//! Mirrors systemd-udevd's behaviour: each device gets a file in +//! `/run/udev/data/` named `:` (block/char) or +//! `+` (others). +//! +//! File format (one entry per line): +//! - `I:` — device event sequence number +//! - `E:=` — device property +//! - `S:` — symlink target path +//! - `L:` — link priority +//! - `T:` — device tag +//! +//! This database is read by `IMPORT{db}` in udev rules, and written whenever +//! a device is added or changed. + +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, Write}; +use std::path::Path; + +/// Default udev database directory. +pub const DATA_DIR: &str = "/run/udev/data"; + +// ── Database filename helpers ──────────────────────────────────────────── + +/// Build the database filename for a device, matching systemd-udevd's scheme. +/// +/// - `b{major}:{minor}` for block devices with device node +/// - `c{major}:{minor}` for char devices with device node +/// - `+{encoded_devpath}` for devices without a device node +pub fn db_filename(subsystem: &str, major: u32, minor: u32, devpath: &str) -> String { + if major > 0 || minor > 0 { + let prefix = if subsystem == "block" { "b" } else { "c" }; + format!("{prefix}{major}:{minor}") + } else { + // Encode devpath (replace / with ! like systemd does for some cases) + let encoded = devpath.trim_start_matches('/').replace('/', "!"); + format!("+{encoded}") + } +} + +/// Build all possible database filenames for a device (for lookup fallback). +pub fn all_possible_filenames( + subsystem: &str, + major: u32, + minor: u32, + devpath: &str, +) -> Vec { + let mut names = Vec::with_capacity(3); + + // Primary: major:minor based + if major > 0 || minor > 0 { + names.push(db_filename(subsystem, major, minor, devpath)); + } + + // Encoded devpath + let encoded = devpath.trim_start_matches('/').replace('/', "!"); + names.push(format!("+{encoded}")); + + // Also try without the subsystem prefix for nodenum-based (legacy) + if major > 0 || minor > 0 { + names.push(format!("b{major}:{minor}")); + if subsystem != "block" { + names.push(format!("c{major}:{minor}")); + } + } + + names +} + +/// Walk up the devpath chain to generate parent devpaths. +pub fn parent_devpaths(devpath: &str) -> Vec { + let mut parents = Vec::new(); + let trimmed = devpath.trim_start_matches('/'); + let mut parts: Vec<&str> = trimmed.split('/').collect(); + + // Remove the last component to get the parent + while parts.len() > 1 { + parts.pop(); + parents.push(format!("/{}", parts.join("/"))); + } + + parents +} + +// ── Database write operations ──────────────────────────────────────────── + +/// Database entry data. +#[derive(Debug, Clone)] +pub struct DbEntry { + pub seqnum: u64, + pub properties: HashMap, + pub symlinks: Vec, + pub tags: Vec, + pub link_priority: i32, + pub devnode: String, +} + +impl DbEntry { + pub fn new() -> Self { + DbEntry { + seqnum: 0, + properties: HashMap::new(), + symlinks: Vec::new(), + tags: Vec::new(), + link_priority: 0, + devnode: String::new(), + } + } +} + +impl Default for DbEntry { + fn default() -> Self { + Self::new() + } +} + +/// Write the database entry for a device to `/run/udev/data/`. +pub fn write_db(subsystem: &str, major: u32, minor: u32, devpath: &str, entry: &DbEntry) -> Result<(), std::io::Error> { + let name = db_filename(subsystem, major, minor, devpath); + let path = Path::new(DATA_DIR).join(&name); + + let mut content = String::new(); + + // I: seqnum + content.push_str(&format!("I:{}\n", entry.seqnum)); + + // E: properties + for (key, val) in &entry.properties { + content.push_str(&format!("E:{key}={val}\n")); + } + + // S: symlinks + for link in &entry.symlinks { + content.push_str(&format!("S:{link}\n")); + } + + // L: link priority + content.push_str(&format!("L:{}\n", entry.link_priority)); + + // T: tags + for tag in &entry.tags { + content.push_str(&format!("T:{tag}\n")); + } + + // Atomic write via temp file + rename + let tmp_path = Path::new(DATA_DIR).join(format!(".{name}")); + let mut tmp_file = fs::File::create(&tmp_path)?; + tmp_file.write_all(content.as_bytes())?; + tmp_file.sync_all()?; + drop(tmp_file); + fs::rename(&tmp_path, &path)?; + + Ok(()) +} + +/// Remove the database entry for a device. +pub fn remove_db(subsystem: &str, major: u32, minor: u32, devpath: &str) -> Result<(), std::io::Error> { + let name = db_filename(subsystem, major, minor, devpath); + let path = Path::new(DATA_DIR).join(&name); + + // Clean up any leftover temp file too + let tmp_path = Path::new(DATA_DIR).join(format!(".{name}")); + let _ = fs::remove_file(&tmp_path); + + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +/// Check if a device has a database entry. +pub fn has_db(subsystem: &str, major: u32, minor: u32, devpath: &str) -> bool { + let name = db_filename(subsystem, major, minor, devpath); + Path::new(DATA_DIR).join(&name).exists() +} + +/// Remove stale / temporary database files. +pub fn clean_stale() -> Result<(), std::io::Error> { + let dir = Path::new(DATA_DIR); + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + let fname = entry.file_name(); + let fname = fname.to_string_lossy(); + // Remove temp files starting with '.' + if fname.starts_with('.') { + let _ = fs::remove_file(entry.path()); + } + } + Ok(()) +} + +// ── Database read operations ───────────────────────────────────────────── + +/// Read the database entry for a device by its canonical filename. +fn read_db_by_name(name: &str) -> Option { + let path = Path::new(DATA_DIR).join(name); + if !path.exists() { + return None; + } + + let file = fs::File::open(&path).ok()?; + let reader = std::io::BufReader::new(file); + + let mut entry = DbEntry::new(); + + for line in reader.lines() { + let line = line.ok()?; + if let Some(val) = line.strip_prefix("I:") { + entry.seqnum = val.parse().unwrap_or(0); + } else if let Some(val) = line.strip_prefix("E:") { + if let Some(eq_pos) = val.find('=') { + let key = val[..eq_pos].to_string(); + let value = val[eq_pos + 1..].to_string(); + entry.properties.insert(key, value); + } + } else if let Some(val) = line.strip_prefix("S:") { + entry.symlinks.push(val.to_string()); + } else if let Some(val) = line.strip_prefix("L:") { + entry.link_priority = val.parse().unwrap_or(0); + } else if let Some(val) = line.strip_prefix("T:") { + entry.tags.push(val.to_string()); + } + } + + Some(entry) +} + +/// Read the database entry for a device, trying all possible filenames. +pub fn read_db(subsystem: &str, major: u32, minor: u32, devpath: &str) -> Option { + let names = all_possible_filenames(subsystem, major, minor, devpath); + for name in &names { + if let Some(entry) = read_db_by_name(name) { + return Some(entry); + } + } + None +} + +/// Read database entry for the first parent device that has one. +/// Walks up the devpath chain (parent → grandparent → ...). +pub fn read_parent_db(_subsystem: &str, _major: u32, _minor: u32, devpath: &str) -> Option<(String, DbEntry)> { + let parents = parent_devpaths(devpath); + + for parent_devpath in parents { + // Try with the parent's encoded path + let encoded = parent_devpath.trim_start_matches('/').replace('/', "!"); + let name = format!("+{encoded}"); + if let Some(entry) = read_db_by_name(&name) { + return Some((parent_devpath, entry)); + } + + // Also try common subsystem prefixes for parent + // (parents may be stored with a different subsystem prefix) + let names = all_possible_filenames("", 0, 0, &parent_devpath); + for n in names { + if n != name + && let Some(entry) = read_db_by_name(&n) { + return Some((parent_devpath, entry)); + } + } + } + + None +} diff --git a/lib/udev-core/src/uevent.rs b/lib/udev-core/src/uevent.rs new file mode 100644 index 0000000..362db2c --- /dev/null +++ b/lib/udev-core/src/uevent.rs @@ -0,0 +1,453 @@ +//! Netlink `NETLINK_KOBJECT_UEVENT` receiver for Linux kernel uevents. +//! +//! Listens for device add/remove/change events from the kernel and parses +//! them into structured [`Uevent`] values. +//! +//! # Example +//! +//! ```no_run +//! # async fn example() { +//! use udev_core::uevent::Connection; +//! +//! let mut conn = Connection::new().unwrap(); +//! loop { +//! let uev = conn.next_uevent().await.unwrap(); +//! println!("action={:?} subsystem={:?}", uev.action(), uev.subsystem()); +//! } +//! # } +//! ``` + +use std::collections::HashMap; +use std::io; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use thiserror::Error; +use tokio::io::unix::AsyncFd; + +// ========================================================================= +// Constants +// ========================================================================= + +/// Maximum payload of a single uevent (kernel-enforced). +const UEVENT_BUFFER_SIZE: usize = 4096; + +/// Netlink protocol identifier for kernel uevent multicast. +const NETLINK_KOBJECT_UEVENT: i32 = 15; + +/// Listen on all uevent multicast groups. +const NL_MULTICAST_GROUPS: u32 = 1; + +// ========================================================================= +// Error type +// ========================================================================= + +/// Errors that can occur during uevent connection or reception. +#[derive(Error, Debug)] +pub enum UeventError { + /// Creating or configuring the netlink socket failed. + #[error("failed to create netlink socket: {0}")] + SocketCreate(#[source] io::Error), + + /// Binding the netlink socket to the kernel multicast group failed. + #[error("failed to bind netlink socket: {0}")] + SocketBind(#[source] io::Error), + + /// A `recv` / `read` on the socket returned an error. + #[error("failed to receive uevent: {0}")] + Receive(#[source] io::Error), + + /// The raw uevent data is malformed (e.g. non-UTF-8 or too short). + #[error("invalid uevent: {0}")] + Invalid(String), +} + +// ========================================================================= +// Uevent — parsed kernel message +// ========================================================================= + +/// A parsed kernel uevent message. +/// +/// Uevents are null-delimited sequences of `KEY=value` strings received from +/// the kernel via `NETLINK_KOBJECT_UEVENT`. Common keys include: +/// +/// | Key | Example | +/// |-------------|---------------------------------| +/// | `ACTION` | `add` / `remove` / `change` | +/// | `DEVPATH` | `/devices/pci0000:00/...` | +/// | `SUBSYSTEM` | `usb`, `pci`, `block`, `tty` | +/// | `DEVTYPE` | `usb_device`, `disk`, `partition` | +/// | `SEQNUM` | `6789` | +/// | `DEVNAME` | `/dev/sda` | +/// | `MAJOR` | `8` | +/// | `MINOR` | `0` | +#[derive(Debug, Clone)] +pub struct Uevent { + pub properties: HashMap, +} + +impl Uevent { + // ----------------------------------------------------------- + // Parsing + // ----------------------------------------------------------- + + /// Build a `Uevent` from an existing property map. + /// + /// Returns `None` if the map is empty. + pub fn from_map(properties: &HashMap) -> Option { + if properties.is_empty() { + return None; + } + Some(Uevent { + properties: properties.clone(), + }) + } + + /// Parse a uevent from a raw byte buffer received off the wire. + /// + /// The buffer is expected to contain a sequence of NUL-terminated + /// `KEY=value` strings. The very first token may be the action string + /// (e.g. `"add\0"`) and appears as the `ACTION` property if no explicit + /// `ACTION=…` is present, but modern kernels always include + /// `ACTION=…` explicitly so we simply parse everything as `KEY=value`. + fn from_raw(buf: &[u8]) -> Result { + let mut properties = HashMap::new(); + + for part in buf.split(|&b| b == 0) { + if part.is_empty() { + continue; + } + let s = std::str::from_utf8(part) + .map_err(|_| UeventError::Invalid("non-UTF-8 data in uevent".into()))?; + + if let Some(eq_pos) = s.find('=') { + let key = s[..eq_pos].to_string(); + let value = s[eq_pos + 1..].to_string(); + properties.insert(key, value); + } + } + + if properties.is_empty() { + return Err(UeventError::Invalid("empty uevent (no key=value pairs)".into())); + } + + Ok(Uevent { properties }) + } + + // ----------------------------------------------------------- + // Convenience accessors + // ----------------------------------------------------------- + + /// Look up a property by key. + pub fn get(&self, key: &str) -> Option<&str> { + self.properties.get(key).map(|s| s.as_str()) + } + + /// The `ACTION` field (`add`, `remove`, `change`, …). + pub fn action(&self) -> Option<&str> { + self.get("ACTION") + } + + /// The `DEVPATH` field (kernel device path). + pub fn devpath(&self) -> Option<&str> { + self.get("DEVPATH") + } + + /// The `SUBSYSTEM` field. + pub fn subsystem(&self) -> Option<&str> { + self.get("SUBSYSTEM") + } + + /// The `DEVTYPE` field. + pub fn devtype(&self) -> Option<&str> { + self.get("DEVTYPE") + } + + /// The `SEQNUM` field, parsed as a `u64`. + pub fn seqnum(&self) -> Option { + self.get("SEQNUM").and_then(|s| s.parse().ok()) + } + + /// The `MAJOR` field, parsed as a `u32`. + pub fn major(&self) -> Option { + self.get("MAJOR").and_then(|s| s.parse().ok()) + } + + /// The `MINOR` field, parsed as a `u32`. + pub fn minor(&self) -> Option { + self.get("MINOR").and_then(|s| s.parse().ok()) + } +} + +impl std::fmt::Display for Uevent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Uevent(")?; + for (i, (k, v)) in self.properties.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{k}={v}")?; + } + write!(f, ")") + } +} + +// ========================================================================= +// Connection — netlink uevent listener +// ========================================================================= + +/// A netlink socket listening for kernel uevents. +/// +/// Create one via [`Connection::new`], then call [`next_uevent`](Self::next_uevent) +/// in a loop to receive device events as they happen. +pub struct Connection { + fd: AsyncFd, + buf: Vec, +} + +// SAFETY: `OwnedFd` is `Send` but not `Sync`; `AsyncFd` is `Sync`. +// The fd is only used from within `poll` / async contexts guarded by the +// `AsyncFd` readiness mechanism, making this safe. +unsafe impl Send for Connection {} +unsafe impl Sync for Connection {} + +impl Connection { + /// Open a new `NETLINK_KOBJECT_UEVENT` socket and bind to the kernel + /// multicast group so we can receive all device events. + pub fn new() -> Result { + // ---- socket(PF_NETLINK, SOCK_RAW | SOCK_CLOEXEC | SOCK_NONBLOCK, NETLINK_KOBJECT_UEVENT) ---- + let fd = unsafe { + let raw = libc::socket( + libc::AF_NETLINK, + libc::SOCK_RAW | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK, + NETLINK_KOBJECT_UEVENT, + ); + if raw < 0 { + return Err(UeventError::SocketCreate(io::Error::last_os_error())); + } + OwnedFd::from_raw_fd(raw) + }; + + // ---- bind: sockaddr_nl { .nl_family = AF_NETLINK, .nl_pid = 0, .nl_groups = 1 } ---- + let mut addr: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + addr.nl_family = libc::AF_NETLINK as libc::sa_family_t; + addr.nl_pid = 0; // let kernel choose + addr.nl_groups = NL_MULTICAST_GROUPS; + + let ret = unsafe { + libc::bind( + fd.as_raw_fd(), + &addr as *const libc::sockaddr_nl as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if ret < 0 { + return Err(UeventError::SocketBind(io::Error::last_os_error())); + } + + let async_fd = AsyncFd::new(fd).map_err(UeventError::SocketCreate)?; + + Ok(Connection { + fd: async_fd, + buf: vec![0u8; UEVENT_BUFFER_SIZE], + }) + } + + /// Wait for the next uevent and parse it. + /// + /// This is an async method that yields until a uevent is received. + pub async fn next_uevent(&mut self) -> Result { + loop { + let mut guard = self + .fd + .readable() + .await + .map_err(UeventError::Receive)?; + + let result = guard.try_io(|fd| { + let buf = &mut self.buf; + let ret = unsafe { + libc::read( + fd.as_raw_fd(), + buf.as_mut_ptr() as *mut libc::c_void, + buf.len(), + ) + }; + if ret < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(ret as usize) + } + }); + + match result { + Ok(Ok(n)) => { + if n > 0 { + return Uevent::from_raw(&self.buf[..n]); + } + // n == 0: EOF should not happen on a netlink socket. + return Err(UeventError::Receive( + io::Error::new(io::ErrorKind::UnexpectedEof, "netlink socket closed"), + )); + } + Ok(Err(e)) => { + return Err(UeventError::Receive(e)); + } + Err(_would_block) => { + continue; + } + } + } + } +} + +// ========================================================================= +// Iterator-style stream adapter (sync / blocking, useful for tests) +// ========================================================================= + +impl Connection { + /// Convenience: wrap into a blocking iterator so that callers can do + /// `for uev in conn.blocking_iter() { … }`. + /// + /// Each call to `next` blocks the current thread. + pub fn blocking_iter(&mut self) -> BlockingUeventIter<'_> { + BlockingUeventIter { conn: self } + } +} + +/// A blocking iterator over uevents (for non-async contexts / tests). +pub struct BlockingUeventIter<'a> { + conn: &'a mut Connection, +} + +impl Iterator for BlockingUeventIter<'_> { + type Item = Result; + + fn next(&mut self) -> Option { + // Block the current thread with a synchronous read. + // In practice this is only used for testing; the async + // `next_uevent()` is the primary API. + let fd = self.conn.fd.as_raw_fd(); + let buf = &mut self.conn.buf; + + loop { + let n = unsafe { + libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) + }; + if n > 0 { + return Some(Uevent::from_raw(&buf[..n as usize])); + } + if n == 0 { + return None; + } + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::WouldBlock { + // Spin-wait — not ideal, but acceptable for test code. + std::thread::yield_now(); + continue; + } + return Some(Err(UeventError::Receive(err))); + } + } +} + +// ========================================================================= +// Tests +// ========================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + // --------------------------------------------------------------- + // Uevent parsing + // --------------------------------------------------------------- + + #[test] + fn test_parse_typical_uevent() { + let raw = b"ACTION=add\0DEVPATH=/devices/pci0000:00/0000:00:14.0/usb1\0SUBSYSTEM=usb\0DEVTYPE=usb_device\0SEQNUM=1234\0MAJOR=189\0MINOR=0\0"; + let uev = Uevent::from_raw(raw).unwrap(); + assert_eq!(uev.action(), Some("add")); + assert_eq!(uev.subsystem(), Some("usb")); + assert_eq!(uev.devtype(), Some("usb_device")); + assert_eq!(uev.seqnum(), Some(1234)); + assert_eq!(uev.major(), Some(189)); + assert_eq!(uev.minor(), Some(0)); + assert!(uev.devpath().unwrap().contains("pci0000:00")); + } + + #[test] + fn test_parse_remove_uevent() { + let raw = b"ACTION=remove\0DEVPATH=/devices/virtual/tty/tty1\0SUBSYSTEM=tty\0SEQNUM=5678\0"; + let uev = Uevent::from_raw(raw).unwrap(); + assert_eq!(uev.action(), Some("remove")); + assert_eq!(uev.subsystem(), Some("tty")); + } + + #[test] + fn test_parse_uevent_with_extra_nulls() { + let raw = b"ACTION=change\0DEVPATH=/dev/foo\0\0\0SUBSYSTEM=block\0\0"; + let uev = Uevent::from_raw(raw).unwrap(); + assert_eq!(uev.action(), Some("change")); + assert_eq!(uev.subsystem(), Some("block")); + } + + #[test] + fn test_parse_uevent_missing_action_ok() { + // Some synthetic or old uevents may not have ACTION — that's fine. + let raw = b"DEVPATH=/dev/sda\0SUBSYSTEM=block\0"; + let uev = Uevent::from_raw(raw).unwrap(); + assert!(uev.action().is_none()); + assert_eq!(uev.subsystem(), Some("block")); + } + + #[test] + fn test_parse_empty_buffer() { + let err = Uevent::from_raw(b"").unwrap_err(); + assert!(err.to_string().contains("empty uevent")); + } + + #[test] + fn test_parse_non_utf8() { + let err = Uevent::from_raw(b"ACTION=add\0\xff\xfe\x00\0DEVPATH=/x\0").unwrap_err(); + assert!(err.to_string().contains("non-UTF-8")); + } + + #[test] + fn test_parse_no_equals_sign_ignored() { + // Tokens without '=' are simply (silently) skipped, which is fine. + let raw = b"ACTION=add\0SOMEGARBAGE\0SUBSYSTEM=usb\0"; + let uev = Uevent::from_raw(raw).unwrap(); + assert_eq!(uev.action(), Some("add")); + assert_eq!(uev.subsystem(), Some("usb")); + assert_eq!(uev.properties.len(), 2); + } + + #[test] + fn test_display() { + let raw = b"ACTION=add\0SUBSYSTEM=usb\0"; + let uev = Uevent::from_raw(raw).unwrap(); + let s = format!("{uev}"); + assert!(s.contains("ACTION=add")); + assert!(s.contains("SUBSYSTEM=usb")); + } + + // --------------------------------------------------------------- + // Connection — unit tests via from_raw (no netlink socket needed) + // --------------------------------------------------------------- + + #[test] + fn test_uevent_get() { + let raw = b"ACTION=add\0CUSTOM_KEY=custom_val\0"; + let uev = Uevent::from_raw(raw).unwrap(); + assert_eq!(uev.get("CUSTOM_KEY"), Some("custom_val")); + assert_eq!(uev.get("NONEXISTENT"), None); + } + + #[test] + fn test_uevent_clone() { + let raw = b"ACTION=add\0SUBSYSTEM=block\0"; + let uev1 = Uevent::from_raw(raw).unwrap(); + let uev2 = uev1.clone(); + assert_eq!(uev1.action(), uev2.action()); + assert_eq!(uev1.subsystem(), uev2.subsystem()); + } +} diff --git a/lxdeviced/Cargo.toml b/lxdeviced/Cargo.toml new file mode 100644 index 0000000..5e10a4c --- /dev/null +++ b/lxdeviced/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lxdeviced" +version = "0.1.0" +edition = "2024" + +[dependencies] +udev-core = { path = "../lib/udev-core" } +tokio = { version = "1", features = ["macros", "rt", "net", "io-util", "fs", "signal", "process", "sync", "time"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde_json = "1" diff --git a/lxdeviced/src/event.rs b/lxdeviced/src/event.rs new file mode 100644 index 0000000..7b37eef --- /dev/null +++ b/lxdeviced/src/event.rs @@ -0,0 +1,303 @@ +//! Event processing — ties uevents → rules → device actions. +//! +//! This module handles the lifecycle of a single uevent: +//! +//! 1. Receive uevent from netlink. +//! 2. Evaluate all rules via [`RuleEngine`]. +//! 3. If matched: +//! a. Create/update device node (`mknod`, `chmod`, `chown`). +//! b. Create symlinks. +//! c. Execute `RUN` programs (spawning worker processes). +//! d. Tag the device. +//! 4. If removed: clean up device node and symlinks. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; + +use udev_core::device::{self, DevType}; +use udev_core::rules::{RuleEngine, RuleResult, SubstContext}; +use udev_core::runtime::udev_db; +use udev_core::uevent::Uevent; + +// ═══════════════════════════════════════════════════════════════════════ +// Event processor +// ═══════════════════════════════════════════════════════════════════════ + +/// Handles the processing of each uevent, leveraging the rule engine and +/// device management. +pub struct EventProcessor { + /// Shared reference to the (potentially reloaded) rule engine. + pub engine: Arc>, + /// Track created device nodes for cleanup on remove. + pub devices: tokio::sync::RwLock>, + /// Whether the exec queue is paused. + pub exec_paused: tokio::sync::RwLock, + /// Maximum number of concurrent RUN workers. + pub max_children: tokio::sync::RwLock, + /// Active RUN worker count. + pub active_workers: Arc, +} + +/// Tracked state for a device. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DeviceState { + /// The device node path. + pub devnode: PathBuf, + /// Symlinks pointing to this device. + pub symlinks: Vec, + /// Device type. + pub dev_type: DevType, + /// Major number. + pub major: u32, + /// Minor number. + pub minor: u32, + /// Device tags. + pub tags: Vec, + /// Device properties (from uevent + rules). + pub properties: HashMap, + /// Whether the database entry should persist after removal. + pub db_persist: bool, +} + +impl EventProcessor { + /// Create a new event processor. + pub fn new(engine: Arc>) -> Self { + EventProcessor { + engine, + devices: tokio::sync::RwLock::new(HashMap::new()), + exec_paused: tokio::sync::RwLock::new(false), + max_children: tokio::sync::RwLock::new(8), + active_workers: Arc::new(AtomicU32::new(0)), + } + } + + /// Process a single uevent — the main event loop calls this. + pub async fn process(&self, uevent: &Uevent) { + let action = uevent.action().unwrap_or("change"); + let devpath = uevent.devpath().unwrap_or(""); + let subsystem = uevent.subsystem().unwrap_or(""); + let major = uevent.major().unwrap_or(0); + let minor = uevent.minor().unwrap_or(0); + + tracing::debug!( + "processing {} event: {} (subsystem={}, {}:{})", + action, + devpath, + subsystem, + major, + minor + ); + + match action { + "add" | "change" | "move" | "online" | "bind" => { + self.process_add_or_change(uevent).await; + } + "remove" | "offline" | "unbind" => { + self.process_remove(uevent).await; + } + other => { + tracing::debug!("unhandled action '{}' for {}", other, devpath); + } + } + } + + /// Handle device add/change events. + async fn process_add_or_change(&self, uevent: &Uevent) { + let engine = self.engine.read().await; + let mut ctx = SubstContext::from_uevent(uevent); + + let result = engine.evaluate(uevent, &mut ctx); + + drop(engine); + + let Some(result) = result else { + tracing::trace!("no rule matched for {}", uevent.devpath().unwrap_or("?")); + return; + }; + + let devpath = uevent.devpath().unwrap_or(""); + let subsystem = uevent.subsystem().unwrap_or(""); + let major = uevent.major().unwrap_or(0); + let minor = uevent.minor().unwrap_or(0); + + // Determine device node path + let devnode_path = result.devnode.clone().unwrap_or_else(|| { + device::devpath_from_uevent(devpath, Some(subsystem), None) + }); + + // Skip for subsystem that don't have device nodes + if subsystem == "net" { + // Network devices don't have /dev nodes, but still process RUN/TAG + self.run_commands(&result).await; + return; + } + + // Determine device type from subsystem + let dev_type = match subsystem { + "block" => DevType::Block, + _ => DevType::Char, + }; + + let mode = result.mode.unwrap_or(0o660); + + // Create device node + tracing::debug!( + "creating device node {:?} ({}:{}, mode={:o})", + devnode_path, + major, + minor, + mode + ); + + if let Err(e) = device::mknod(&devnode_path, dev_type, major, minor, mode) { + tracing::error!("failed to create device node {:?}: {}", devnode_path, e); + return; + } + + // Set permissions + if let Err(e) = device::chmod(&devnode_path, mode) { + tracing::warn!("failed to chmod {:?}: {}", devnode_path, e); + } + + // Set ownership + if let (Some(uid), Some(gid)) = (result.uid, result.gid) + && let Err(e) = device::chown(&devnode_path, uid, gid) { + tracing::warn!("failed to chown {:?}: {}", devnode_path, e); + } + + // Create symlinks + let mut link_paths = Vec::new(); + for link in &result.symlinks { + let link_path = if link.starts_with('/') { + PathBuf::from(link) + } else { + PathBuf::from("/dev").join(link) + }; + if let Err(e) = device::symlink(&devnode_path, &link_path) { + tracing::warn!("failed to create symlink {:?} -> {:?}: {}", link_path, devnode_path, e); + } else { + link_paths.push(link_path); + } + } + + // Store device state + { + let mut devices = self.devices.write().await; + devices.insert( + devpath.to_string(), + DeviceState { + devnode: devnode_path.clone(), + symlinks: link_paths.clone(), + dev_type, + major, + minor, + tags: result.tags.clone(), + properties: ctx.env.clone(), + db_persist: result.db_persist, + }, + ); + } + + // ── Write udev database entry ─────────────────────────────────── + let devnode_str = devnode_path.to_string_lossy().to_string(); + let db_entry = udev_db::DbEntry { + seqnum: uevent.seqnum().unwrap_or(0), + properties: ctx.env.clone(), + symlinks: result.symlinks.clone(), + tags: result.tags.clone(), + link_priority: 0, + devnode: devnode_str, + }; + if let Err(e) = udev_db::write_db(subsystem, major, minor, devpath, &db_entry) { + tracing::warn!("failed to write udev database entry for {}: {}", devpath, e); + } + + // Execute RUN commands + self.run_commands(&result).await; + } + + /// Handle device remove events. + async fn process_remove(&self, uevent: &Uevent) { + let devpath = uevent.devpath().unwrap_or(""); + let subsystem = uevent.subsystem().unwrap_or(""); + let major = uevent.major().unwrap_or(0); + let minor = uevent.minor().unwrap_or(0); + + let mut devices = self.devices.write().await; + let db_persist = devices.get(devpath).map(|s| s.db_persist).unwrap_or(false); + + if let Some(state) = devices.remove(devpath) { + tracing::debug!("removing device {:?}", state.devnode); + device::remove_device_and_links(&state.devnode, &state.symlinks); + + // Remove database entry (unless db_persist is set) + if !db_persist { + if let Err(e) = udev_db::remove_db(subsystem, major, minor, devpath) { + tracing::warn!("failed to remove udev database entry for {}: {}", devpath, e); + } + } else { + tracing::trace!("db_persist set, keeping database entry for {}", devpath); + } + } else { + // Device not tracked locally — still try to clean up the database entry + // (useful for coldplug where udevadm may have created entries) + tracing::trace!("no tracked state for {}, cleaning up database entry", devpath); + let _ = udev_db::remove_db(subsystem, major, minor, devpath); + } + } + + /// Execute RUN command directives. + async fn run_commands(&self, result: &RuleResult) { + if result.run_commands.is_empty() { + return; + } + + let paused = *self.exec_paused.read().await; + if paused { + tracing::debug!("exec queue is paused, deferring {} RUN commands", result.run_commands.len()); + return; + } + + for (prog, args) in &result.run_commands { + let max = *self.max_children.read().await; + let active = self.active_workers.load(Ordering::Relaxed); + + if active >= max { + tracing::warn!("max children ({}) reached, deferring RUN: {}", max, prog); + break; + } + + self.active_workers.fetch_add(1, Ordering::Relaxed); + + let prog = prog.clone(); + let args = args.clone(); + let workers = self.active_workers.clone(); + + tokio::spawn(async move { + tracing::debug!("RUN: {} {:?}", prog, args); + match tokio::process::Command::new(&prog) + .args(&args) + .spawn() + { + Ok(mut child) => { + let _ = child.wait().await; + } + Err(e) => { + tracing::error!("failed to spawn RUN '{}': {}", prog, e); + } + } + workers.fetch_sub(1, Ordering::Relaxed); + }); + } + } + + /// Wait until active workers all finish. + pub async fn drain_workers(&self) { + while self.active_workers.load(Ordering::Relaxed) > 0 { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } +} diff --git a/lxdeviced/src/main.rs b/lxdeviced/src/main.rs new file mode 100644 index 0000000..38fd758 --- /dev/null +++ b/lxdeviced/src/main.rs @@ -0,0 +1,353 @@ +//! lxdeviced — A systemd-udev compatible device event manager daemon. +//! +//! ```text +//! lxdeviced [OPTIONS] +//! ``` +//! +//! Listens for kernel uevents over `NETLINK_KOBJECT_UEVENT`, evaluates udev +//! rules, manages device nodes, and provides a Varlink control interface at +//! `/run/udev/control`. +//! +//! # Options +//! +//! * `--debug` / `-d` — Enable debug logging. +//! * `--trace` / `-t` — Enable trace logging (very verbose). +//! * `--children=` — Max concurrent worker processes (default: 8). +//! * `--help` / `-h` — Print usage. + +// Shared runtime modules (re-exported from udev-core via lxdeviced's runtime) +mod runtime; +// Daemon-specific modules +mod event; + +use std::sync::Arc; +use tokio::signal::unix::{signal, SignalKind}; +use udev_core::config::Config; +use udev_core::rules::RuleEngine; +use udev_core::runtime::control::{ControlCommand, ControlError}; +use crate::event::EventProcessor; +use crate::runtime::control_daemon::{ControlListener, ControlResponder}; + +// ── Constants ──────────────────────────────────────────────────────────── + +/// Default udev runtime directory. +const RUN_UDEV_DIR: &str = "/run/udev"; + +// ── Program entry point ────────────────────────────────────────────────── + +#[tokio::main(flavor = "current_thread")] +async fn main() { + // ── Parse CLI arguments ──────────────────────────────────────────── + let args: Vec = std::env::args().collect(); + let mut log_level = "info"; + let mut children_max: u32 = 8; + + for arg in &args[1..] { + match arg.as_str() { + "--debug" | "-d" => log_level = "debug", + "--trace" | "-t" => log_level = "trace", + "--help" | "-h" => { + print_usage(); + return; + } + _ => { + if let Some(val) = arg.strip_prefix("--children=") { + if let Ok(n) = val.parse::() { + children_max = n; + } + } else if arg.starts_with("--") || arg.starts_with('-') { + eprintln!("unknown option: {arg}"); + print_usage(); + std::process::exit(1); + } + } + } + } + + // ── Initialize logging ───────────────────────────────────────────── + use tracing_subscriber::EnvFilter; + + let filter = match log_level { + "trace" => EnvFilter::new("trace"), + "debug" => EnvFilter::new("debug"), + _ => EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info")), + }; + + tracing_subscriber::fmt() + .with_env_filter(filter) + .init(); + + tracing::info!("lxdeviced starting (max_children={})", children_max); + + // ── Create runtime directory ─────────────────────────────────────── + if let Err(e) = tokio::fs::create_dir_all(RUN_UDEV_DIR).await { + tracing::error!("failed to create {}: {}", RUN_UDEV_DIR, e); + std::process::exit(1); + } + + // ── Create database directory /run/udev/data ─────────────────────── + if let Err(e) = tokio::fs::create_dir_all(udev_core::runtime::udev_db::DATA_DIR).await { + tracing::error!("failed to create {}: {}", udev_core::runtime::udev_db::DATA_DIR, e); + std::process::exit(1); + } + // Clean stale temporary files from previous runs + let _ = udev_core::runtime::udev_db::clean_stale(); + + // ── Load configuration ───────────────────────────────────────────── + let cfg = Config::load(); + tracing::info!("configuration loaded: {} rule files", cfg.rules.len()); + + // ── Create rule engine ───────────────────────────────────────────── + let rule_engine = Arc::new(tokio::sync::RwLock::new(RuleEngine::new(&cfg.rules, &cfg.hwdb))); + + // ── Create event processor ───────────────────────────────────────── + let processor = Arc::new(EventProcessor::new(rule_engine.clone())); + + // ── Set max children ─────────────────────────────────────────────── + { + let mut max = processor.max_children.write().await; + *max = children_max; + } + + // ── Bind control socket ──────────────────────────────────────────── + let control_socket = match ControlListener::bind_at( + format!("{}/io.systemd.Udev", RUN_UDEV_DIR), + ) + .await + { + Ok(listener) => { + tracing::info!("control socket: {}/io.systemd.Udev", RUN_UDEV_DIR); + + // Create compatibility symlink /run/udev/control -> io.systemd.Udev + let compat = format!("{}/control", RUN_UDEV_DIR); + let _ = tokio::fs::remove_file(&compat).await; + if let Err(e) = std::os::unix::fs::symlink("io.systemd.Udev", &compat) { + tracing::warn!("cannot create compat symlink {}: {}", compat, e); + } + + listener + } + Err(e) => { + tracing::error!("failed to bind control socket: {e}"); + std::process::exit(1); + } + }; + + // ── Open netlink uevent connection ───────────────────────────────── + let mut uevent_conn = match udev_core::uevent::Connection::new() { + Ok(conn) => { + tracing::info!("uevent listener: NETLINK_KOBJECT_UEVENT"); + conn + } + Err(e) => { + tracing::error!("failed to open uevent connection: {e}"); + std::process::exit(1); + } + }; + + // ── Signal handling setup ────────────────────────────────────────── + let mut sighup = signal(SignalKind::hangup()).expect("failed to create SIGHUP handler"); + let mut sigterm = signal(SignalKind::terminate()).expect("failed to create SIGTERM handler"); + let mut sigint = signal(SignalKind::interrupt()).expect("failed to create SIGINT handler"); + + let shutdown = Arc::new(tokio::sync::Notify::new()); + + // ── Task: handle SIGHUP (reload) ─────────────────────────────────── + let reload_rule_engine = rule_engine.clone(); + tokio::spawn(async move { + loop { + sighup.recv().await; + tracing::info!("received SIGHUP — reloading configuration"); + let cfg = Config::load(); + reload_rule_engine.write().await.reload(&cfg.rules, &cfg.hwdb); + } + }); + + // ── Task: handle SIGTERM/SIGINT (shutdown) ───────────────────────── + let shutdown_signal = shutdown.clone(); + tokio::spawn(async move { + tokio::select! { + _ = sigterm.recv() => { tracing::info!("received SIGTERM"); } + _ = sigint.recv() => { tracing::info!("received SIGINT"); } + } + shutdown_signal.notify_waiters(); + }); + + // ── Task: control socket handler ──────────────────────────────────── + let ctl_processor = processor.clone(); + let ctl_shutdown = shutdown.clone(); + let ctl_rule_engine = rule_engine.clone(); + let mut ctl_listener = control_socket; + + tokio::spawn(async move { + loop { + let (cmd, responder) = match ctl_listener.next_command().await { + Ok(c) => c, + Err(ControlError::Accept(e)) => { + tracing::error!("control accept error: {}", e); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + Err(e) => { + tracing::error!("control error: {}", e); + continue; + } + }; + + let proc = ctl_processor.clone(); + let shutdown = ctl_shutdown.clone(); + let re = ctl_rule_engine.clone(); + + tokio::spawn(async move { + handle_control_command(cmd, responder, proc, shutdown, re).await; + }); + } + }); + + // ── Main event loop ──────────────────────────────────────────────── + tracing::info!("lxdeviced ready — processing uevents"); + + loop { + tokio::select! { + _ = shutdown.notified() => { + tracing::info!("shutting down..."); + processor.drain_workers().await; + break; + } + uevent_result = uevent_conn.next_uevent() => { + match uevent_result { + Ok(uevent) => { + tokio::spawn({ + let p = processor.clone(); + async move { + p.process(&uevent).await; + } + }); + } + Err(e) => { + tracing::error!("uevent receive error: {}", e); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + match udev_core::uevent::Connection::new() { + Ok(new_conn) => { + uevent_conn = new_conn; + tracing::info!("reconnected to netlink uevent"); + } + Err(e2) => { + tracing::error!("failed to reconnect: {}", e2); + } + } + } + } + } + } + } + + tracing::info!("lxdeviced stopped"); +} + +// ── Control command handler ────────────────────────────────────────────── + +async fn handle_control_command( + cmd: ControlCommand, + mut responder: ControlResponder, + processor: Arc, + shutdown: Arc, + rule_engine: Arc>, +) { + tracing::debug!("control command: {cmd}"); + + match cmd { + ControlCommand::Ping => { + let _ = responder.respond_ok().await; + } + ControlCommand::Reload => { + let cfg = Config::load(); + rule_engine.write().await.reload(&cfg.rules, &cfg.hwdb); + tracing::info!("reload command processed"); + let _ = responder.respond_ok().await; + } + ControlCommand::SetLogLevel { level } => { + let filter = match level { + Some(l) => match l { + 0..=1 => "error", + 2..=3 => "warn", + 4 => "info", + 5..=6 => "debug", + _ => "trace", + }, + None => "info", + }; + tracing::info!("set log level to '{}' (requested: {:?})", filter, level); + let _ = responder.respond_ok().await; + } + ControlCommand::GetEnvironment => { + let resp = serde_json::json!({ "environment": [] }); + let _ = responder.respond_value(&resp).await; + } + ControlCommand::SetTrace { enable } => { + tracing::info!("trace mode: {enable}"); + let _ = responder.respond_ok().await; + } + ControlCommand::SetChildrenMax { number } => { + let mut max = processor.max_children.write().await; + *max = number; + tracing::info!("max children set to {number}"); + let _ = responder.respond_ok().await; + } + ControlCommand::SetEnvironment { assignments } => { + for a in &assignments { + tracing::info!("environment assignment: {a}"); + } + let _ = responder.respond_ok().await; + } + ControlCommand::Revert => { + tracing::info!("reverting configuration changes"); + let _ = responder.respond_ok().await; + } + ControlCommand::StartExecQueue => { + let mut paused = processor.exec_paused.write().await; + *paused = false; + tracing::info!("exec queue started"); + let _ = responder.respond_ok().await; + } + ControlCommand::StopExecQueue => { + let mut paused = processor.exec_paused.write().await; + *paused = true; + tracing::info!("exec queue stopped"); + let _ = responder.respond_ok().await; + } + ControlCommand::Exit => { + tracing::info!("exit command received, shutting down"); + let _ = responder.respond_ok().await; + shutdown.notify_waiters(); + } + ControlCommand::Unknown { method, .. } => { + let _ = responder + .respond_error( + "io.systemd.service.MethodNotFound", + Some(&format!("unknown method: {method}")), + ) + .await; + } + } +} + +// ── Usage ──────────────────────────────────────────────────────────────── + +fn print_usage() { + eprintln!( + "\ +lxdeviced — systemd-udev compatible device event manager + +USAGE: + lxdeviced [OPTIONS] + +OPTIONS: + -d, --debug Enable debug logging + -t, --trace Enable trace logging + --children= Max concurrent RUN worker processes (default: 8) + -h, --help Print this help message +" + ); +} diff --git a/lxdeviced/src/runtime/control_daemon.rs b/lxdeviced/src/runtime/control_daemon.rs new file mode 100644 index 0000000..66bcf69 --- /dev/null +++ b/lxdeviced/src/runtime/control_daemon.rs @@ -0,0 +1,159 @@ +//! Udev control daemon — async Varlink server for udev control commands. +//! +//! Provides [`ControlListener`] (accepts connections) and +//! [`ControlResponder`] (sends replies), both using `tokio` async I/O. +//! +//! The shared protocol types (`ControlCommand`, `ControlSender`, +//! `ControlError`, `VarlinkRequest`, `VarlinkResponse`, etc.) live in the +//! `udev-core` crate at `udev_core::runtime::control`. + +use std::io; +use std::path::Path; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::unix::OwnedWriteHalf; +use tokio::net::UnixListener; + +use udev_core::runtime::control::{ControlCommand, ControlError, VarlinkResponse}; + +// ========================================================================= +// ControlListener — receive commands (daemon side, async) +// ========================================================================= + +/// An async Varlink server that accepts control connections from `udevadm` +/// on the udev control socket. +pub struct ControlListener { + listener: UnixListener, +} + +/// A handle for responding to a Varlink control command. +/// +/// Obtained from [`ControlListener::next_command`]. +pub struct ControlResponder { + write: OwnedWriteHalf, +} + +impl ControlResponder { + /// Send a successful Varlink response (`{"parameters": {}}`) back to + /// the client. + pub async fn respond_ok(&mut self) -> Result<(), ControlError> { + self.respond_value(&serde_json::json!({})).await + } + + /// Send a Varlink response with the given `parameters` object. + pub async fn respond_value( + &mut self, + parameters: &serde_json::Value, + ) -> Result<(), ControlError> { + let resp = VarlinkResponse { + error: None, + parameters: parameters.clone(), + }; + let mut json = serde_json::to_vec(&resp)?; + json.push(b'\0'); + self.write + .write_all(&json) + .await + .map_err(ControlError::Send) + } + + /// Send a Varlink error response. + pub async fn respond_error( + &mut self, + method_error: &str, + explanation: Option<&str>, + ) -> Result<(), ControlError> { + let params = match explanation { + Some(msg) => serde_json::json!({ "explanation": msg }), + None => serde_json::Value::Object(Default::default()), + }; + let error_frame = VarlinkResponse { + error: Some(method_error.to_string()), + parameters: params, + }; + let mut json = serde_json::to_vec(&error_frame)?; + json.push(b'\0'); + self.write + .write_all(&json) + .await + .map_err(ControlError::Send) + } +} + +impl ControlListener { + /// Bind the control socket at the compatibility path + /// (`/run/udev/control`). + /// + /// If the socket file already exists, it is removed first to clean up + /// stale sockets from a previous daemon instance. + #[allow(dead_code)] + pub async fn bind() -> Result { + Self::bind_at(udev_core::runtime::control::COMPAT_CONTROL_SOCKET).await + } + + /// Bind the control socket at a custom path. + pub async fn bind_at(path: impl AsRef) -> Result { + let path = path.as_ref(); + let _ = tokio::fs::remove_file(path).await; + + let listener = UnixListener::bind(path).map_err(|e| ControlError::Bind { + path: path.display().to_string(), + source: e, + })?; + + Ok(ControlListener { listener }) + } + + /// Accept the next Varlink control connection and parse the first + /// request. + /// + /// Returns the parsed [`ControlCommand`] together with a + /// [`ControlResponder`] for sending a reply. + pub async fn next_command( + &mut self, + ) -> Result<(ControlCommand, ControlResponder), ControlError> { + let (stream, _addr) = self + .listener + .accept() + .await + .map_err(ControlError::Accept)?; + + let (read, write) = stream.into_split(); + let mut reader = BufReader::new(read); + + // Read until the first NUL byte — that's the complete Varlink frame. + let mut frame = Vec::new(); + loop { + let available = reader + .fill_buf() + .await + .map_err(ControlError::Receive)?; + + if available.is_empty() { + return Err(ControlError::Receive(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Varlink frame truncated (missing NUL terminator)", + ))); + } + + match available.iter().position(|&b| b == b'\0') { + Some(n) => { + frame.extend_from_slice(&available[..n]); + reader.consume(n + 1); + break; + } + None => { + frame.extend_from_slice(available); + let len = available.len(); + reader.consume(len); + } + } + } + + let value: serde_json::Value = + serde_json::from_slice(&frame).map_err(ControlError::Json)?; + + let cmd = ControlCommand::from_varlink(&value)?; + + Ok((cmd, ControlResponder { write })) + } +} diff --git a/lxdeviced/src/runtime/mod.rs b/lxdeviced/src/runtime/mod.rs new file mode 100644 index 0000000..de1dbe9 --- /dev/null +++ b/lxdeviced/src/runtime/mod.rs @@ -0,0 +1,2 @@ +// Daemon-specific control listener/responder +pub mod control_daemon; diff --git a/misc/lxdeviced.airs b/misc/lxdeviced.airs new file mode 100644 index 0000000..f8e652a --- /dev/null +++ b/misc/lxdeviced.airs @@ -0,0 +1,13 @@ +[service] +display-name = "Linux Device Daemon" +description = "Discovery and management service of Linux devices, compatible with udev interface" + +[exec] +start = "lxdeviced" + +[env] +clear_vars = true +working_dir = "/" + +[env.vars] +PATH = "/usr/bin" diff --git a/misc/rules/50-udev-default.rules b/misc/rules/50-udev-default.rules new file mode 100644 index 0000000..de32d0e --- /dev/null +++ b/misc/rules/50-udev-default.rules @@ -0,0 +1,129 @@ +# do not edit this file, it will be overwritten on update + +# run a command on remove events +ACTION=="remove", ENV{REMOVE_CMD}!="", RUN+="$env{REMOVE_CMD}" +ACTION=="remove", GOTO="default_end" + +# The md driver increments diskseq *after* emitting 'change' uevent. +# Drop the line below if it is fixed on the kernel side. +SUBSYSTEM=="block", KERNEL=="md*", ENV{ID_IGNORE_DISKSEQ}="1" + +SUBSYSTEM=="virtio-ports", KERNEL=="vport*", ATTR{name}=="?*", SYMLINK+="virtio-ports/$attr{name}" + +SUBSYSTEM=="rtc", GROUP="clock", MODE="0660" +# select "system RTC" or just use the first one +SUBSYSTEM=="rtc", ATTR{hctosys}=="1", SYMLINK+="rtc" +SUBSYSTEM=="rtc", KERNEL=="rtc0", SYMLINK+="rtc", OPTIONS+="link_priority=-100" + +SUBSYSTEM=="hidraw", IMPORT{builtin}="hwdb" + +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", IMPORT{builtin}="usb_id", IMPORT{builtin}="hwdb --subsystem=usb" +ENV{MODALIAS}!="", IMPORT{builtin}="hwdb --subsystem=$env{SUBSYSTEM}" + +# Before c43ff248f94266cfc93e300a2d3d163ed805e55b, the following line in +# 60-drm.rules also sets ID_PATH for all pci, usb, and platform devices: +#### +# ACTION!="remove", SUBSYSTEM=="drm", SUBSYSTEMS=="pci|usb|platform", IMPORT{builtin}="path_id" +#### +# Unfortunately, some existing rules already rely on the unexpected behavior. +# To keep the backward compatibility, let's set ID_PATH for them. +SUBSYSTEM=="pci|usb|platform", IMPORT{builtin}="path_id" + +SUBSYSTEM=="net", IMPORT{builtin}="net_driver" + +SUBSYSTEM=="ptp", GROUP="clock", MODE="0664" +SUBSYSTEM=="ptp", ATTR{clock_name}=="KVM virtual PTP", SYMLINK+="ptp_kvm" +SUBSYSTEM=="ptp", ATTR{clock_name}=="hyperv", SYMLINK+="ptp_hyperv" +SUBSYSTEM=="ptp", ATTR{clock_name}=="ptp_vmw", SYMLINK+="ptp_vmware" +SUBSYSTEM=="ptp", ATTR{clock_name}=="s390 Physical Clock", SYMLINK+="ptp_s390_physical" +SUBSYSTEM=="ptp", ATTR{clock_name}=="s390 STCKE Clock", SYMLINK+="ptp_s390_stcke" + +ACTION!="add", GOTO="default_end" + +SUBSYSTEM=="mem", KERNEL=="null", GROUP="root", MODE="0666" + +SUBSYSTEM=="tty", KERNEL=="ptmx", GROUP="tty", MODE="0666" +SUBSYSTEM=="tty", KERNEL=="tty", GROUP="tty", MODE="0666" +SUBSYSTEM=="tty", KERNEL=="tty[0-9]*|hvc[0-9]*|sclp_line[0-9]*|ttysclp[0-9]*|3270/tty[0-9]*", GROUP="tty", MODE="{{TTY_MODE}}" +SUBSYSTEM=="vc", KERNEL=="vcs*|vcsa*", GROUP="tty" +KERNEL=="tty[A-Z]*[0-9]|ttymxc[0-9]*|pppox[0-9]*|ircomm[0-9]*|noz[0-9]*|rfcomm[0-9]*", GROUP="dialout" + +SUBSYSTEM=="mem", KERNEL=="mem|kmem|port", GROUP="kmem", MODE="0640" + +SUBSYSTEM=="input", GROUP="input" +SUBSYSTEM=="input", KERNEL=="js[0-9]*", MODE="0664" + +SUBSYSTEM=="video4linux", GROUP="video" +SUBSYSTEM=="graphics", GROUP="video" +SUBSYSTEM=="drm", KERNEL!="renderD*", GROUP="video" +SUBSYSTEM=="dvb", GROUP="video" +SUBSYSTEM=="media", GROUP="video" +SUBSYSTEM=="cec", GROUP="video" + +SUBSYSTEM=="drm", KERNEL=="renderD*", GROUP="render", MODE="{{GROUP_RENDER_MODE}}" +SUBSYSTEM=="kfd", GROUP="render", MODE="{{GROUP_RENDER_MODE}}" +SUBSYSTEM=="accel", GROUP="render", MODE="{{GROUP_RENDER_MODE}}" + +SUBSYSTEM=="misc", KERNEL=="sgx_enclave", GROUP="sgx", MODE="0660" +SUBSYSTEM=="misc", KERNEL=="sgx_vepc", GROUP="sgx", MODE="0660" + +# When using static_node= with non-default permissions, also update +# tmpfiles.d/static-nodes-permissions.conf.in to keep permissions synchronized. + +SUBSYSTEM=="sound", GROUP="audio", \ + OPTIONS+="static_node=snd/seq", OPTIONS+="static_node=snd/timer" + +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", MODE="0664" + +SUBSYSTEM=="firewire", TEST=="units", TEST=="model", \ + IMPORT{builtin}="hwdb 'ieee1394:node:ven$attr{vendor}mo$attr{model}units$attr{units}'" + +SUBSYSTEM=="firewire", TEST=="units", TEST!="model", \ + IMPORT{builtin}="hwdb 'ieee1394:node:ven$attr{vendor}units$attr{units}'" + +SUBSYSTEM=="firewire", TEST=="units", ENV{IEEE1394_UNIT_FUNCTION_MIDI}=="1", GROUP="audio" +SUBSYSTEM=="firewire", TEST=="units", ENV{IEEE1394_UNIT_FUNCTION_AUDIO}=="1", GROUP="audio" +SUBSYSTEM=="firewire", TEST=="units", ENV{IEEE1394_UNIT_FUNCTION_VIDEO}=="1", GROUP="video" + +KERNEL=="parport[0-9]*", GROUP="lp" +SUBSYSTEM=="printer", KERNEL=="lp*", GROUP="lp" +SUBSYSTEM=="ppdev", GROUP="lp" +KERNEL=="lp[0-9]*", GROUP="lp" +KERNEL=="irlpt[0-9]*", GROUP="lp" +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ENV{ID_USB_INTERFACES}=="*:0701??:*", GROUP="lp" + +SUBSYSTEM=="block", GROUP="disk" +SUBSYSTEM=="block", KERNEL=="sr[0-9]*", GROUP="cdrom" +SUBSYSTEM=="scsi_generic", SUBSYSTEMS=="scsi", ATTRS{type}=="4|5", GROUP="cdrom" +KERNEL=="sch[0-9]*", GROUP="cdrom" +KERNEL=="pktcdvd[0-9]*", GROUP="cdrom" +KERNEL=="pktcdvd", GROUP="cdrom" + +SUBSYSTEM=="scsi_generic|scsi_tape", SUBSYSTEMS=="scsi", ATTRS{type}=="1|8", GROUP="tape" +SUBSYSTEM=="scsi_generic", SUBSYSTEMS=="scsi", ATTRS{type}=="0", GROUP="disk" +KERNEL=="qft[0-9]*|nqft[0-9]*|zqft[0-9]*|nzqft[0-9]*|rawqft[0-9]*|nrawqft[0-9]*", GROUP="disk" +KERNEL=="loop-control", GROUP="disk", OPTIONS+="static_node=loop-control" +KERNEL=="btrfs-control", GROUP="disk" +KERNEL=="rawctl", GROUP="disk" +SUBSYSTEM=="raw", KERNEL=="raw[0-9]*", GROUP="disk" +SUBSYSTEM=="aoe", GROUP="disk", MODE="0220" +SUBSYSTEM=="aoe", KERNEL=="err", MODE="0440" + +KERNEL=="rfkill", MODE="0664" +KERNEL=="tun", MODE="0666", OPTIONS+="static_node=net/tun" + +KERNEL=="fuse", MODE="0666", OPTIONS+="static_node=fuse" + +# The static_node is required on s390x and ppc (they are using MODULE_ALIAS) +KERNEL=="kvm", GROUP="kvm", MODE="{{DEV_KVM_MODE}}", OPTIONS+="static_node=kvm" + +KERNEL=="vfio", MODE="0666", OPTIONS+="static_node=vfio/vfio" + +KERNEL=="vsock", MODE="0666" +KERNEL=="vhost-vsock", GROUP="kvm", MODE="{{DEV_KVM_MODE}}", OPTIONS+="static_node=vhost-vsock" + +KERNEL=="vhost-net", GROUP="kvm", MODE="{{DEV_KVM_MODE}}", OPTIONS+="static_node=vhost-net" + +KERNEL=="udmabuf", GROUP="kvm" + +LABEL="default_end" diff --git a/misc/rules/60-autosuspend.rules b/misc/rules/60-autosuspend.rules new file mode 100644 index 0000000..ce31a92 --- /dev/null +++ b/misc/rules/60-autosuspend.rules @@ -0,0 +1,22 @@ +# do not edit this file, it will be overwritten on update + +ACTION!="add", GOTO="autosuspend_end" + +# I2C rules +SUBSYSTEM=="i2c", ATTR{name}=="cyapa", \ + ATTR{power/control}="on", GOTO="autosuspend_end" + +# Enable autosuspend if hwdb says so. Here we are relying on +# the hwdb import done earlier based on MODALIAS. +ENV{ID_AUTOSUSPEND}=="1", TEST=="power/control", \ + ATTR{power/control}="auto" + +# Disable USB persist if hwdb says so. +ENV{ID_PERSIST}=="0", TEST=="power/persist", \ + ATTR{power/persist}="0" + +# Set up an autosuspend delay if hwdb say so +ENV{ID_AUTOSUSPEND_DELAY_MS}!="", TEST=="power/control", \ + ATTR{power/autosuspend_delay_ms}="$env{ID_AUTOSUSPEND_DELAY_MS}" + +LABEL="autosuspend_end" diff --git a/misc/rules/60-block.rules b/misc/rules/60-block.rules new file mode 100644 index 0000000..6e159ab --- /dev/null +++ b/misc/rules/60-block.rules @@ -0,0 +1,24 @@ +# do not edit this file, it will be overwritten on update + +# enable in-kernel media-presence polling +ACTION=="add", SUBSYSTEM=="module", KERNEL=="block", ATTR{parameters/events_dfl_poll_msecs}=="0", \ + ATTR{parameters/events_dfl_poll_msecs}="2000" + +# forward scsi device event to corresponding block device +ACTION=="change", SUBSYSTEM=="scsi", ENV{DEVTYPE}=="scsi_device", TEST=="block", ATTR{block/*/uevent}="change" + +# watch metadata changes, caused by tools closing the device node which was opened for writing +ACTION!="remove", SUBSYSTEM=="block", \ + KERNEL=="loop*|mmcblk*[0-9]|msblk*[0-9]|mspblk*[0-9]|nvme*|sd*|vd*|xvd*|bcache*|cciss*|dasd*|ubd*|ubi*|scm*|pmem*|nbd*|zd*|rbd*|zram*|ublkb*", \ + OPTIONS+="watch" + +# Reset access rights to each loopback device once it gets detached. +ACTION=="change", SUBSYSTEM=="block", KERNEL=="loop*", ENV{DISK_MEDIA_CHANGE}=="1", TEST!="loop/backing_file", GROUP="disk", MODE="660" + +# Provide a somewhat cleaned up field indicating the subsystem various +# 'virtual' block devices belong to, in order to avoid replicating name based +# pattern matching in every consumer +ACTION!="remove", SUBSYSTEM=="block", KERNEL=="dm-*", ENV{ID_BLOCK_SUBSYSTEM}="dm" +ACTION!="remove", SUBSYSTEM=="block", KERNEL=="loop*", ENV{ID_BLOCK_SUBSYSTEM}="loop" +ACTION!="remove", SUBSYSTEM=="block", KERNEL=="md*", ENV{ID_BLOCK_SUBSYSTEM}="md" +ACTION!="remove", SUBSYSTEM=="block", KERNEL=="zram*", ENV{ID_BLOCK_SUBSYSTEM}="zram" diff --git a/misc/rules/60-cdrom_id.rules b/misc/rules/60-cdrom_id.rules new file mode 100644 index 0000000..288f8ce --- /dev/null +++ b/misc/rules/60-cdrom_id.rules @@ -0,0 +1,29 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="cdrom_end" +SUBSYSTEM!="block", GOTO="cdrom_end" +KERNEL!="sr[0-9]*|vdisk*|xvd*", GOTO="cdrom_end" +ENV{DEVTYPE}!="disk", GOTO="cdrom_end" + +# unconditionally tag device as CDROM +KERNEL=="sr[0-9]*", ENV{ID_CDROM}="1" + +# stop automatically any mount units bound to the device if the media eject +# button is pressed. +ENV{ID_CDROM}=="1", ENV{SYSTEMD_MOUNT_DEVICE_BOUND}="1" + +# media eject button pressed +ENV{DISK_EJECT_REQUEST}=="?*", RUN+="cdrom_id --eject-media $devnode", GOTO="cdrom_end" + +# import device and media properties and lock tray to +# enable the receiving of media eject button events +IMPORT{program}="cdrom_id --lock-media $devnode" + +# ejecting a CD does not remove the device node, so mark the systemd device +# unit as inactive while there is no medium; this automatically cleans up of +# stale mounts after ejecting +ENV{DISK_MEDIA_CHANGE}=="?*", ENV{ID_CDROM_MEDIA}!="?*", ENV{SYSTEMD_READY}="0" + +KERNEL=="sr0", SYMLINK+="cdrom", OPTIONS+="link_priority=-100" + +LABEL="cdrom_end" diff --git a/misc/rules/60-dmi-id.rules b/misc/rules/60-dmi-id.rules new file mode 100644 index 0000000..ecea74e --- /dev/null +++ b/misc/rules/60-dmi-id.rules @@ -0,0 +1,29 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="dmi_end" +SUBSYSTEM!="dmi", GOTO="dmi_end" +KERNEL!="id", GOTO="dmi_end" + +ENV{ID_SYS_VENDOR_IS_RUBBISH}!="1", ENV{ID_VENDOR}="$attr{sys_vendor}" +ENV{ID_SYSFS_ATTRIBUTE_MODEL}=="", ENV{ID_PRODUCT_NAME_IS_RUBBISH}!="1", ENV{ID_MODEL}="$attr{product_name}" +ENV{ID_SYSFS_ATTRIBUTE_MODEL}=="product_name", ENV{ID_MODEL}="$attr{product_name}" +ENV{ID_SYSFS_ATTRIBUTE_MODEL}=="product_version", ENV{ID_MODEL}="$attr{product_version}" +# Fallback to board information +ENV{ID_VENDOR}=="", ENV{ID_VENDOR}="$attr{board_vendor}" +ENV{ID_MODEL}=="", ENV{ID_MODEL}="$attr{board_name}" + +# Stock keeping unit +ENV{ID_PRODUCT_SKU_IS_RUBBISH}!="1", ENV{ID_SKU}="$attr{product_sku}" + +# Hardware version +ENV{ID_PRODUCT_VERSION_IS_RUBBISH}!="1", ENV{ID_HARDWARE_VERSION}="$attr{product_version}" +ENV{ID_HARDWARE_VERSION}=="", ENV{ID_BOARD_VERSION_IS_RUBBISH}!="1", ENV{ID_HARDWARE_VERSION}="$attr{board_version}" + +# Chassis asset tag +ENV{MODALIAS}!="", ATTR{chassis_asset_tag}!="", IMPORT{builtin}="hwdb '$attr{modalias}cat$attr{chassis_asset_tag}:'" +ENV{ID_CHASSIS_ASSET_TAG_IS_RUBBISH}!="1", ENV{ID_CHASSIS_ASSET_TAG}="$attr{chassis_asset_tag}" + +# Allow units to be ordered after the DMI device +TAG+="systemd" + +LABEL="dmi_end" diff --git a/misc/rules/60-drm.rules b/misc/rules/60-drm.rules new file mode 100644 index 0000000..061b2a2 --- /dev/null +++ b/misc/rules/60-drm.rules @@ -0,0 +1,11 @@ +# do not edit this file, it will be overwritten on update + +ACTION!="remove", SUBSYSTEM=="drm", SUBSYSTEMS=="pci|usb|platform", IMPORT{builtin}="path_id" + +# by-path +KERNEL=="card*", ENV{ID_PATH}=="?*", SYMLINK+="dri/by-path/$env{ID_PATH}-card" +KERNEL=="card*", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="dri/by-path/$env{ID_PATH_WITH_USB_REVISION}-card" +KERNEL=="controlD*", ENV{ID_PATH}=="?*", SYMLINK+="dri/by-path/$env{ID_PATH}-control" +KERNEL=="controlD*", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="dri/by-path/$env{ID_PATH_WITH_USB_REVISION}-control" +KERNEL=="renderD*", ENV{ID_PATH}=="?*", SYMLINK+="dri/by-path/$env{ID_PATH}-render" +KERNEL=="renderD*", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="dri/by-path/$env{ID_PATH_WITH_USB_REVISION}-render" diff --git a/misc/rules/60-evdev.rules b/misc/rules/60-evdev.rules new file mode 100644 index 0000000..c97cdec --- /dev/null +++ b/misc/rules/60-evdev.rules @@ -0,0 +1,30 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="evdev_end" +KERNEL!="event*", GOTO="evdev_end" + +# Execute the match patterns below, from least-to-most specific. + +# Device matching the modalias string (bustype, vendor, product, version, other properties) +IMPORT{builtin}="hwdb --subsystem=input --lookup-prefix=evdev:", \ + ENV{.HAVE_HWDB_PROPERTIES}="1" + +# AT keyboard matching by the machine's DMI data +DRIVERS=="atkbd", \ + IMPORT{builtin}="hwdb 'evdev:atkbd:$attr{[dmi/id]modalias}'", \ + ENV{.HAVE_HWDB_PROPERTIES}="1" + +# Device matching the input device name and the machine's DMI data +KERNELS=="input*", \ + IMPORT{builtin}="hwdb 'evdev:name:$attr{name}:$attr{[dmi/id]modalias}'", \ + ENV{.HAVE_HWDB_PROPERTIES}="1" + +# Device matching the input device name + properties + the machine's DMI data +KERNELS=="input*", \ + IMPORT{builtin}="hwdb 'evdev:name:$attr{name}:phys:$attr{phys}:ev:$attr{capabilities/ev}:$attr{[dmi/id]modalias}'", \ + ENV{.HAVE_HWDB_PROPERTIES}="1" + +ENV{.HAVE_HWDB_PROPERTIES}=="1", \ + IMPORT{builtin}="keyboard" + +LABEL="evdev_end" diff --git a/misc/rules/60-fido-id.rules b/misc/rules/60-fido-id.rules new file mode 100644 index 0000000..48c259e --- /dev/null +++ b/misc/rules/60-fido-id.rules @@ -0,0 +1,14 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="fido_id_end" + +SUBSYSTEM=="hidraw", IMPORT{program}="fido_id" + +# Tag any form of security token as such +ENV{ID_SECURITY_TOKEN}=="1", TAG+="security-device" + +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ENV{ID_USB_INTERFACES}=="*:0b????:*", ENV{ID_SMARTCARD_READER}="1" +# Tag any CCID device (i.e. Smartcard Reader) as security token +ENV{ID_SMARTCARD_READER}=="1", TAG+="security-device" + +LABEL="fido_id_end" diff --git a/misc/rules/60-gpiochip.rules b/misc/rules/60-gpiochip.rules new file mode 100644 index 0000000..76c57b4 --- /dev/null +++ b/misc/rules/60-gpiochip.rules @@ -0,0 +1,17 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="gpiochip_end" +SUBSYSTEM!="gpio", GOTO="gpiochip_end" + +KERNEL!="gpiochip[0-9]*", GOTO="gpiochip_end" + +IMPORT{builtin}="path_id" +ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="gpio/by-path/$env{ID_PATH_WITH_USB_REVISION}" + +SUBSYSTEMS=="usb", IMPORT{builtin}="usb_id" +ENV{ID_BUS}=="", GOTO="gpiochip_end" +ENV{ID_SERIAL}=="", GOTO="gpiochip_end" +ENV{ID_USB_INTERFACE_NUM}=="", GOTO="gpiochip_end" +SYMLINK+="gpio/by-id/$env{ID_BUS}-$env{ID_SERIAL}-if$env{ID_USB_INTERFACE_NUM}" + +LABEL="gpiochip_end" diff --git a/misc/rules/60-infiniband.rules b/misc/rules/60-infiniband.rules new file mode 100644 index 0000000..da3eea6 --- /dev/null +++ b/misc/rules/60-infiniband.rules @@ -0,0 +1,12 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="infiniband_end" +SUBSYSTEM!="infiniband_verbs", GOTO="infiniband_end" +KERNEL!="uverbs*", GOTO="infiniband_end" + +IMPORT{builtin}="path_id" + +ENV{ID_PATH}=="?*", SYMLINK+="infiniband/by-path/$env{ID_PATH}" +ATTR{ibdev}=="?*", SYMLINK+="infiniband/by-ibdev/uverbs-$attr{ibdev}" + +LABEL="infiniband_end" diff --git a/misc/rules/60-input-id.rules b/misc/rules/60-input-id.rules new file mode 100644 index 0000000..c2bdbfd --- /dev/null +++ b/misc/rules/60-input-id.rules @@ -0,0 +1,19 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="id_input_end" + +SUBSYSTEM=="input", ENV{ID_INPUT}=="", IMPORT{builtin}="input_id" +SUBSYSTEM=="input", IMPORT{builtin}="hwdb --subsystem=input --lookup-prefix=id-input:modalias:" + +# id-input::vp:name::* +KERNELS=="input*", ATTRS{id/bustype}=="0003", \ + IMPORT{builtin}="hwdb 'id-input:usb:v$attr{id/vendor}p$attr{id/product}:name:$attr{name}:'", \ + GOTO="id_input_end" +KERNELS=="input*", ATTRS{id/bustype}=="0005", \ + IMPORT{builtin}="hwdb 'id-input:bluetooth:v$attr{id/vendor}p$attr{id/product}:name:$attr{name}:'", \ + GOTO="id_input_end" +KERNELS=="input*", ATTRS{id/bustype}=="0018", \ + IMPORT{builtin}="hwdb 'id-input:i2c:v$attr{id/vendor}p$attr{id/product}:name:$attr{name}:'", \ + GOTO="id_input_end" + +LABEL="id_input_end" diff --git a/misc/rules/60-persistent-alsa.rules b/misc/rules/60-persistent-alsa.rules new file mode 100644 index 0000000..466ab1c --- /dev/null +++ b/misc/rules/60-persistent-alsa.rules @@ -0,0 +1,15 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="persistent_alsa_end" +SUBSYSTEM!="sound", GOTO="persistent_alsa_end" +KERNEL!="controlC[0-9]*", GOTO="persistent_alsa_end" + +SUBSYSTEMS=="usb", ENV{ID_MODEL}=="", IMPORT{builtin}="usb_id" +ENV{ID_SERIAL}=="?*", ENV{ID_USB_INTERFACE_NUM}=="?*", SYMLINK+="snd/by-id/$env{ID_BUS}-$env{ID_SERIAL}-$env{ID_USB_INTERFACE_NUM}" +ENV{ID_SERIAL}=="?*", ENV{ID_USB_INTERFACE_NUM}=="", SYMLINK+="snd/by-id/$env{ID_BUS}-$env{ID_SERIAL}" + +IMPORT{builtin}="path_id" +ENV{ID_PATH}=="?*", SYMLINK+="snd/by-path/$env{ID_PATH}" +ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="snd/by-path/$env{ID_PATH_WITH_USB_REVISION}" + +LABEL="persistent_alsa_end" diff --git a/misc/rules/60-persistent-hidraw.rules b/misc/rules/60-persistent-hidraw.rules new file mode 100644 index 0000000..c22db7d --- /dev/null +++ b/misc/rules/60-persistent-hidraw.rules @@ -0,0 +1,26 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="persistent_hidraw_end" +SUBSYSTEM!="hidraw", GOTO="persistent_hidraw_end" +KERNEL!="hidraw*", GOTO="persistent_hidraw_end" + +SUBSYSTEMS=="usb", ENV{ID_BUS}=="", IMPORT{builtin}="usb_id" +SUBSYSTEMS=="usb", IMPORT{builtin}="path_id" + +ENV{ID_BUS}=="", GOTO="persistent_hidraw_bus_end" + +# by-id links +ATTRS{bInterfaceNumber}=="|00", SYMLINK+="input/by-id/$env{ID_BUS}-$env{ID_SERIAL}-hidraw" +ATTRS{bInterfaceNumber}=="?*", ATTRS{bInterfaceNumber}!="00", SYMLINK+="input/by-id/$env{ID_BUS}-$env{ID_SERIAL}-if$attr{bInterfaceNumber}-hidraw" + +# add a more readable 'fido' link for devices with ID_FIDO_TOKEN==1 +ENV{ID_FIDO_TOKEN}=="?*", ATTRS{bInterfaceNumber}=="|00", SYMLINK+="input/by-id/$env{ID_BUS}-$env{ID_SERIAL}-fido" +ENV{ID_FIDO_TOKEN}=="?*", ATTRS{bInterfaceNumber}=="?*", ATTRS{bInterfaceNumber}!="00", SYMLINK+="input/by-id/$env{ID_BUS}-$env{ID_SERIAL}-if$attr{bInterfaceNumber}-fido" + +LABEL="persistent_hidraw_bus_end" + +# by-path +ENV{ID_PATH}=="?*", SYMLINK+="input/by-path/$env{ID_PATH}-hidraw" +ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="input/by-path/$env{ID_PATH_WITH_USB_REVISION}-hidraw" + +LABEL="persistent_hidraw_end" diff --git a/misc/rules/60-persistent-input.rules b/misc/rules/60-persistent-input.rules new file mode 100644 index 0000000..7ac43a8 --- /dev/null +++ b/misc/rules/60-persistent-input.rules @@ -0,0 +1,55 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="persistent_input_end" +SUBSYSTEM!="input", GOTO="persistent_input_end" +SUBSYSTEMS=="bluetooth", ENV{ID_BUS}="bluetooth", GOTO="persistent_input_end" +# Bluetooth devices don't always have the bluetooth subsystem +ATTRS{id/bustype}=="0005", ENV{ID_BUS}="bluetooth", GOTO="persistent_input_end" +SUBSYSTEMS=="acpi", ENV{ID_BUS}="acpi" +# platform must be before serio as serio can be child +SUBSYSTEMS=="platform", ENV{ID_BUS}="platform" +SUBSYSTEMS=="i2c", ENV{ID_BUS}="i2c" +SUBSYSTEMS=="rmi4", ENV{ID_BUS}="rmi" +SUBSYSTEMS=="serio", ENV{ID_BUS}="i8042" +SUBSYSTEMS=="spi", ENV{ID_BUS}="spi" + +SUBSYSTEMS=="usb", ENV{ID_BUS}=="", IMPORT{builtin}="usb_id" + +# subsystems before (usb, platform, i2c) can be under pci so only set them if we still have no ID_BUS. +# we could set this the first but will break the ENV{ID_BUS}=="" condition for usb. +SUBSYSTEMS=="pci", ENV{ID_BUS}=="", ENV{ID_BUS}="pci" + +# determine class name for persistent symlinks +ENV{ID_INPUT_KEYBOARD}=="?*", ENV{.INPUT_CLASS}="kbd" +ENV{ID_INPUT_MOUSE}=="?*", ENV{.INPUT_CLASS}="mouse" +ENV{ID_INPUT_TOUCHPAD}=="?*", ENV{.INPUT_CLASS}="mouse" +ENV{ID_INPUT_TABLET}=="?*", ENV{.INPUT_CLASS}="mouse" +ENV{ID_INPUT_JOYSTICK}=="?*", ENV{.INPUT_CLASS}="joystick" +DRIVERS=="pcspkr", ENV{.INPUT_CLASS}="spkr" +ATTRS{name}=="*dvb*|*DVB*|* IR *", ENV{.INPUT_CLASS}="ir" + +# fill empty serial number +ENV{.INPUT_CLASS}=="?*", ENV{ID_SERIAL}=="", ENV{ID_SERIAL}="noserial" + +# by-id links +KERNEL=="mouse*|js*", ENV{ID_BUS}=="?*", ENV{.INPUT_CLASS}=="?*", ATTRS{bInterfaceNumber}=="|00", SYMLINK+="input/by-id/$env{ID_BUS}-$env{ID_SERIAL}-$env{.INPUT_CLASS}" +KERNEL=="mouse*|js*", ENV{ID_BUS}=="?*", ENV{.INPUT_CLASS}=="?*", ATTRS{bInterfaceNumber}=="?*", ATTRS{bInterfaceNumber}!="00", SYMLINK+="input/by-id/$env{ID_BUS}-$env{ID_SERIAL}-if$attr{bInterfaceNumber}-$env{.INPUT_CLASS}" +KERNEL=="event*", ENV{ID_BUS}=="?*", ENV{.INPUT_CLASS}=="?*", ATTRS{bInterfaceNumber}=="|00", SYMLINK+="input/by-id/$env{ID_BUS}-$env{ID_SERIAL}-event-$env{.INPUT_CLASS}" +KERNEL=="event*", ENV{ID_BUS}=="?*", ENV{.INPUT_CLASS}=="?*", ATTRS{bInterfaceNumber}=="?*", ATTRS{bInterfaceNumber}!="00", SYMLINK+="input/by-id/$env{ID_BUS}-$env{ID_SERIAL}-if$attr{bInterfaceNumber}-event-$env{.INPUT_CLASS}" +# allow empty class for USB devices, by appending the interface number +SUBSYSTEMS=="usb", ENV{ID_BUS}=="?*", KERNEL=="event*", ENV{.INPUT_CLASS}=="", ATTRS{bInterfaceNumber}=="?*", \ + SYMLINK+="input/by-id/$env{ID_BUS}-$env{ID_SERIAL}-event-if$attr{bInterfaceNumber}" + +# by-path +SUBSYSTEMS=="pci|usb|platform|acpi", IMPORT{builtin}="path_id" +ENV{.INPUT_CLASS}=="?*", KERNEL=="mouse*|js*", ENV{ID_PATH}=="?*", SYMLINK+="input/by-path/$env{ID_PATH}-$env{.INPUT_CLASS}" +ENV{.INPUT_CLASS}=="?*", KERNEL=="mouse*|js*", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="input/by-path/$env{ID_PATH_WITH_USB_REVISION}-$env{.INPUT_CLASS}" +ENV{.INPUT_CLASS}=="?*", KERNEL=="event*", ENV{ID_PATH}=="?*", SYMLINK+="input/by-path/$env{ID_PATH}-event-$env{.INPUT_CLASS}" +ENV{.INPUT_CLASS}=="?*", KERNEL=="event*", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="input/by-path/$env{ID_PATH_WITH_USB_REVISION}-event-$env{.INPUT_CLASS}" +# allow empty class for platform, usb and i2c devices; platform supports only a single interface that way +SUBSYSTEMS=="usb|platform|i2c", KERNEL=="event*", ENV{.INPUT_CLASS}=="", ENV{ID_PATH}=="?*", \ + SYMLINK+="input/by-path/$env{ID_PATH}-event" +SUBSYSTEMS=="usb|platform|i2c", KERNEL=="event*", ENV{.INPUT_CLASS}=="", ENV{ID_PATH_WITH_USB_REVISION}=="?*", \ + SYMLINK+="input/by-path/$env{ID_PATH_WITH_USB_REVISION}-event" + +LABEL="persistent_input_end" diff --git a/misc/rules/60-persistent-media-controller.rules b/misc/rules/60-persistent-media-controller.rules new file mode 100644 index 0000000..8c2175c --- /dev/null +++ b/misc/rules/60-persistent-media-controller.rules @@ -0,0 +1,13 @@ +# do not edit this file, it will be overwritten on update + +# Media controller rules + +ACTION=="remove", GOTO="persistent_media_ctl_end" +SUBSYSTEM!="media", GOTO="persistent_media_ctl_end" +ENV{MAJOR}=="", GOTO="persistent_media_ctl_end" + +IMPORT{builtin}="path_id" +KERNEL=="media*", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="media/by-path/$env{ID_PATH_WITH_USB_REVISION}-media-controller" +KERNEL=="media*", ENV{ID_PATH_WITH_USB_REVISION}=="", ENV{ID_PATH}=="?*", SYMLINK+="media/by-path/$env{ID_PATH}-media-controller" + +LABEL="persistent_media_ctl_end" diff --git a/misc/rules/60-persistent-storage-mtd.rules b/misc/rules/60-persistent-storage-mtd.rules new file mode 100644 index 0000000..bcf93b9 --- /dev/null +++ b/misc/rules/60-persistent-storage-mtd.rules @@ -0,0 +1,12 @@ +# do not edit this file, it will be overwritten on update + +# persistent storage links: /dev/mtd/by-name + +ACTION=="remove", GOTO="persistent_storage_mtd_end" +SUBSYSTEM!="mtd", GOTO="persistent_storage_mtd_end" +KERNEL!="mtd[0-9]*", GOTO="persistent_storage_mtd_end" +KERNEL=="mtd[0-9]*ro", GOTO="persistent_storage_mtd_end" + +ATTR{name}=="?*", SYMLINK+="mtd/by-name/$attr{name}" + +LABEL="persistent_storage_mtd_end" diff --git a/misc/rules/60-persistent-storage-tape.rules b/misc/rules/60-persistent-storage-tape.rules new file mode 100644 index 0000000..0678d71 --- /dev/null +++ b/misc/rules/60-persistent-storage-tape.rules @@ -0,0 +1,45 @@ +# do not edit this file, it will be overwritten on update + +# persistent storage links: /dev/tape/{by-id,by-path} + +ACTION=="remove", GOTO="persistent_storage_tape_end" +ENV{UDEV_DISABLE_PERSISTENT_STORAGE_RULES_FLAG}=="1", GOTO="persistent_storage_tape_end" + +# type 8 devices are "Medium Changers" +SUBSYSTEM=="scsi_generic", SUBSYSTEMS=="scsi", ATTRS{type}=="8", GOTO="medium_changer_begin" +GOTO="medium_changer_end" + +LABEL="medium_changer_begin" + +IMPORT{program}="scsi_id --sg-version=3 --export --allowlisted -d $devnode" +ENV{ID_SERIAL}=="?*", SYMLINK+="tape/by-id/scsi-$env{ID_SERIAL} tape/by-id/scsi-$env{ID_SERIAL}-changer" + +# iSCSI devices from the same host have all the same ID_SERIAL, +# but additionally a property named ID_SCSI_SERIAL. +ENV{ID_SCSI_SERIAL}=="?*", SYMLINK+="tape/by-id/scsi-$env{ID_SCSI_SERIAL}" + +IMPORT{builtin}="path_id" +ENV{ID_PATH}=="?*", SYMLINK+="tape/by-path/$env{ID_PATH}-changer" +ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="tape/by-path/$env{ID_PATH_WITH_USB_REVISION}-changer" + +LABEL="medium_changer_end" + +SUBSYSTEM!="scsi_tape", GOTO="persistent_storage_tape_end" + +KERNEL=="st*[0-9]|nst*[0-9]", ATTRS{ieee1394_id}=="?*", ENV{ID_SERIAL}="$attr{ieee1394_id}", ENV{ID_BUS}="ieee1394" +KERNEL=="st*[0-9]|nst*[0-9]", ENV{ID_SERIAL}!="?*", SUBSYSTEMS=="usb", IMPORT{builtin}="usb_id" +KERNEL=="st*[0-9]|nst*[0-9]", ENV{ID_SERIAL}!="?*", SUBSYSTEMS=="scsi", KERNELS=="[0-9]*:*[0-9]", ENV{.BSG_DEV}="$root/bsg/$id" +KERNEL=="st*[0-9]|nst*[0-9]", ENV{ID_SERIAL}!="?*", IMPORT{program}="scsi_id --allowlisted --export --device=$env{.BSG_DEV}", ENV{ID_BUS}="scsi" +KERNEL=="st*[0-9]", ENV{ID_SERIAL}=="?*", SYMLINK+="tape/by-id/$env{ID_BUS}-$env{ID_SERIAL}", OPTIONS+="link_priority=10" +KERNEL=="st*[0-9]", ENV{ID_SCSI_SERIAL}=="?*", SYMLINK+="tape/by-id/$env{ID_BUS}-$env{ID_SCSI_SERIAL}" +KERNEL=="nst*[0-9]", ENV{ID_SERIAL}=="?*", SYMLINK+="tape/by-id/$env{ID_BUS}-$env{ID_SERIAL}-nst" +KERNEL=="nst*[0-9]", ENV{ID_SCSI_SERIAL}=="?*", SYMLINK+="tape/by-id/$env{ID_BUS}-$env{ID_SCSI_SERIAL}-nst" + +# by-path (parent device path) +KERNEL=="st*[0-9]|nst*[0-9]", IMPORT{builtin}="path_id" +KERNEL=="st*[0-9]", ENV{ID_PATH}=="?*", SYMLINK+="tape/by-path/$env{ID_PATH}" +KERNEL=="st*[0-9]", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="tape/by-path/$env{ID_PATH_WITH_USB_REVISION}" +KERNEL=="nst*[0-9]", ENV{ID_PATH}=="?*", SYMLINK+="tape/by-path/$env{ID_PATH}-nst" +KERNEL=="nst*[0-9]", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="tape/by-path/$env{ID_PATH_WITH_USB_REVISION}-nst" + +LABEL="persistent_storage_tape_end" diff --git a/misc/rules/60-persistent-storage.rules b/misc/rules/60-persistent-storage.rules new file mode 100644 index 0000000..e07f7b5 --- /dev/null +++ b/misc/rules/60-persistent-storage.rules @@ -0,0 +1,177 @@ +# do not edit this file, it will be overwritten on update + +# persistent storage links: /dev/disk/{by-id,by-uuid,by-label,by-path} +# scheme based on "Linux persistent device names", 2004, Hannes Reinecke + +ACTION=="remove", GOTO="persistent_storage_end" +ENV{UDEV_DISABLE_PERSISTENT_STORAGE_RULES_FLAG}=="1", GOTO="persistent_storage_end" + +SUBSYSTEM!="block|ubi", GOTO="persistent_storage_end" +KERNEL!="loop*|mmcblk*[0-9]|msblk*[0-9]|mspblk*[0-9]|nvme*|sd*|sr*|vd*|xvd*|bcache*|cciss*|dasd*|ubd*|ubi*|scm*|pmem*|nbd*|zd*|rbd*|zram*|ublkb*", GOTO="persistent_storage_end" + +# ignore partitions that span the entire disk +TEST=="whole_disk", GOTO="persistent_storage_end" + +# For partitions import parent disk ID_* information, except ID_FS_*. +# +# This is particularly important on media where a filesystem superblock and +# partition table are found on the same level, e.g. common Linux distro ISO +# installation media. +# +# In the case where a partition device points to the same filesystem that +# was detected on the parent disk, the ID_FS_* information is already +# present on the partition devices as well as the parent, so no need to +# propagate it. In the case where the partition device points to a different +# filesystem, merging the parent ID_FS_ properties would lead to +# inconsistencies, so we avoid doing so. +ENV{DEVTYPE}=="partition", \ + IMPORT{parent}="ID_[!F]*", IMPORT{parent}="ID_", \ + IMPORT{parent}="ID_F[!S]*", IMPORT{parent}="ID_F", \ + IMPORT{parent}="ID_FS[!_]*", IMPORT{parent}="ID_FS" + +ENV{DEVTYPE}=="partition", ENV{.PART_SUFFIX}="-part%n" +ENV{DEVTYPE}!="partition", ENV{.PART_SUFFIX}="" + +# NVMe +KERNEL!="nvme*[0-9]n*[0-9]|nvme*[0-9]n*[0-9]p*[0-9]", GOTO="nvme_end" + +ATTRS{serial}=="?*", ENV{ID_SERIAL_SHORT}="$attr{serial}" +ATTRS{wwid}=="?*", ENV{ID_WWN}="$attr{wwid}" +ATTRS{model}=="?*", ENV{ID_MODEL}="$attr{model}" +ATTRS{firmware_rev}=="?*", ENV{ID_REVISION}="$attr{firmware_rev}" +ATTRS{nsid}=="?*", ENV{ID_NSID}="$attr{nsid}" + +ENV{ID_WWN}=="?*", SYMLINK+="disk/by-id/nvme-$env{ID_WWN}$env{.PART_SUFFIX}" + +# obsolete symlink with non-escaped characters, kept for backward compatibility +ENV{ID_MODEL}=="?*", ENV{ID_SERIAL_SHORT}=="?*", ENV{ID_MODEL}!="*/*", ENV{ID_SERIAL_SHORT}!="*/*", \ + ENV{ID_SERIAL}="$env{ID_MODEL}_$env{ID_SERIAL_SHORT}", ENV{ID_NSID}=="1", SYMLINK+="disk/by-id/nvme-$env{ID_SERIAL}$env{.PART_SUFFIX}" +# obsolete symlink that might get overridden on adding a new nvme controller, kept for backward compatibility +ENV{ID_MODEL}=="?*", ENV{ID_SERIAL_SHORT}=="?*", ENV{ID_NSID}=="1", OPTIONS="string_escape=replace", \ + ENV{ID_SERIAL}="$env{ID_MODEL}_$env{ID_SERIAL_SHORT}", SYMLINK+="disk/by-id/nvme-$env{ID_SERIAL}$env{.PART_SUFFIX}" +ENV{ID_MODEL}=="?*", ENV{ID_SERIAL_SHORT}=="?*", ENV{ID_NSID}=="?*", OPTIONS="string_escape=replace", \ + ENV{ID_SERIAL}="$env{ID_MODEL}_$env{ID_SERIAL_SHORT}_$env{ID_NSID}", SYMLINK+="disk/by-id/nvme-$env{ID_SERIAL}$env{.PART_SUFFIX}" + +LABEL="nvme_end" + +# virtio-blk +KERNEL=="vd*", ATTRS{serial}=="?*", ENV{ID_SERIAL}="$attr{serial}", SYMLINK+="disk/by-id/virtio-$env{ID_SERIAL}$env{.PART_SUFFIX}" + +# ATA +KERNEL=="sd*[!0-9]|sr*", ENV{ID_SERIAL}!="?*", SUBSYSTEMS=="scsi", ATTRS{vendor}=="ATA", IMPORT{program}="ata_id --export $devnode" +KERNEL=="sd*[!0-9]|sr*", ENV{ID_BUS}=="ata", ENV{ID_ATA_PERIPHERAL_DEVICE_TYPE}=="20", PROGRAM="scsi_id -u -g $devnode", \ + SYMLINK+="disk/by-id/scsi-$result$env{.PART_SUFFIX}" + +# ATAPI devices (SPC-3 or later) +KERNEL=="sd*[!0-9]|sr*", ENV{ID_SERIAL}!="?*", SUBSYSTEMS=="scsi", ATTRS{type}=="5", ATTRS{scsi_level}=="[6-9]*", IMPORT{program}="ata_id --export $devnode" + +# Run ata_id on non-removable USB Mass Storage (SATA/PATA disks in enclosures) +KERNEL=="sd*[!0-9]|sr*", ENV{ID_SERIAL}!="?*", ATTR{removable}=="0", SUBSYSTEMS=="usb", IMPORT{program}="ata_id --export $devnode" + +# Also import properties from usb_id for USB devices +KERNEL=="sd*[!0-9]|sr*", SUBSYSTEMS=="usb", IMPORT{builtin}="usb_id" + +# SCSI devices +KERNEL=="sd*[!0-9]|sr*", ENV{ID_SERIAL}!="?*", IMPORT{program}="scsi_id --export --allowlisted -d $devnode", ENV{ID_BUS}="scsi" +KERNEL=="cciss*", ENV{DEVTYPE}=="disk", ENV{ID_SERIAL}!="?*", IMPORT{program}="scsi_id --export --allowlisted -d $devnode", ENV{ID_BUS}="cciss" + +KERNEL=="sd*|sr*|cciss*", ENV{ID_SERIAL}=="?*", SYMLINK+="disk/by-id/$env{ID_BUS}-$env{ID_SERIAL}$env{.PART_SUFFIX}" +# Previously, ata_id in the above might not be able to retrieve attributes correctly, +# and properties from usb_id were used as a fallback. See issue #24921 and PR #24923. +# To keep backward compatibility, still we need to create symlinks based on USB serial. +# See issue #25179. +KERNEL=="sd*|sr*|cciss*", ENV{ID_USB_SERIAL}=="?*", SYMLINK+="disk/by-id/usb-$env{ID_USB_SERIAL}$env{.PART_SUFFIX}" + +# PMEM devices +KERNEL=="pmem*", ATTRS{uuid}=="?*", SYMLINK+="disk/by-id/pmem-$attr{uuid}$env{.PART_SUFFIX}" + +# FireWire +KERNEL=="sd*|sr*", ATTRS{ieee1394_id}=="?*", SYMLINK+="disk/by-id/ieee1394-$attr{ieee1394_id}$env{.PART_SUFFIX}" + +# MMC +KERNEL=="mmcblk[0-9]|mmcblk[0-9]p[0-9]*", SUBSYSTEMS=="mmc", GOTO="mmc_start" +GOTO="mmc_end" +LABEL="mmc_start" +ATTRS{name}=="?*", ENV{ID_NAME}="$attr{name}" +ATTRS{serial}=="?*", ENV{ID_SERIAL}="$attr{serial}" +ENV{ID_NAME}=="?*", ENV{ID_SERIAL}=="?*", SYMLINK+="disk/by-id/mmc-$env{ID_NAME}_$env{ID_SERIAL}$env{.PART_SUFFIX}" +LABEL="mmc_end" + +# Memstick +KERNEL=="msblk[0-9]|mspblk[0-9]|msblk[0-9]p[0-9]|mspblk[0-9]p[0-9]", SUBSYSTEMS=="memstick", GOTO="memstick_start" +GOTO="memstick_end" +LABEL="memstick_start" +ATTRS{name}=="?*", ENV{ID_NAME}="$attr{name}" +ATTRS{serial}=="?*", ENV{ID_SERIAL}="$attr{serial}" +ENV{ID_NAME}=="?*", ENV{ID_SERIAL}=="?*", SYMLINK+="disk/by-id/memstick-$env{ID_NAME}_$env{ID_SERIAL}$env{.PART_SUFFIX}" +LABEL="memstick_end" + +# by-path +ENV{DEVTYPE}=="disk", DEVPATH!="*/virtual/*", IMPORT{builtin}="path_id" +ENV{DEVTYPE}=="disk", SUBSYSTEMS=="nvme-subsystem", IMPORT{builtin}="path_id" +KERNEL=="mmcblk[0-9]boot[0-9]", ENV{DEVTYPE}=="disk", ENV{ID_PATH}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH}-boot%n" +KERNEL=="mmcblk[0-9]boot[0-9]", ENV{DEVTYPE}=="disk", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH_WITH_USB_REVISION}-boot%n" +KERNEL!="mmcblk[0-9]boot[0-9]", ENV{ID_PATH}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH}$env{.PART_SUFFIX}" +KERNEL!="mmcblk[0-9]boot[0-9]", ENV{ID_PATH_ATA_COMPAT}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH_ATA_COMPAT}$env{.PART_SUFFIX}" +KERNEL!="mmcblk[0-9]boot[0-9]", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH_WITH_USB_REVISION}$env{.PART_SUFFIX}" + +# legacy virtio-pci by-path links (deprecated) +KERNEL=="vd*", ENV{ID_PATH}=="pci-*", SYMLINK+="disk/by-path/virtio-$env{ID_PATH}$env{.PART_SUFFIX}" +KERNEL=="vd*", ENV{ID_PATH_WITH_USB_REVISION}=="pci-*", SYMLINK+="disk/by-path/virtio-$env{ID_PATH_WITH_USB_REVISION}$env{.PART_SUFFIX}" + +{% if HAVE_BLKID %} +# allow admin to disable probing the filesystem for slow devices like floppy disk drives +ENV{UDEV_DISABLE_PERSISTENT_STORAGE_BLKID_FLAG}=="1", GOTO="persistent_storage_blkid_probe_end" + +# probe filesystem metadata of optical drives which have a media inserted +KERNEL=="sr*", ENV{DISK_EJECT_REQUEST}!="?*", ENV{ID_CDROM_MEDIA_TRACK_COUNT_DATA}=="?*", ENV{ID_CDROM_MEDIA_SESSION_LAST_OFFSET}=="?*", \ + IMPORT{builtin}="blkid --hint=session_offset=$env{ID_CDROM_MEDIA_SESSION_LAST_OFFSET}" +# single-session CDs do not have ID_CDROM_MEDIA_SESSION_LAST_OFFSET +KERNEL=="sr*", ENV{DISK_EJECT_REQUEST}!="?*", ENV{ID_CDROM_MEDIA_TRACK_COUNT_DATA}=="?*", ENV{ID_CDROM_MEDIA_SESSION_LAST_OFFSET}=="", \ + IMPORT{builtin}="blkid --noraid" + +# probe filesystem metadata of disks +KERNEL!="sr*|mmcblk[0-9]boot[0-9]", IMPORT{builtin}="blkid" + +LABEL="persistent_storage_blkid_probe_end" +{% endif %} + +# by-label/by-uuid links (filesystem metadata) +ENV{ID_FS_USAGE}=="filesystem|other|crypto", ENV{ID_FS_UUID_ENC}=="?*", SYMLINK+="disk/by-uuid/$env{ID_FS_UUID_ENC}" +ENV{ID_FS_USAGE}=="filesystem|other|crypto", ENV{ID_FS_LABEL_ENC}=="?*", SYMLINK+="disk/by-label/$env{ID_FS_LABEL_ENC}" + +# by-id (World Wide Name) +ENV{ID_WWN_WITH_EXTENSION}=="?*", SYMLINK+="disk/by-id/wwn-$env{ID_WWN_WITH_EXTENSION}$env{.PART_SUFFIX}" + +# by-partlabel/by-partuuid links (partition metadata) +ENV{ID_PART_ENTRY_UUID}=="?*", SYMLINK+="disk/by-partuuid/$env{ID_PART_ENTRY_UUID}" +ENV{ID_PART_ENTRY_SCHEME}=="gpt", ENV{ID_PART_ENTRY_NAME}=="?*", SYMLINK+="disk/by-partlabel/$env{ID_PART_ENTRY_NAME}" + +# by-path//by-* links (path + partition/filesystem metadata) +ENV{ID_PATH}=="", GOTO="persistent_storage_by-path_parts_end" +ENV{DEVTYPE}!="partition", GOTO="persistent_storage_by-path_parts_end" + +SYMLINK+="disk/by-path/$env{ID_PATH}-part/by-partnum/%n" +ENV{ID_PART_ENTRY_UUID}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH}-part/by-partuuid/$env{ID_PART_ENTRY_UUID}" +ENV{ID_PART_ENTRY_SCHEME}=="gpt", ENV{ID_PART_ENTRY_NAME}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH}-part/by-partlabel/$env{ID_PART_ENTRY_NAME}" + +ENV{ID_FS_USAGE}=="filesystem|other|crypto", ENV{ID_FS_UUID_ENC}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH}-part/by-uuid/$env{ID_FS_UUID_ENC}" +ENV{ID_FS_USAGE}=="filesystem|other|crypto", ENV{ID_FS_LABEL_ENC}=="?*", SYMLINK+="disk/by-path/$env{ID_PATH}-part/by-label/$env{ID_FS_LABEL_ENC}" + +LABEL="persistent_storage_by-path_parts_end" + +# by-diskseq link (if an app is told to open a path like this, they may parse +# the diskseq number from the path, then issue BLKGETDISKSEQ to verify they really got +# the right device, to access specific disks in a race-free fashion) +ENV{DISKSEQ}=="?*", ENV{ID_IGNORE_DISKSEQ}!="1", SYMLINK+="disk/by-diskseq/$env{DISKSEQ}$env{.PART_SUFFIX}" + +# Create symlinks that allow referencing loopback devices by their backing file's inode number +ENV{ID_LOOP_BACKING_DEVICE}!="", ENV{ID_LOOP_BACKING_INODE}!="", SYMLINK+="disk/by-loop-inode/$env{ID_LOOP_BACKING_DEVICE}-$env{ID_LOOP_BACKING_INODE}$env{.PART_SUFFIX}" + +# Similar, but uses the .lo_file_name field of the loopback device (note that +# this is basically just a free-form string passed from userspace to the kernel +# when the device is created, it is not necessarily a file system path like the +# "loop/backing_file" sysfs attribute, which is always an absolute path) +ENV{ID_LOOP_BACKING_FILENAME_ENC}!="", SYMLINK+="disk/by-loop-ref/$env{ID_LOOP_BACKING_FILENAME_ENC}$env{.PART_SUFFIX}" + +LABEL="persistent_storage_end" diff --git a/misc/rules/60-persistent-v4l.rules b/misc/rules/60-persistent-v4l.rules new file mode 100644 index 0000000..071650a --- /dev/null +++ b/misc/rules/60-persistent-v4l.rules @@ -0,0 +1,22 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="persistent_v4l_end" +SUBSYSTEM!="video4linux", GOTO="persistent_v4l_end" +ENV{MAJOR}=="", GOTO="persistent_v4l_end" + +IMPORT{program}="v4l_id $devnode" + +SUBSYSTEMS=="usb", IMPORT{builtin}="usb_id" +KERNEL=="video*", ENV{ID_SERIAL}=="?*", SYMLINK+="v4l/by-id/$env{ID_BUS}-$env{ID_SERIAL}-video-index$attr{index}" + +# check for valid "index" number +TEST!="index", GOTO="persistent_v4l_end" +ATTR{index}!="?*", GOTO="persistent_v4l_end" + +IMPORT{builtin}="path_id" +KERNEL=="video*|vbi*", ENV{ID_PATH}=="?*", SYMLINK+="v4l/by-path/$env{ID_PATH}-video-index$attr{index}" +KERNEL=="video*|vbi*", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="v4l/by-path/$env{ID_PATH_WITH_USB_REVISION}-video-index$attr{index}" +KERNEL=="audio*", ENV{ID_PATH}=="?*", SYMLINK+="v4l/by-path/$env{ID_PATH}-audio-index$attr{index}" +KERNEL=="audio*", ENV{ID_PATH_WITH_USB_REVISION}=="?*", SYMLINK+="v4l/by-path/$env{ID_PATH_WITH_USB_REVISION}-audio-index$attr{index}" + +LABEL="persistent_v4l_end" diff --git a/misc/rules/60-sensor.rules b/misc/rules/60-sensor.rules new file mode 100644 index 0000000..09180b4 --- /dev/null +++ b/misc/rules/60-sensor.rules @@ -0,0 +1,34 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="sensor_end" + +# device matching the sensor's label, name and the machine's DMI data for IIO devices +SUBSYSTEM=="iio", KERNEL=="iio*", SUBSYSTEMS=="usb|i2c|platform", ATTR{label}!="", \ + IMPORT{builtin}="hwdb 'sensor:$attr{label}:modalias:$attr{modalias}:$attr{[dmi/id]modalias}'", \ + GOTO="sensor_end" + +# Before Linux v6.0, cros-ec-accel used a non-standard 'location' sysfs file +SUBSYSTEM=="iio", KERNEL=="iio*", SUBSYSTEMS=="platform", \ + ATTR{name}=="cros-ec-accel|cros-ec-accel-legacy", ATTR{location}=="base", \ + IMPORT{builtin}="hwdb 'sensor:accel-base:modalias:$attr{modalias}:$attr{[dmi/id]modalias}'", \ + GOTO="sensor_end" + +SUBSYSTEM=="iio", KERNEL=="iio*", SUBSYSTEMS=="platform", \ + ATTR{name}=="cros-ec-accel|cros-ec-accel-legacy", ATTR{location}=="lid", \ + IMPORT{builtin}="hwdb 'sensor:accel-display:modalias:$attr{modalias}:$attr{[dmi/id]modalias}'", \ + GOTO="sensor_end" + +# device matching the sensor's name and the machine's DMI data for IIO devices +SUBSYSTEM=="iio", KERNEL=="iio*", SUBSYSTEMS=="usb|i2c|platform", \ + IMPORT{builtin}="hwdb 'sensor:modalias:$attr{modalias}:$attr{[dmi/id]modalias}'", \ + GOTO="sensor_end" + +SUBSYSTEM=="input", ENV{ID_INPUT_ACCELEROMETER}=="1", SUBSYSTEMS=="acpi", \ + IMPORT{builtin}="hwdb 'sensor:modalias:acpi:$attr{hid}:$attr{[dmi/id]modalias}'", \ + GOTO="sensor_end" + +SUBSYSTEM=="input", ENV{ID_INPUT_ACCELEROMETER}=="1", SUBSYSTEMS=="platform", \ + IMPORT{builtin}="hwdb 'sensor:modalias:platform:$id:$attr{[dmi/id]modalias}'", \ + GOTO="sensor_end" + +LABEL="sensor_end" diff --git a/misc/rules/60-serial.rules b/misc/rules/60-serial.rules new file mode 100644 index 0000000..0432122 --- /dev/null +++ b/misc/rules/60-serial.rules @@ -0,0 +1,28 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="serial_end" +SUBSYSTEM!="tty", GOTO="serial_end" + +SUBSYSTEMS=="usb", IMPORT{builtin}="usb_id", IMPORT{builtin}="hwdb --subsystem=usb" +SUBSYSTEMS=="pci", ENV{ID_BUS}=="", ENV{ID_BUS}="pci", \ + ENV{ID_VENDOR_ID}="$attr{vendor}", ENV{ID_MODEL_ID}="$attr{device}", \ + IMPORT{builtin}="hwdb --subsystem=pci" + +# /dev/serial/by-path/, /dev/serial/by-id/ for USB devices +KERNEL!="ttyUSB[0-9]*|ttyACM[0-9]*", GOTO="serial_end" + +SUBSYSTEMS=="usb-serial", ENV{.ID_PORT}="$attr{port_number}" + +IMPORT{builtin}="path_id" +ENV{ID_PATH}=="?*", ENV{.ID_PORT}=="", SYMLINK+="serial/by-path/$env{ID_PATH}" +ENV{ID_PATH_WITH_USB_REVISION}=="?*", ENV{.ID_PORT}=="", SYMLINK+="serial/by-path/$env{ID_PATH_WITH_USB_REVISION}" +ENV{ID_PATH}=="?*", ENV{.ID_PORT}=="?*", SYMLINK+="serial/by-path/$env{ID_PATH}-port$env{.ID_PORT}" +ENV{ID_PATH_WITH_USB_REVISION}=="?*", ENV{.ID_PORT}=="?*", SYMLINK+="serial/by-path/$env{ID_PATH_WITH_USB_REVISION}-port$env{.ID_PORT}" + +ENV{ID_BUS}=="", GOTO="serial_end" +ENV{ID_SERIAL}=="", GOTO="serial_end" +ENV{ID_USB_INTERFACE_NUM}=="", GOTO="serial_end" +ENV{.ID_PORT}=="", SYMLINK+="serial/by-id/$env{ID_BUS}-$env{ID_SERIAL}-if$env{ID_USB_INTERFACE_NUM}" +ENV{.ID_PORT}=="?*", SYMLINK+="serial/by-id/$env{ID_BUS}-$env{ID_SERIAL}-if$env{ID_USB_INTERFACE_NUM}-port$env{.ID_PORT}" + +LABEL="serial_end" diff --git a/misc/rules/60-tpm2-id.rules b/misc/rules/60-tpm2-id.rules new file mode 100644 index 0000000..1e08f3b --- /dev/null +++ b/misc/rules/60-tpm2-id.rules @@ -0,0 +1,10 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="tpm2_id_end" +SUBSYSTEM!="tpmrm", GOTO="tpm2_id_end" +KERNEL!="tpmrm[0-9]*", GOTO="tpm2_id_end" + +IMPORT{program}="tpm2_id identify" +ENV{ID_TPM2_MODALIAS}!="", IMPORT{builtin}="hwdb 'tpm2:$env{ID_TPM2_MODALIAS}'" + +LABEL="tpm2_id_end" diff --git a/misc/rules/64-btrfs.rules b/misc/rules/64-btrfs.rules new file mode 100644 index 0000000..039d759 --- /dev/null +++ b/misc/rules/64-btrfs.rules @@ -0,0 +1,17 @@ +# do not edit this file, it will be overwritten on update + +SUBSYSTEM!="block", GOTO="btrfs_end" +ACTION=="remove", GOTO="btrfs_end" +ENV{ID_FS_TYPE}!="btrfs", GOTO="btrfs_end" +ENV{SYSTEMD_READY}=="0", GOTO="btrfs_end" + +# let the kernel know about this btrfs filesystem, and check if it is complete +IMPORT{builtin}="btrfs ready $devnode" + +# mark the device as not ready to be used by the system +ENV{ID_BTRFS_READY}=="0", ENV{SYSTEMD_READY}="0" + +# reconsider pending devices in case when multidevice volume awaits +ENV{ID_BTRFS_READY}=="1", RUN+="{{BINDIR}}/udevadm trigger -s block -p ID_BTRFS_READY=0" + +LABEL="btrfs_end" diff --git a/misc/rules/65-integration.rules b/misc/rules/65-integration.rules new file mode 100644 index 0000000..5d78c94 --- /dev/null +++ b/misc/rules/65-integration.rules @@ -0,0 +1,27 @@ +# do not edit this file, it will be overwritten on update + +# ID_INTEGRATION variable tells us if a device is internal (inherent part of the system) or external otherwise. +# This must be loaded after 60-persistent-*.rules to have ID_BUS. + +ACTION=="remove", GOTO="integration_end" +ENV{ID_BUS}=="", GOTO="integration_end" + +# ACPI, platform, PS/2, I2C, RMI, SPI and PCI devices: Internal by default. +ENV{ID_BUS}=="acpi|platform|i8042|i2c|rmi|spi|pci", ENV{ID_INTEGRATION}="internal", GOTO="libinput_integration_compat" + +# Bluetooth devices: External by default. +ENV{ID_BUS}=="bluetooth", ENV{ID_INTEGRATION}="external", GOTO="libinput_integration_compat" + +# USB devices: Internal if it's connected to a fixed port, external to a removable and if it's unknown we use the main parent device attribute. +ENV{ID_BUS}!="usb", GOTO="usb_integration_end" +DRIVERS=="usb", ATTRS{maxchild}=="0", ATTRS{removable}=="fixed", ENV{ID_INTEGRATION}="internal", GOTO="libinput_integration_compat" +DRIVERS=="usb", ATTRS{maxchild}=="0", ATTRS{removable}=="removable", ENV{ID_INTEGRATION}="external", GOTO="libinput_integration_compat" +DRIVERS=="usb", ATTRS{devpath}!="0", ATTRS{removable}=="fixed", ENV{ID_INTEGRATION}="internal", GOTO="libinput_integration_compat" +DRIVERS=="usb", ATTRS{devpath}!="0", ATTRS{removable}=="removable|unknown", ENV{ID_INTEGRATION}="external", GOTO="libinput_integration_compat" +LABEL="usb_integration_end" + +# libinput compatibility, must be loaded before 70-touchpad.rules to allow hwdb quirks to override. +LABEL="libinput_integration_compat" +ENV{ID_INPUT_TOUCHPAD}=="1", ENV{ID_INPUT_TOUCHPAD_INTEGRATION}="$env{ID_INTEGRATION}" + +LABEL="integration_end" diff --git a/misc/rules/70-camera.rules b/misc/rules/70-camera.rules new file mode 100644 index 0000000..d225c4f --- /dev/null +++ b/misc/rules/70-camera.rules @@ -0,0 +1,9 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="camera_end" + +SUBSYSTEM=="video4linux", ENV{ID_BUS}=="usb", \ + IMPORT{builtin}="hwdb 'camera:usb:v$env{ID_VENDOR_ID}p$env{ID_MODEL_ID}:name:$attr{name}:'", \ + GOTO="camera_end" + +LABEL="camera_end" diff --git a/misc/rules/70-joystick.rules b/misc/rules/70-joystick.rules new file mode 100644 index 0000000..34c369a --- /dev/null +++ b/misc/rules/70-joystick.rules @@ -0,0 +1,11 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="joystick_end" +ENV{ID_INPUT_JOYSTICK}=="", GOTO="joystick_end" +KERNEL!="event*", GOTO="joystick_end" + +# joystick::vp:name::* +KERNELS=="input*", ENV{ID_BUS}!="", \ + IMPORT{builtin}="hwdb 'joystick:$env{ID_BUS}:v$attr{id/vendor}p$attr{id/product}:name:$attr{name}:'" + +LABEL="joystick_end" diff --git a/misc/rules/70-memory.rules b/misc/rules/70-memory.rules new file mode 100644 index 0000000..f2610ff --- /dev/null +++ b/misc/rules/70-memory.rules @@ -0,0 +1,8 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="memory_end" +SUBSYSTEM!="dmi", GOTO="memory_end" + +IMPORT{program}="dmi_memory_id" + +LABEL="memory_end" diff --git a/misc/rules/70-mouse.rules b/misc/rules/70-mouse.rules new file mode 100644 index 0000000..3ea743a --- /dev/null +++ b/misc/rules/70-mouse.rules @@ -0,0 +1,18 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="mouse_end" +KERNEL!="event*", GOTO="mouse_end" +ENV{ID_INPUT_MOUSE}=="", GOTO="mouse_end" + +# mouse::vp:name::* +KERNELS=="input*", ENV{ID_BUS}=="usb", \ + IMPORT{builtin}="hwdb 'mouse:$env{ID_BUS}:v$attr{id/vendor}p$attr{id/product}:name:$attr{name}:'", \ + GOTO="mouse_end" +KERNELS=="input*", ENV{ID_BUS}=="bluetooth", \ + IMPORT{builtin}="hwdb 'mouse:$env{ID_BUS}:v$attr{id/vendor}p$attr{id/product}:name:$attr{name}:'", \ + GOTO="mouse_end" +DRIVERS=="psmouse", SUBSYSTEMS=="serio", \ + IMPORT{builtin}="hwdb 'mouse:ps2::name:$attr{device/name}:'", \ + GOTO="mouse_end" + +LABEL="mouse_end" diff --git a/misc/rules/70-power-switch.rules b/misc/rules/70-power-switch.rules new file mode 100644 index 0000000..3fb954a --- /dev/null +++ b/misc/rules/70-power-switch.rules @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +ACTION=="remove", GOTO="power_switch_end" + +SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_SWITCH}=="1", TAG+="power-switch" +SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_KEY}=="1", TAG+="power-switch" + +LABEL="power_switch_end" diff --git a/misc/rules/70-touchpad.rules b/misc/rules/70-touchpad.rules new file mode 100644 index 0000000..e51d8cf --- /dev/null +++ b/misc/rules/70-touchpad.rules @@ -0,0 +1,16 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="touchpad_end" +ENV{ID_INPUT_TOUCHPAD}=="", GOTO="touchpad_end" +KERNEL!="event*", GOTO="touchpad_end" + +# touchpad::vp:name::* +KERNELS=="input*", ENV{ID_BUS}!="", \ + IMPORT{builtin}="hwdb 'touchpad:$env{ID_BUS}:v$attr{id/vendor}p$attr{id/product}:name:$attr{name}:'" + +# Spread the hwdb override to ID_INTEGRATION, in the future we could remove the +# touchpad hwdb entirely or retain it using the generic ID_INTEGRATION instead +# specific ID_INPUT_TOUCHPAD_INTEGRATION. +ENV{ID_INPUT_TOUCHPAD_INTEGRATION}!="", ENV{ID_INTEGRATION}="$env{ID_INPUT_TOUCHPAD_INTEGRATION}" + +LABEL="touchpad_end" diff --git a/misc/rules/70-uaccess.rules b/misc/rules/70-uaccess.rules new file mode 100644 index 0000000..6e20a44 --- /dev/null +++ b/misc/rules/70-uaccess.rules @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +ACTION=="remove", GOTO="uaccess_end" +ENV{MAJOR}=="", GOTO="uaccess_end" + +# PTP/MTP protocol devices, cameras, portable media players +SUBSYSTEM=="usb", ENV{ID_USB_INTERFACES}=="*:060101:*", TAG+="uaccess" + +# Digicams with proprietary protocol +ENV{ID_GPHOTO2}=="?*", TAG+="uaccess" + +# SCSI and USB scanners +ENV{libsane_matched}=="yes", TAG+="uaccess" + +# HPLIP devices (necessary for ink level check and HP tool maintenance) +ENV{ID_HPLIP}=="1", TAG+="uaccess" + +# optical drives +SUBSYSTEM=="block", ENV{ID_CDROM}=="1", TAG+="uaccess" +SUBSYSTEM=="scsi_generic", SUBSYSTEMS=="scsi", ATTRS{type}=="4|5", TAG+="uaccess" + +# Sound devices +SUBSYSTEM=="sound", TAG+="uaccess", \ + OPTIONS+="static_node=snd/timer", OPTIONS+="static_node=snd/seq" + +# Webcams, frame grabber, TV cards +SUBSYSTEM=="video4linux", TAG+="uaccess" +SUBSYSTEM=="dvb", TAG+="uaccess" +SUBSYSTEM=="media", TAG+="uaccess" +# libcamera software ISP used with some cams requires udmabuf access +KERNEL=="udmabuf", TAG+="uaccess" + +# industrial cameras, some webcams, camcorders, set-top boxes, TV sets, audio devices, and more +SUBSYSTEM=="firewire", TEST=="units", ENV{IEEE1394_UNIT_FUNCTION_MIDI}=="1", TAG+="uaccess" +SUBSYSTEM=="firewire", TEST=="units", ENV{IEEE1394_UNIT_FUNCTION_AUDIO}=="1", TAG+="uaccess" +SUBSYSTEM=="firewire", TEST=="units", ENV{IEEE1394_UNIT_FUNCTION_VIDEO}=="1", TAG+="uaccess" + +# DRI video devices +SUBSYSTEM=="drm", KERNEL=="card*", TAG+="uaccess" +{% if GROUP_RENDER_UACCESS %} +# DRI render nodes +SUBSYSTEM=="drm", KERNEL=="renderD*", TAG+="uaccess", TAG+="xaccess-render" +# DRI accel nodes +SUBSYSTEM=="accel", KERNEL=="accel*", TAG+="uaccess", TAG+="xaccess-accel" +# AMD KFD nodes +SUBSYSTEM=="kfd", KERNEL=="kfd", TAG+="uaccess", TAG+="xaccess-render" +{% endif %} +{% if DEV_KVM_UACCESS %} +# KVM +SUBSYSTEM=="misc", KERNEL=="kvm", TAG+="uaccess" +{% endif %} + +# smart-card readers +ENV{ID_SMARTCARD_READER}=="?*", TAG+="uaccess" + +# (USB) authentication devices +ENV{ID_SECURITY_TOKEN}=="?*", TAG+="uaccess" + +# PDA devices +ENV{ID_PDA}=="?*", TAG+="uaccess" + +# Programmable remote control +ENV{ID_REMOTE_CONTROL}=="1", TAG+="uaccess" + +# joysticks +SUBSYSTEM=="input", ENV{ID_INPUT_JOYSTICK}=="?*", TAG+="uaccess" + +# color measurement devices +ENV{COLOR_MEASUREMENT_DEVICE}=="?*", TAG+="uaccess" + +# DDC/CI device, usually high-end monitors such as the DreamColor +ENV{DDC_DEVICE}=="?*", TAG+="uaccess" + +# media player raw devices (for user-mode drivers, Android SDK, etc.) +SUBSYSTEM=="usb", ENV{ID_MEDIA_PLAYER}=="?*", TAG+="uaccess" + +# Android devices (ADB DbC, ADB, Fastboot) +# Used to interact with devices over Android Debug Bridge and Fastboot protocols, see: +# * https://developer.android.com/tools/adb +# * https://source.android.com/docs/setup/test/running +# * https://source.android.com/docs/setup/test/flash +# +# The bInterfaceClass and bInterfaceSubClass used are documented in source code here: +# * https://android.googlesource.com/platform/packages/modules/adb/+/d0db47dcdf941673f405e1095e6ffb5e565902e5/adb.h#199 +# * https://android.googlesource.com/platform/system/core/+/7199051aaf0ddfa2849650933119307327d8669c/fastboot/fastboot.cpp#244 +# +# Since it's using a generic vendor specific interface class, this can potentially result +# in a rare case where non-ADB/Fastboot device ends up with an ID_DEBUG_APPLIANCE="android". +SUBSYSTEM=="usb", ENV{ID_USB_INTERFACES}=="*:dc0201:*|*:ff4201:*|*:ff4203:*", ENV{ID_DEBUG_APPLIANCE}="android" + +# software-defined radio communication devices +ENV{ID_SOFTWARE_RADIO}=="?*", TAG+="uaccess" + +# 3D printers, CNC machines, laser cutters, 3D scanners, etc. +ENV{ID_MAKER_TOOL}=="?*", TAG+="uaccess" + +# Protocol analyzers +ENV{ID_SIGNAL_ANALYZER}=="?*", ENV{DEVTYPE}=="usb_device", TAG+="uaccess" +ENV{ID_SIGNAL_ANALYZER}=="?*", KERNEL=="ttyACM[0-9]*", TAG+="uaccess" + +# rfkill / radio killswitches +KERNEL=="rfkill", SUBSYSTEM=="misc", TAG+="uaccess" + +# AV production controllers +# Most of these devices use HID for the knobs, faders, buttons, encoders, and jog wheels. +SUBSYSTEM=="hidraw", ENV{ID_AV_PRODUCTION_CONTROLLER}=="1", TAG+="uaccess" + +# Some devices use vendor defined protocols on USB Bulk endpoints for controllers. +# Other devices transfer graphics to screens on the device through USB Bulk endpoints. +# This also allows accessing HID devices with the libusb backend of hidapi. +SUBSYSTEM=="usb", ENV{ID_AV_PRODUCTION_CONTROLLER}=="1", TAG+="uaccess" + +# USB and Bluetooth controllable lights +SUBSYSTEM=="hidraw", ENV{ID_AV_LIGHTS}=="1", TAG+="uaccess" +SUBSYSTEM=="usb", ENV{ID_AV_LIGHTS}=="1", TAG+="uaccess" + +# Hardware wallets +SUBSYSTEM=="usb", ENV{ID_HARDWARE_WALLET}=="1", TAG+="uaccess" +SUBSYSTEM=="hidraw", ENV{ID_HARDWARE_WALLET}=="1", TAG+="uaccess" + +# 3D mice +# As defined in https://en.wikipedia.org/wiki/3Dconnexion +SUBSYSTEM=="hidraw", ENV{ID_INPUT_3D_MOUSE}=="1", TAG+="uaccess" + +# Debug interfaces (e.g. Android Debug Bridge) +# Made available via xaccess additionally to support scenarios like headless testing machines. +ENV{ID_DEBUG_APPLIANCE}=="?*", TAG+="uaccess", TAG+="xaccess-debug-appliance" + +LABEL="uaccess_end" diff --git a/misc/rules/71-seat.rules b/misc/rules/71-seat.rules new file mode 100644 index 0000000..8cc904c --- /dev/null +++ b/misc/rules/71-seat.rules @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +ACTION=="remove", GOTO="seat_end" + +TAG=="uaccess", SUBSYSTEM!="sound", TAG+="seat" +SUBSYSTEM=="sound", KERNEL=="card*", TAG+="seat" +SUBSYSTEM=="input", KERNEL=="input*", TAG+="seat" +SUBSYSTEM=="hidraw", KERNEL=="hidraw*", TAG+="seat" +SUBSYSTEM=="graphics", KERNEL=="fb[0-9]*", TAG+="seat" + +# Assign keyboard and LCD backlights to the seat +SUBSYSTEM=="leds", TAG+="seat" +SUBSYSTEM=="backlight", TAG+="seat" + +# Allow efifb / uvesafb to be a master if KMS is disabled +SUBSYSTEM=="graphics", KERNEL=="fb[0-9]", IMPORT{cmdline}="nomodeset", TAG+="master-of-seat" + +# Allow any PCI graphics device to be a master and synthesize a seat if KMS +# is disabled and the kernel doesn't have a driver that would work with this device. +SUBSYSTEM=="pci", ENV{ID_PCI_CLASS_FROM_DATABASE}=="Display controller", \ + ENV{DRIVER}=="", IMPORT{cmdline}="nomodeset", TAG+="seat", TAG+="master-of-seat" + +# Synthesize a seat for graphic devices without DRM and that fall back to fb +# device instead. Such HWs are listed in hwdb. +SUBSYSTEM=="graphics", KERNEL=="fb[0-9]*", ATTRS{modalias}=="?*", IMPORT{builtin}="hwdb fb:$attr{modalias}" +ENV{ID_TAG_MASTER_OF_SEAT}=="1", TAG+="master-of-seat" + +SUBSYSTEM=="drm", KERNEL=="card[0-9]*", TAG+="seat", TAG+="master-of-seat" + +# Allow individual USB ports to be assigned to a seat +SUBSYSTEM=="usb", ATTR{bDeviceClass}=="00", TAG+="seat" + +# Allow USB hubs (and all downstream ports) to be assigned to a seat +SUBSYSTEM=="usb", ATTR{bDeviceClass}=="09", TAG+="seat" + +# 'Plugable' USB hub, sound, network, graphics adapter +SUBSYSTEM=="usb", ATTR{idVendor}=="2230", ATTR{idProduct}=="000[13]", ENV{ID_AUTOSEAT}="1" + +# qemu (version 2.4+) has a PCI-PCI bridge (-device pci-bridge-seat) to group +# devices belonging to one seat. See: +# http://git.qemu.org/?p=qemu.git;a=blob;f=docs/multiseat.txt +SUBSYSTEM=="pci", ATTR{vendor}=="0x1b36", ATTR{device}=="0x000a", TAG+="seat", ENV{ID_AUTOSEAT}="1" + +# Mimo 720, with integrated USB hub, displaylink graphics, and e2i +# touchscreen. This device carries no proper VID/PID in the USB hub, +# but it does carry good ID data in the graphics component, hence we +# check it from the parent. There's a bit of a race here however, +# given that the child devices might not exist yet at the time this +# rule is executed. To work around this we'll trigger the parent from +# the child if we notice that the parent wasn't recognized yet. + +# Match parent +{% raw -%} +SUBSYSTEM=="usb", ATTR{idVendor}=="058f", ATTR{idProduct}=="6254", \ + ATTR{%k.2/idVendor}=="17e9", ATTR{%k.2/idProduct}=="401a", ATTR{%k.2/product}=="mimo inc", \ + ENV{ID_AUTOSEAT}="1", ENV{ID_AVOID_LOOP}="1" +{% endraw %} + +# Match child, look for parent's ID_AVOID_LOOP +SUBSYSTEM=="usb", ATTR{idVendor}=="17e9", ATTR{idProduct}=="401a", ATTR{product}=="mimo inc", \ + ATTR{../idVendor}=="058f", ATTR{../idProduct}=="6254", \ + IMPORT{parent}="ID_AVOID_LOOP" + +# Match child, retrigger parent +SUBSYSTEM=="usb", ATTR{idVendor}=="17e9", ATTR{idProduct}=="401a", ATTR{product}=="mimo inc", \ + ATTR{../idVendor}=="058f", ATTR{../idProduct}=="6254", \ + ENV{ID_AVOID_LOOP}=="", \ + RUN+="{{BINDIR}}/udevadm trigger --parent-match=%p/.." + +TAG=="seat", ENV{ID_PATH}=="", IMPORT{builtin}="path_id" +TAG=="seat", ENV{ID_FOR_SEAT}=="", ENV{ID_PATH_TAG}!="", ENV{ID_FOR_SEAT}="$env{SUBSYSTEM}-$env{ID_PATH_TAG}" + +SUBSYSTEM=="input", ATTR{name}=="Wiebetech LLC Wiebetech", RUN+="{{BINDIR}}/loginctl lock-sessions" + +LABEL="seat_end" diff --git a/misc/rules/73-seat-late.rules b/misc/rules/73-seat-late.rules new file mode 100644 index 0000000..f5d302c --- /dev/null +++ b/misc/rules/73-seat-late.rules @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +ACTION=="remove", GOTO="seat_late_end" + +ENV{ID_SEAT}=="", ENV{ID_AUTOSEAT}=="1", ENV{ID_FOR_SEAT}!="", ENV{ID_SEAT}="seat-$env{ID_FOR_SEAT}" +ENV{ID_SEAT}=="", IMPORT{parent}="ID_SEAT" + +ENV{ID_SEAT}!="", TAG+="$env{ID_SEAT}" +{% if HAVE_ACL %} +TAG=="uaccess|xaccess-*", ENV{MAJOR}!="", RUN{builtin}+="uaccess" +{% endif %} + +LABEL="seat_late_end" diff --git a/misc/rules/75-net-description.rules b/misc/rules/75-net-description.rules new file mode 100644 index 0000000..e154cfa --- /dev/null +++ b/misc/rules/75-net-description.rules @@ -0,0 +1,17 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="net_end" +SUBSYSTEM!="net", GOTO="net_end" + +IMPORT{builtin}="hwdb 'net:naming:dr$env{ID_NET_DRIVER}:'" + +SUBSYSTEMS=="usb", IMPORT{builtin}="usb_id", IMPORT{builtin}="hwdb --subsystem=usb" +SUBSYSTEMS=="usb", GOTO="import_net_id" + +SUBSYSTEMS=="pci", ENV{ID_BUS}="pci", ENV{ID_VENDOR_ID}="$attr{vendor}", ENV{ID_MODEL_ID}="$attr{device}" +SUBSYSTEMS=="pci", IMPORT{builtin}="hwdb --subsystem=pci" + +LABEL="import_net_id" +IMPORT{builtin}="net_id" + +LABEL="net_end" diff --git a/misc/rules/75-probe_mtd.rules b/misc/rules/75-probe_mtd.rules new file mode 100644 index 0000000..8848aee --- /dev/null +++ b/misc/rules/75-probe_mtd.rules @@ -0,0 +1,7 @@ +# do not edit this file, it will be overwritten on update + +ACTION!="add", GOTO="mtd_probe_end" + +KERNEL=="mtd*ro", IMPORT{program}="mtd_probe $devnode" + +LABEL="mtd_probe_end" diff --git a/misc/rules/78-sound-card.rules b/misc/rules/78-sound-card.rules new file mode 100644 index 0000000..f2fc277 --- /dev/null +++ b/misc/rules/78-sound-card.rules @@ -0,0 +1,96 @@ +# do not edit this file, it will be overwritten on update + +SUBSYSTEM!="sound", GOTO="sound_end" + +ACTION=="add|change", KERNEL=="controlC*", ATTR{../uevent}="change" +ACTION!="change", GOTO="sound_end" + +# Ok, we probably need a little explanation here for what the two lines above +# are good for. +# +# The story goes like this: when ALSA registers a new sound card it emits a +# series of 'add' events to userspace, for the main card device and for all the +# child device nodes that belong to it. udev relays those to applications, +# however only maintains the order between father and child, but not between +# the siblings. The control device node creation can be used as synchronization +# point. All other devices that belong to a card are created in the kernel +# before it. However unfortunately due to the fact that siblings are forwarded +# out of order by udev this fact is lost to applications. +# +# OTOH before an application can open a device it needs to make sure that all +# its device nodes are completely created and set up. +# +# As a workaround for this issue we have added the udev rule above which will +# generate a 'change' event on the main card device from the 'add' event of the +# card's control device. Due to the ordering semantics of udev this event will +# only be relayed after all child devices have finished processing properly. +# When an application needs to listen for appearing devices it can hence look +# for 'change' events only, and ignore the actual 'add' events. +# +# When the application is initialized at the same time as a device is plugged +# in it may need to figure out if the 'change' event has already been triggered +# or not for a card. To find that out we store the flag environment variable +# SOUND_INITIALIZED on the device which simply tells us if the card 'change' +# event has already been processed. + +KERNEL!="card*", GOTO="sound_end" + +ENV{SOUND_INITIALIZED}="1" + +IMPORT{builtin}="hwdb" +SUBSYSTEMS=="usb", IMPORT{builtin}="usb_id" +SUBSYSTEMS=="usb", GOTO="skip_pci" + +SUBSYSTEMS=="firewire", ATTRS{guid}=="?*", \ + ENV{ID_BUS}="firewire", ENV{ID_SERIAL}="$attr{guid}", ENV{ID_SERIAL_SHORT}="$attr{guid}", \ + ENV{ID_VENDOR_ID}="$attr{vendor}", ENV{ID_MODEL_ID}="$attr{model}", \ + ENV{ID_VENDOR}="$attr{vendor_name}", ENV{ID_MODEL}="$attr{model_name}" +SUBSYSTEMS=="firewire", GOTO="skip_pci" + +SUBSYSTEMS=="pci", ENV{ID_BUS}="pci", ENV{ID_VENDOR_ID}="$attr{vendor}", ENV{ID_MODEL_ID}="$attr{device}" +SUBSYSTEMS=="pci", GOTO="skip_pci" + +# If we reach here, the device nor any of its parents are USB/PCI/firewire bus devices. +# If we now find a parent that is a platform device, assume that we're working with +# an internal sound card. +SUBSYSTEMS=="platform", ENV{SOUND_FORM_FACTOR}="internal", GOTO="sound_end" + +LABEL="skip_pci" + +# Define ID_ID if ID_BUS and ID_SERIAL are set. This will work for both +# USB and firewire. +ENV{ID_SERIAL}=="?*", ENV{ID_USB_INTERFACE_NUM}=="?*", ENV{ID_ID}="$env{ID_BUS}-$env{ID_SERIAL}-$env{ID_USB_INTERFACE_NUM}" +ENV{ID_SERIAL}=="?*", ENV{ID_USB_INTERFACE_NUM}=="", ENV{ID_ID}="$env{ID_BUS}-$env{ID_SERIAL}" + +IMPORT{builtin}="path_id" + +# The values used here for $SOUND_FORM_FACTOR and $SOUND_CLASS should be kept +# in sync with those defined for PulseAudio's src/pulse/proplist.h +# PA_PROP_DEVICE_FORM_FACTOR, PA_PROP_DEVICE_CLASS properties. + +# If the first PCM device of this card has the pcm class 'modem', then the card is a modem +ATTR{pcmC%nD0p/pcm_class}=="modem", ENV{SOUND_CLASS}="modem", GOTO="sound_end" + +# Identify cards on the internal PCI bus as internal +SUBSYSTEMS=="pci", DEVPATH=="*/0000:00:??.?/sound/*", ENV{SOUND_FORM_FACTOR}="internal", GOTO="sound_end" + +# Devices that also support Image/Video interfaces are most likely webcams +SUBSYSTEMS=="usb", ENV{ID_USB_INTERFACES}=="*:0e????:*", ENV{SOUND_FORM_FACTOR}="webcam", GOTO="sound_end" + +# Matching on the model strings is a bit ugly, I admit +ENV{ID_MODEL}=="*[Ss]peaker*", ENV{SOUND_FORM_FACTOR}="speaker", GOTO="sound_end" +ENV{ID_MODEL_FROM_DATABASE}=="*[Ss]peaker*", ENV{SOUND_FORM_FACTOR}="speaker", GOTO="sound_end" + +ENV{ID_MODEL}=="*[Hh]eadphone*", ENV{SOUND_FORM_FACTOR}="headphone", GOTO="sound_end" +ENV{ID_MODEL_FROM_DATABASE}=="*[Hh]eadphone*", ENV{SOUND_FORM_FACTOR}="headphone", GOTO="sound_end" + +ENV{ID_MODEL}=="*[Hh]eadset*", ENV{SOUND_FORM_FACTOR}="headset", GOTO="sound_end" +ENV{ID_MODEL_FROM_DATABASE}=="*[Hh]eadset*", ENV{SOUND_FORM_FACTOR}="headset", GOTO="sound_end" + +ENV{ID_MODEL}=="*[Hh]andset*", ENV{SOUND_FORM_FACTOR}="handset", GOTO="sound_end" +ENV{ID_MODEL_FROM_DATABASE}=="*[Hh]andset*", ENV{SOUND_FORM_FACTOR}="handset", GOTO="sound_end" + +ENV{ID_MODEL}=="*[Mm]icrophone*", ENV{SOUND_FORM_FACTOR}="microphone", GOTO="sound_end" +ENV{ID_MODEL_FROM_DATABASE}=="*[Mm]icrophone*", ENV{SOUND_FORM_FACTOR}="microphone", GOTO="sound_end" + +LABEL="sound_end" diff --git a/misc/rules/80-drivers.rules b/misc/rules/80-drivers.rules new file mode 100644 index 0000000..4bf942f --- /dev/null +++ b/misc/rules/80-drivers.rules @@ -0,0 +1,13 @@ +# do not edit this file, it will be overwritten on update + +ACTION!="add", GOTO="drivers_end" + +ENV{MODALIAS}=="?*", RUN{builtin}+="kmod load" +SUBSYSTEM=="tifm", ENV{TIFM_CARD_TYPE}=="SD", RUN{builtin}+="kmod load tifm_sd" +SUBSYSTEM=="tifm", ENV{TIFM_CARD_TYPE}=="MS", RUN{builtin}+="kmod load tifm_ms" +SUBSYSTEM=="memstick", RUN{builtin}+="kmod load ms_block mspro_block" +SUBSYSTEM=="i2o", RUN{builtin}+="kmod load i2o_block" +SUBSYSTEM=="module", KERNEL=="parport_pc", RUN{builtin}+="kmod load ppdev" +KERNEL=="mtd*ro", ENV{MTD_FTL}=="smartmedia", RUN{builtin}+="kmod load sm_ftl" + +LABEL="drivers_end" diff --git a/misc/rules/80-net-setup-link.rules b/misc/rules/80-net-setup-link.rules new file mode 100644 index 0000000..bafc3fb --- /dev/null +++ b/misc/rules/80-net-setup-link.rules @@ -0,0 +1,13 @@ +# do not edit this file, it will be overwritten on update + +SUBSYSTEM!="net", GOTO="net_setup_link_end" + +IMPORT{builtin}="path_id" + +ACTION=="remove", GOTO="net_setup_link_end" + +IMPORT{builtin}="net_setup_link" + +NAME=="", ENV{ID_NET_NAME}!="", NAME="$env{ID_NET_NAME}" + +LABEL="net_setup_link_end" diff --git a/misc/rules/81-net-bridge.rules b/misc/rules/81-net-bridge.rules new file mode 100644 index 0000000..defb31f --- /dev/null +++ b/misc/rules/81-net-bridge.rules @@ -0,0 +1,16 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="net_bridge_end" +SUBSYSTEM!="net", GOTO="net_bridge_end" + +# Some devices require the port to be up before joining the bridge. +# In such cases, set ID_NET_BRING_UP_BEFORE_JOINING_BRIDGE to "1". + +# Texas Instruments Ethernet device with switchdev mode: +# https://docs.kernel.org/networking/device_drivers/ethernet/ti/am65_nuss_cpsw_switchdev.html#enabling-switch +ENV{ID_NET_DRIVER}=="am65-cpsw-nuss", SUBSYSTEMS=="platform", DRIVERS=="am65-cpsw-nuss", \ + PROGRAM="/usr/sbin/devlink dev param show platform/%b name switch_mode", \ + RESULT=="*cmode runtime value true*", \ + ENV{ID_NET_BRING_UP_BEFORE_JOINING_BRIDGE}="1" + +LABEL="net_bridge_end" diff --git a/misc/rules/81-net-dhcp.rules b/misc/rules/81-net-dhcp.rules new file mode 100644 index 0000000..2ef25ba --- /dev/null +++ b/misc/rules/81-net-dhcp.rules @@ -0,0 +1,14 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="net_dhcp_end" +SUBSYSTEM!="net", GOTO="net_dhcp_end" + +# Network interfaces requiring DHCPOFFER messages to be broadcast +# must set ID_NET_DHCP_BROADCAST to "1". This property will be +# checked by the networkd DHCP4 client to set the DHCP option + +# s390 ccwgroup interfaces in layer3 mode need broadcast DHCPOFFER +# using the link driver to detect this condition +ENV{ID_NET_DRIVER}=="qeth_l3", ENV{ID_NET_DHCP_BROADCAST}="1" + +LABEL="net_dhcp_end" diff --git a/misc/rules/82-net-auto-link-local.rules b/misc/rules/82-net-auto-link-local.rules new file mode 100644 index 0000000..88e581c --- /dev/null +++ b/misc/rules/82-net-auto-link-local.rules @@ -0,0 +1,15 @@ +# do not edit this file, it will be overwritten on update + +ACTION=="remove", GOTO="net_link_local_end" +SUBSYSTEM!="net", GOTO="net_link_local_end" + +# Network interfaces for which only Link-Local communication (i.e. IPv4LL, …) +# makes sense, because they almost certainly will point to another host, not an +# internet router. + +# (Note: matches against VID/PID go into 82-net-auto-link-local.hwdb instead) + +# Thunderbolt host-to-host connections +DRIVERS=="thunderbolt-net", ENV{ID_NET_AUTO_LINK_LOCAL_ONLY}="1" + +LABEL="net_link_local_end" diff --git a/misc/rules/90-image-dissect.rules b/misc/rules/90-image-dissect.rules new file mode 100644 index 0000000..9bb097a --- /dev/null +++ b/misc/rules/90-image-dissect.rules @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +ACTION=="remove", GOTO="image_dissect_end" +SUBSYSTEM!="block", GOTO="image_dissect_end" + +# Add symlink to GPT root disk – in two flavours: one which takes the factory +# reset state into account, and one which does not. The former is useful for +# wipe-rootfs-on-factory-reset scenarios where we should not be tempted to use +# the root fs before factory reset is complete. The latter is useful for +# wipe-only-/var-on-factory-reset where we should use it (because that's where +# repart.d/ definitions are placed which tell us what to wipe). +ENV{ID_PART_GPT_AUTO_ROOT}!="1", GOTO="gpt_auto_root_end" + IMPORT{builtin}="factory_reset status" + ENV{ID_FS_TYPE}!="crypto_LUKS", ENV{ID_FACTORY_RESET}!="on", SYMLINK+="gpt-auto-root" + ENV{ID_FS_TYPE}!="crypto_LUKS", ENV{ID_FACTORY_RESET}=="on|complete", SYMLINK+="gpt-auto-root-ignore-factory-reset" + ENV{ID_FS_TYPE}=="crypto_LUKS", ENV{ID_FACTORY_RESET}!="on", SYMLINK+="gpt-auto-root-luks" + ENV{ID_FS_TYPE}=="crypto_LUKS", ENV{ID_FACTORY_RESET}=="on|complete", SYMLINK+="gpt-auto-root-luks-ignore-factory-reset" +LABEL="gpt_auto_root_end" + +# Note we don't need to condition the gpt-auto-root LUKS symlink for +# auto-discovered LUKS devices, because it's sufficient if we do this for the +# underlying partition block device, which is covered by the above. +ENV{DM_UUID}=="CRYPT-*", ENV{DM_NAME}=="root", IMPORT{builtin}="factory_reset status", SYMLINK+="gpt-auto-root" +ENV{DM_UUID}=="CRYPT-*", ENV{DM_NAME}=="root", ENV{ID_FACTORY_RESET}=="on|complete", SYMLINK+="gpt-auto-root-ignore-factory-reset" + +# If this is the whole disk that we booted from, then dissect it +ENV{DEVTYPE}=="disk", ENV{ID_PART_GPT_AUTO_ROOT_DISK}=="1", IMPORT{builtin}="dissect_image probe" +ENV{DEVTYPE}=="disk", ENV{ID_PART_GPT_AUTO_ROOT_DISK}=="1", ENV{ID_FACTORY_RESET}=="", IMPORT{builtin}="factory_reset status" + +# If this is a partition, and we found something on the parent, then copy the +# right properties from the parent, and rename them +ENV{DEVTYPE}=="partition", ENV{ID_DISSECT_IMAGE}!="", IMPORT{builtin}="dissect_image copy" + +# Create symlinks based on the designator for the partitions themselves. If we detect LUKS or Verity, suffix them with "-luks" or "-vdata" +ENV{DEVTYPE}!="partition", GOTO="dissect_partition_symlinks_end" + ENV{ID_DISSECT_PART_DESIGNATOR}=="", GOTO="dissect_partition_symlinks_end" + ENV{ID_FS_TYPE}!="crypto_LUKS", ENV{ID_DISSECT_PART_HAS_VERITY}!="1", ENV{ID_FACTORY_RESET}!="on", SYMLINK+="disk/by-designator/$env{ID_DISSECT_PART_DESIGNATOR}" + ENV{ID_FS_TYPE}!="crypto_LUKS", ENV{ID_DISSECT_PART_HAS_VERITY}!="1", ENV{ID_FACTORY_RESET}=="on|complete", SYMLINK+="disk/by-designator/$env{ID_DISSECT_PART_DESIGNATOR}-ignore-factory-reset" + ENV{ID_FS_TYPE}=="crypto_LUKS", ENV{ID_FACTORY_RESET}!="on", SYMLINK+="disk/by-designator/$env{ID_DISSECT_PART_DESIGNATOR}-luks" + ENV{ID_FS_TYPE}=="crypto_LUKS", ENV{ID_FACTORY_RESET}=="on|complete", SYMLINK+="disk/by-designator/$env{ID_DISSECT_PART_DESIGNATOR}-luks-ignore-factory-reset" + ENV{ID_FS_TYPE}!="crypto_LUKS", ENV{ID_DISSECT_PART_HAS_VERITY}=="1", ENV{ID_FACTORY_RESET}!="on", SYMLINK+="disk/by-designator/$env{ID_DISSECT_PART_DESIGNATOR}-verity-data" + ENV{ID_FS_TYPE}!="crypto_LUKS", ENV{ID_DISSECT_PART_HAS_VERITY}=="1", ENV{ID_FACTORY_RESET}=="on|complete", SYMLINK+="disk/by-designator/$env{ID_DISSECT_PART_DESIGNATOR}-verity-data-ignore-factory-reset" +LABEL="dissect_partition_symlinks_end" + +# For LUKS or Verity partitions we rely on the selected volume name +ENV{DM_UUID}=="CRYPT-*", ENV{DM_NAME}=="root|usr|home|srv|swap|tmp|var", IMPORT{builtin}="factory_reset status", SYMLINK+="disk/by-designator/$env{DM_NAME}" +ENV{DM_UUID}=="CRYPT-*", ENV{DM_NAME}=="root|usr|home|srv|swap|tmp|var", ENV{ID_FACTORY_RESET}=="on|complete", SYMLINK+="disk/by-designator/$env{DM_NAME}-ignore-factory-reset" + +LABEL="image_dissect_end" diff --git a/misc/rules/90-iocost.rules b/misc/rules/90-iocost.rules new file mode 100644 index 0000000..34311de --- /dev/null +++ b/misc/rules/90-iocost.rules @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +SUBSYSTEM!="block", GOTO="iocost_end" +ENV{DEVTYPE}!="disk", GOTO="iocost_end" +ACTION=="remove", GOTO="iocost_end" + +ENV{.MODEL}="" +ENV{ID_MODEL}!="", ENV{.MODEL}="$env{ID_MODEL}" +ENV{ID_MODEL_FROM_DATABASE}!="", ENV{.MODEL}="$env{ID_MODEL_FROM_DATABASE}" + +ENV{.MODEL}!="", IMPORT{builtin}="hwdb 'block::name:$env{.MODEL}:fwrev:$env{ID_REVISION}:'" + +ENV{IOCOST_SOLUTIONS}!="", RUN+="iocost apply $env{DEVNAME}" + +LABEL="iocost_end" diff --git a/misc/rules/90-vconsole.rules b/misc/rules/90-vconsole.rules new file mode 100644 index 0000000..bc7f8a1 --- /dev/null +++ b/misc/rules/90-vconsole.rules @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +# Each vtcon keeps its own state of fonts. +# +ACTION=="add", SUBSYSTEM=="vtconsole", KERNEL=="vtcon*", RUN+="{{SYSTEMCTL_BINARY_PATH}} --no-block restart systemd-vconsole-setup.service" diff --git a/misc/rules/LICENSE.LGPL2.1 b/misc/rules/LICENSE.LGPL2.1 new file mode 100644 index 0000000..86c73c7 --- /dev/null +++ b/misc/rules/LICENSE.LGPL2.1 @@ -0,0 +1,502 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 2.1, February 1999 + + Copyright (C) 1991, 1999 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts + as the successor of the GNU Library Public License, version 2, hence + the version number 2.1.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Lesser General Public License, applies to some +specially designated software packages--typically libraries--of the +Free Software Foundation and other authors who decide to use it. You +can use it too, but we suggest you first think carefully about whether +this license or the ordinary General Public License is the better +strategy to use in any particular case, based on the explanations below. + + When we speak of free software, we are referring to freedom of use, +not price. Our General Public Licenses are designed to make sure that +you have the freedom to distribute copies of free software (and charge +for this service if you wish); that you receive source code or can get +it if you want it; that you can change the software and use pieces of +it in new free programs; and that you are informed that you can do +these things. + + To protect your rights, we need to make restrictions that forbid +distributors to deny you these rights or to ask you to surrender these +rights. These restrictions translate to certain responsibilities for +you if you distribute copies of the library or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link other code with the library, you must provide +complete object files to the recipients, so that they can relink them +with the library after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + We protect your rights with a two-step method: (1) we copyright the +library, and (2) we offer you this license, which gives you legal +permission to copy, distribute and/or modify the library. + + To protect each distributor, we want to make it very clear that +there is no warranty for the free library. Also, if the library is +modified by someone else and passed on, the recipients should know +that what they have is not the original version, so that the original +author's reputation will not be affected by problems that might be +introduced by others. + + Finally, software patents pose a constant threat to the existence of +any free program. We wish to make sure that a company cannot +effectively restrict the users of a free program by obtaining a +restrictive license from a patent holder. Therefore, we insist that +any patent license obtained for a version of the library must be +consistent with the full freedom of use specified in this license. + + Most GNU software, including some libraries, is covered by the +ordinary GNU General Public License. This license, the GNU Lesser +General Public License, applies to certain designated libraries, and +is quite different from the ordinary General Public License. We use +this license for certain libraries in order to permit linking those +libraries into non-free programs. + + When a program is linked with a library, whether statically or using +a shared library, the combination of the two is legally speaking a +combined work, a derivative of the original library. The ordinary +General Public License therefore permits such linking only if the +entire combination fits its criteria of freedom. The Lesser General +Public License permits more lax criteria for linking other code with +the library. + + We call this license the "Lesser" General Public License because it +does Less to protect the user's freedom than the ordinary General +Public License. It also provides other free software developers Less +of an advantage over competing non-free programs. These disadvantages +are the reason we use the ordinary General Public License for many +libraries. However, the Lesser license provides advantages in certain +special circumstances. + + For example, on rare occasions, there may be a special need to +encourage the widest possible use of a certain library, so that it becomes +a de-facto standard. To achieve this, non-free programs must be +allowed to use the library. A more frequent case is that a free +library does the same job as widely used non-free libraries. In this +case, there is little to gain by limiting the free library to free +software only, so we use the Lesser General Public License. + + In other cases, permission to use a particular library in non-free +programs enables a greater number of people to use a large body of +free software. For example, permission to use the GNU C Library in +non-free programs enables many more people to use the whole GNU +operating system, as well as its variant, the GNU/Linux operating +system. + + Although the Lesser General Public License is Less protective of the +users' freedom, it does ensure that the user of a program that is +linked with the Library has the freedom and the wherewithal to run +that program using a modified version of the Library. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, whereas the latter must +be combined with the library in order to run. + + GNU LESSER GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library or other +program which contains a notice placed by the copyright holder or +other authorized party saying it may be distributed under the terms of +this Lesser General Public License (also called "this License"). +Each licensee is addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also combine or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (1) uses at run time a + copy of the library already present on the user's computer system, + rather than copying library functions into the executable, and (2) + will operate properly with a modified version of the library, if + the user installs one, as long as the modified version is + interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + e) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the materials to be distributed need not include anything that is +normally distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties with +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Lesser General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/udevadm/Cargo.toml b/udevadm/Cargo.toml new file mode 100644 index 0000000..292a06b --- /dev/null +++ b/udevadm/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "udevadm" +version = "0.1.0" +edition = "2024" + +[dependencies] +udev-core = { path = "../lib/udev-core" } +clap = { version = "4", features = ["derive"] } +log = "0.4" +env_logger = "0.11" +serde_json = "1" diff --git a/udevadm/src/coldplug.rs b/udevadm/src/coldplug.rs new file mode 100644 index 0000000..5b3f7d8 --- /dev/null +++ b/udevadm/src/coldplug.rs @@ -0,0 +1,114 @@ +//! udev coldplug: trigger synthetic uevents for all existing devices. +//! +//! Walks `/sys/dev/block/` and `/sys/dev/char/`, resolves each symlink to +//! the real sysfs device path, and writes `"add\n"` to the device's `uevent` +//! file. The kernel then broadcasts a `NETLINK_KOBJECT_UEVENT` message that +//! the device daemon (lxdeviced / udevd) receives and processes — evaluating +//! rules, creating device nodes, applying permissions, etc. +//! +//! Errors on individual devices are logged as warnings and skipped; they do +//! not abort the whole coldplug process. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Perform udev coldplug — trigger `add` uevents for every known device. +/// +/// This is the equivalent of `udevadm trigger --action add`. +pub fn coldplug() -> io::Result<()> { + for sys_dir in ["/sys/dev/block", "/sys/dev/char"] { + trigger_devices_in(sys_dir)?; + } + Ok(()) +} + +// ── per-directory processing ────────────────────────────────────────────── + +/// Walk a single `/sys/dev/{block,char}` directory and trigger uevents. +fn trigger_devices_in(sys_dir: &str) -> io::Result<()> { + let dir = match fs::read_dir(sys_dir) { + Ok(d) => d, + Err(e) => { + log::warn!("cannot list {}: {}", sys_dir, e); + return Ok(()); + } + }; + + for entry in dir { + let entry = match entry { + Ok(e) => e, + Err(e) => { + log::warn!("cannot read entry in {}: {}", sys_dir, e); + continue; + } + }; + + let path = entry.path(); + + // Entry name is "MAJOR:MINOR", e.g. "8:0". + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => { + log::warn!("non-UTF-8 entry name in {}: {:?}", sys_dir, path); + continue; + } + }; + + // Resolve the relative symlink to the real sysfs device path. + // e.g. "../../devices/pci0000:00/.../block/sda". + let link_target = match fs::read_link(&path) { + Ok(t) => t, + Err(e) => { + log::warn!("cannot read symlink {:?}: {}", path, e); + continue; + } + }; + + let sysfs_path = resolve_sysfs_path(&path, &link_target); + + // ── Trigger the uevent by writing to the sysfs uevent file ── + // The kernel picks this up and broadcasts a netlink uevent + // that the device daemon processes. + if let Err(e) = trigger_uevent(&sysfs_path) { + log::warn!( + "cannot trigger uevent for device '{}' ({:?}): {}", + name, + sysfs_path, + e + ); + continue; + } + + log::info!("triggered uevent for device '{}' ({:?})", name, sysfs_path); + } + + Ok(()) +} + +// ── helpers ─────────────────────────────────────────────────────────────── + +/// Resolve a relative sysfs symlink to an absolute path. +/// +/// Symlinks in `/sys/dev/{block,char}` look like: +/// +/// ```text +/// /sys/dev/block/8:0 -> ../../devices/pci0000:00/.../block/sda +/// ``` +fn resolve_sysfs_path(link_path: &Path, link_target: &Path) -> PathBuf { + match fs::canonicalize(link_path) { + Ok(p) => p, + Err(_) => { + // Manual fallback: target is relative to the symlink's parent. + let parent = link_path.parent().unwrap_or(Path::new("/")); + parent.join(link_target) + } + } +} + +/// Write `"add\n"` to the device's `uevent` sysfs file to trigger a +/// kernel uevent broadcast. +fn trigger_uevent(sysfs_path: &Path) -> io::Result<()> { + let uevent_path = sysfs_path.join("uevent"); + fs::write(&uevent_path, b"add\n") +} diff --git a/udevadm/src/control.rs b/udevadm/src/control.rs new file mode 100644 index 0000000..ca4fcd6 --- /dev/null +++ b/udevadm/src/control.rs @@ -0,0 +1,88 @@ +//! `udevadm control` — send control commands to the daemon. +//! +//! Communicates with lxdeviced (or systemd-udevd) over the Varlink +//! control socket at `/run/udev/io.systemd.Udev`. + +use clap::Args; +use udev_core::runtime::control::{ControlCommand, ControlSender}; + +#[derive(Args, Debug)] +pub struct ControlArgs { + /// Ping the daemon + #[arg(long, short = 'p')] + pub ping: bool, + + /// Reload rules and hwdb + #[arg(long, short = 'R')] + pub reload: bool, + + /// Tell the daemon to exit + #[arg(long, short = 'e')] + pub exit: bool, + + /// Start processing events + #[arg(long, short = 'S')] + pub start_exec_queue: bool, + + /// Stop processing events + #[arg(long, short = 's')] + pub stop_exec_queue: bool, + + /// Revert config changes + #[arg(long)] + pub revert: bool, + + /// Set max concurrent workers + #[arg(long, value_name = "N")] + pub children_max: Option, + + /// Set log level (0-7) + #[arg(long, value_name = "N")] + pub log_level: Option, + + /// Set environment variable (KEY=VALUE), may be repeated + #[arg(long, value_name = "KEY=VALUE", action = clap::ArgAction::Append)] + pub env: Vec, +} + +/// Run `udevadm control`. +pub fn run(args: ControlArgs) -> Result<(), String> { + let mut sender = ControlSender::connect().map_err(|e| format!("cannot connect: {e}"))?; + + if args.ping { + sender.ping().map_err(|e| format!("ping failed: {e}"))?; + println!("pong"); + return Ok(()); + } + + let cmd = if args.reload { + ControlCommand::Reload + } else if args.exit { + ControlCommand::Exit + } else if args.start_exec_queue { + ControlCommand::StartExecQueue + } else if args.stop_exec_queue { + ControlCommand::StopExecQueue + } else if args.revert { + ControlCommand::Revert + } else if let Some(n) = args.children_max { + ControlCommand::SetChildrenMax { number: n } + } else if let Some(level) = args.log_level { + ControlCommand::SetLogLevel { level: Some(level) } + } else if !args.env.is_empty() { + ControlCommand::SetEnvironment { + assignments: args.env, + } + } else { + return Err("no command specified; use --help for options".to_string()); + }; + + let resp = sender + .call(&cmd) + .map_err(|e| format!("command failed: {e}"))?; + + let output = serde_json::to_string_pretty(&resp.parameters).unwrap(); + println!("response: {output}"); + + Ok(()) +} diff --git a/udevadm/src/hwdb.rs b/udevadm/src/hwdb.rs new file mode 100644 index 0000000..5b7931d --- /dev/null +++ b/udevadm/src/hwdb.rs @@ -0,0 +1,87 @@ +//! `udevadm hwdb` — query the hardware database. + +use clap::Args; +use udev_core::hwdb_parser::parse_hwdb; +use std::fs; +use std::path::Path; + +#[derive(Args, Debug)] +pub struct HwdbArgs { + /// Update the hwdb database + #[arg(long, short = 'u')] + pub update: bool, + + /// Query key (modalias, vendor ID, etc.) + #[arg(long, value_name = "KEY")] + pub query: Option, + + /// Search string (positional) + pub key: Option, +} + +/// Run `udevadm hwdb`. +pub fn run(args: HwdbArgs) -> Result<(), String> { + if args.update { + eprintln!("hwdb update not yet implemented"); + eprintln!("(systemd uses `systemd-hwdb update` for binary hwdb)"); + return Ok(()); + } + + let query_key = args + .query + .or(args.key) + .ok_or_else(|| "need --query=KEY or a search string".to_string())?; + + let hwdb_dirs = ["/etc/udev/hwdb.d", "/usr/lib/udev/hwdb.d"]; + let mut found = false; + + for dir in &hwdb_dirs { + let dir_path = Path::new(dir); + if !dir_path.exists() { + continue; + } + + let entries = match fs::read_dir(dir_path) { + Ok(e) => e, + Err(_) => continue, + }; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + + let fpath = entry.path(); + if !fpath.to_string_lossy().ends_with(".hwdb") { + continue; + } + + let content = match fs::read_to_string(&fpath) { + Ok(c) => c, + Err(_) => continue, + }; + + let hwdb = match parse_hwdb(&content) { + Ok(h) => h, + Err(_) => continue, + }; + + for record in hwdb.records_matching(&query_key) { + found = true; + for m in &record.matches { + println!("match: {}", m.pattern); + } + for prop in &record.properties { + println!(" {prop}"); + } + } + } + } + + if !found { + eprintln!("no matching hwdb entry found for: {query_key}"); + } + + Ok(()) +} diff --git a/udevadm/src/info.rs b/udevadm/src/info.rs new file mode 100644 index 0000000..9348d20 --- /dev/null +++ b/udevadm/src/info.rs @@ -0,0 +1,205 @@ +//! `udevadm info` — query device information from sysfs and the udev database. + +use clap::Args; +use udev_core::runtime::udev_db; +use std::fs; +use std::path::Path; + +#[derive(Args, Debug)] +pub struct InfoArgs { + /// Query type: all, name, symlink, property, value, path + #[arg(long, short = 'q', value_name = "TYPE")] + pub query: Option, + + /// sysfs device path + #[arg(long, short = 'p', value_name = "DEVPATH")] + pub path: Option, + + /// Device node or name + #[arg(long, short = 'n', value_name = "DEVICE")] + pub name: Option, + + /// Export in key=value format + #[arg(long)] + pub export: bool, + + /// Device path (positional) + pub devpath: Option, +} + +/// Run `udevadm info`. +pub fn run(args: InfoArgs) -> Result<(), String> { + let query_type = args.query.as_deref().unwrap_or("all"); + let export = args.export; + + // Resolve devpath from --path, positional, or --name + let resolved_devpath = if let Some(p) = args.path.or(args.devpath) { + p + } else if let Some(n) = args.name { + resolve_devpath_from_name(&n)? + } else { + return Err("need --path=DEVPATH, --name=DEVICE, or a device argument".to_string()); + }; + + let subsystem = detect_subsystem(&resolved_devpath); + let major = read_uevent_attr(&resolved_devpath, "MAJOR") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let minor = read_uevent_attr(&resolved_devpath, "MINOR") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + match query_type { + "all" => query_all(&resolved_devpath, &subsystem, major, minor, export), + "name" => query_name(&resolved_devpath), + "symlink" => query_symlink(&resolved_devpath, &subsystem, major, minor), + "property" | "env" => query_property(&resolved_devpath, &subsystem, major, minor), + "value" => query_value(&resolved_devpath, &subsystem, major, minor), + "path" => { + println!("{resolved_devpath}"); + Ok(()) + } + _ => Err(format!("unknown query type: {query_type}")), + } +} + +fn query_all(devpath: &str, subsystem: &str, major: u32, minor: u32, export: bool) -> Result<(), String> { + println!("P: {devpath}"); + println!("S: {subsystem}"); + + if let Some(uevent) = read_sysfs_file(devpath, "uevent") { + for line in uevent.lines() { + if let Some(eq) = line.find('=') { + let key = &line[..eq]; + let val = &line[eq + 1..]; + if export { + println!("E: {key}={val}"); + } else { + println!(" {key}: {val}"); + } + } + } + } + + if let Some(entry) = udev_db::read_db(subsystem, major, minor, devpath) { + println!(); + println!("database entry ({major}:{minor}):"); + for (k, v) in &entry.properties { + if export { + println!("E: {k}={v}"); + } else { + println!(" {k}: {v}"); + } + } + for link in &entry.symlinks { + println!(" S: {link}"); + } + for tag in &entry.tags { + println!(" T: {tag}"); + } + println!(" L: {}", entry.link_priority); + } + + let attr_dir = Path::new("/sys").join(devpath.trim_start_matches('/')); + let attr_dir = attr_dir.join("power"); + if attr_dir.exists() { + for attr in &["modalias", "driver", "vendor", "device", "class"] { + if let Some(val) = read_sysfs_attr(devpath, attr) { + println!(" A: {attr}={val}"); + } + } + } + + Ok(()) +} + +fn query_name(devpath: &str) -> Result<(), String> { + let name = devpath.trim_start_matches('/').split('/').next_back().unwrap_or("?"); + println!("{name}"); + Ok(()) +} + +fn query_symlink(devpath: &str, subsystem: &str, major: u32, minor: u32) -> Result<(), String> { + if let Some(entry) = udev_db::read_db(subsystem, major, minor, devpath) { + for link in &entry.symlinks { + println!("{link}"); + } + } + Ok(()) +} + +fn query_property(devpath: &str, subsystem: &str, major: u32, minor: u32) -> Result<(), String> { + if let Some(entry) = udev_db::read_db(subsystem, major, minor, devpath) { + for (k, v) in &entry.properties { + println!("{k}={v}"); + } + } + Ok(()) +} + +fn query_value(devpath: &str, subsystem: &str, major: u32, minor: u32) -> Result<(), String> { + if let Some(entry) = udev_db::read_db(subsystem, major, minor, devpath) { + for (k, v) in &entry.properties { + println!("{k}={v}"); + } + } + Ok(()) +} + +fn resolve_devpath_from_name(name: &str) -> Result { + let trimmed = name.trim_start_matches('/'); + let sysfs_path = Path::new("/sys").join(trimmed); + if sysfs_path.exists() && sysfs_path.join("uevent").exists() { + return Ok(format!("/{trimmed}")); + } + + let dev_name = name.trim_start_matches("/dev/"); + for class_dir in &["/sys/block", "/sys/class"] { + let candidate = Path::new(class_dir).join(dev_name); + if candidate.exists() + && let Ok(target) = fs::read_link(&candidate) { + let devpath = target + .to_string_lossy() + .trim_start_matches("../../") + .to_string(); + return Ok(format!("/{devpath}")); + } + } + + Err(format!("cannot resolve device name: {name}")) +} + +fn detect_subsystem(devpath: &str) -> String { + let path = Path::new("/sys") + .join(devpath.trim_start_matches('/')) + .join("subsystem"); + if let Ok(target) = fs::read_link(&path) { + target + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() + } else { + String::new() + } +} + +fn read_uevent_attr(devpath: &str, key: &str) -> Option { + let content = read_sysfs_file(devpath, "uevent")?; + for line in content.lines() { + if let Some(rest) = line.strip_prefix(&format!("{key}=")) { + return Some(rest.to_string()); + } + } + None +} + +fn read_sysfs_file(devpath: &str, filename: &str) -> Option { + let path = Path::new("/sys") + .join(devpath.trim_start_matches('/')) + .join(filename); + fs::read_to_string(&path).ok().map(|s| s.trim().to_string()) +} + +fn read_sysfs_attr(devpath: &str, attr: &str) -> Option { + read_sysfs_file(devpath, attr) +} diff --git a/udevadm/src/main.rs b/udevadm/src/main.rs new file mode 100644 index 0000000..01a29d6 --- /dev/null +++ b/udevadm/src/main.rs @@ -0,0 +1,61 @@ +//! udevadm — device event manager CLI. +//! +//! Compatible with systemd-udevd's `udevadm` command-line interface. + +mod coldplug; +mod control; +mod hwdb; +mod info; +mod monitor; +mod settle; +mod test; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "udevadm", version, about = "device event manager CLI")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Query device information from sysfs and the udev database + Info(info::InfoArgs), + /// Trigger uevents for all devices (coldplug) + Trigger, + /// Wait for the event queue to drain + Settle(settle::SettleArgs), + /// Send control commands to the daemon + Control(control::ControlArgs), + /// Listen for kernel uevents + Monitor(monitor::MonitorArgs), + /// Simulate rule evaluation for a device + Test(test::TestArgs), + /// Query the hardware database + Hwdb(hwdb::HwdbArgs), +} + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")) + .format_timestamp_millis() + .init(); + + let cli = Cli::parse(); + + let result = match cli.command { + Command::Info(args) => info::run(args), + Command::Trigger => coldplug::coldplug().map_err(|e| e.to_string()), + Command::Settle(args) => settle::run(args), + Command::Control(args) => control::run(args), + Command::Monitor(args) => monitor::run(args), + Command::Test(args) => test::run(args), + Command::Hwdb(args) => hwdb::run(args), + }; + + if let Err(e) = result { + eprintln!("error: {e}"); + std::process::exit(1); + } +} diff --git a/udevadm/src/monitor.rs b/udevadm/src/monitor.rs new file mode 100644 index 0000000..2b25226 --- /dev/null +++ b/udevadm/src/monitor.rs @@ -0,0 +1,73 @@ +//! `udevadm monitor` — listen for kernel uevents and print them. + +use clap::Args; +use udev_core::uevent::{Connection, UeventError}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Args, Debug)] +pub struct MonitorArgs { + /// Print event properties + #[arg(long, short = 'p')] + pub property: bool, + + /// Filter by subsystem (may be repeated) + #[arg(long = "subsystem-match", value_name = "SUBSYS", action = clap::ArgAction::Append)] + pub subsystem_match: Vec, +} + +/// Run `udevadm monitor`. +pub fn run(args: MonitorArgs) -> Result<(), String> { + let subsystem_filter = if args.subsystem_match.is_empty() { + None + } else { + Some(args.subsystem_match) + }; + + eprintln!("monitor will print received uevents"); + + let mut conn = Connection::new().map_err(|e| format!("cannot open uevent connection: {e}"))?; + + for result in conn.blocking_iter() { + let uevent = match result { + Ok(uev) => uev, + Err(UeventError::SocketCreate(_)) => { + return Err("uevent socket error".to_string()); + } + Err(e) => { + eprintln!("receive error: {e}"); + continue; + } + }; + + if let Some(ref filters) = subsystem_filter { + let subsys = uevent.subsystem().unwrap_or(""); + if !filters.iter().any(|f| subsys.starts_with(f)) { + continue; + } + } + + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs_f64()) + .unwrap_or(0.0); + + let action = uevent.action().unwrap_or("?"); + let devpath = uevent.devpath().unwrap_or("?"); + let subsys = uevent.subsystem().unwrap_or("?"); + + println!("KERNEL[{ts:.6}] {action} {devpath} ({subsys})"); + + if args.property { + let mut keys: Vec<&String> = uevent.properties.keys().collect(); + keys.sort(); + for key in keys { + if let Some(val) = uevent.properties.get(key) { + println!(" {key}={val}"); + } + } + println!(); + } + } + + Ok(()) +} diff --git a/udevadm/src/settle.rs b/udevadm/src/settle.rs new file mode 100644 index 0000000..6cf1129 --- /dev/null +++ b/udevadm/src/settle.rs @@ -0,0 +1,74 @@ +//! `udevadm settle` — wait for the event queue to drain. + +use clap::Args; +use std::fs; +use std::path::Path; +use std::time::{Duration, Instant}; + +#[derive(Args, Debug)] +pub struct SettleArgs { + /// Maximum time to wait in seconds (default: 120) + #[arg(long)] + pub timeout: Option, +} + +/// Run `udevadm settle`. +pub fn run(args: SettleArgs) -> Result<(), String> { + let timeout = Duration::from_secs(args.timeout.unwrap_or(120)); + + eprintln!("waiting for event queue to drain..."); + + let start = Instant::now(); + + loop { + if start.elapsed() > timeout { + return Err("timeout reached while waiting for event queue".to_string()); + } + + match check_seqnum() { + Ok(true) => { + eprintln!("event queue is empty"); + return Ok(()); + } + Ok(false) => { + std::thread::sleep(Duration::from_millis(200)); + } + Err(e) => { + eprintln!("warning: cannot check seqnum: {e}"); + std::thread::sleep(Duration::from_secs(1)); + } + } + } +} + +fn check_seqnum() -> Result { + let kernel_seqnum_path = Path::new("/sys/kernel/uevent_seqnum"); + let kernel_seqnum = fs::read_to_string(kernel_seqnum_path) + .map_err(|e| format!("cannot read uevent_seqnum: {e}"))? + .trim() + .parse::() + .map_err(|e| format!("invalid seqnum: {e}"))?; + + let data_dir = Path::new("/run/udev/data"); + let seqnum_path = data_dir.join("seqnum"); + + if seqnum_path.exists() { + let daemon_seqnum = fs::read_to_string(&seqnum_path) + .map_err(|e| format!("cannot read seqnum: {e}"))? + .trim() + .parse::() + .map_err(|e| format!("invalid daemon seqnum: {e}"))?; + + Ok(daemon_seqnum >= kernel_seqnum) + } else { + let before = kernel_seqnum; + std::thread::sleep(Duration::from_millis(100)); + let after = fs::read_to_string(kernel_seqnum_path) + .map_err(|e| format!("cannot read uevent_seqnum: {e}"))? + .trim() + .parse::() + .map_err(|e| format!("invalid seqnum: {e}"))?; + + Ok(before == after && before > 0) + } +} diff --git a/udevadm/src/test.rs b/udevadm/src/test.rs new file mode 100644 index 0000000..bea5feb --- /dev/null +++ b/udevadm/src/test.rs @@ -0,0 +1,117 @@ +//! `udevadm test` — simulate rule evaluation for a device. + +use clap::Args; +use udev_core::config::Config; +use udev_core::rules::{RuleEngine, SubstContext}; +use udev_core::uevent::Uevent; +use std::collections::HashMap; + +#[derive(Args, Debug)] +pub struct TestArgs { + /// uevent action (default: add) + #[arg(long, value_name = "ACTION")] + pub action: Option, + + /// sysfs device path + pub devpath: String, +} + +/// Run `udevadm test`. +pub fn run(args: TestArgs) -> Result<(), String> { + let action = args.action.unwrap_or_else(|| "add".to_string()); + let devpath = args.devpath; + + eprintln!("Loading configuration..."); + let cfg = Config::load(); + eprintln!(" {} rule files loaded", cfg.rules.len()); + + let engine = RuleEngine::new(&cfg.rules, &cfg.hwdb); + eprintln!(" {} rules loaded", engine.rules.len()); + + eprintln!(); + eprintln!("device: {devpath}"); + eprintln!("action: {action}"); + + let mut properties = HashMap::new(); + properties.insert("ACTION".to_string(), action.clone()); + properties.insert("DEVPATH".to_string(), devpath.clone()); + + let sysfs_base = format!("/sys{devpath}"); + let uevent_path = format!("{sysfs_base}/uevent"); + if let Ok(content) = std::fs::read_to_string(&uevent_path) { + for line in content.lines() { + if let Some(eq) = line.find('=') { + let key = line[..eq].to_string(); + let val = line[eq + 1..].to_string(); + if key != "DEVPATH" { + properties.insert(key, val); + } + } + } + } + + let uevent = Uevent::from_map(&properties).ok_or_else(|| "failed to build uevent".to_string())?; + + eprintln!(); + eprintln!("uevent properties:"); + let mut keys: Vec<&String> = uevent.properties.keys().collect(); + keys.sort(); + for key in keys { + if let Some(val) = uevent.properties.get(key) { + eprintln!(" {key}={val}"); + } + } + + eprintln!(); + eprintln!("Evaluating rules..."); + let mut ctx = SubstContext::from_uevent(&uevent); + + let result = engine.evaluate(&uevent, &mut ctx); + + match result { + Some(r) => { + eprintln!(); + eprintln!("result:"); + if let Some(ref node) = r.devnode { + eprintln!(" NAME: {node:?}"); + } else { + eprintln!(" NAME: (none -- default derived)"); + } + if let Some(mode) = r.mode { + eprintln!(" MODE: {mode:o}"); + } + if let Some(uid) = r.uid { + eprintln!(" OWNER: uid={uid}"); + } + if let Some(gid) = r.gid { + eprintln!(" GROUP: gid={gid}"); + } + for link in &r.symlinks { + eprintln!(" SYMLINK: {link}"); + } + for tag in &r.tags { + eprintln!(" TAG: {tag}"); + } + for (prog, args) in &r.run_commands { + eprintln!(" RUN: {prog} {args:?}"); + } + if r.db_persist { + eprintln!(" OPTIONS: db_persist"); + } + eprintln!(); + eprintln!("Environment after evaluation:"); + let mut env_keys: Vec<&String> = ctx.env.keys().collect(); + env_keys.sort(); + for key in env_keys { + if let Some(val) = ctx.env.get(key) { + eprintln!(" {key}={val}"); + } + } + } + None => { + eprintln!(" (no rule matched)"); + } + } + + Ok(()) +}