Skip to main content

linsight_core/
hardware.rs

1// SPDX-FileCopyrightText: 2026 VisorCraft LLC
2// SPDX-License-Identifier: GPL-3.0-only
3
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7use crate::SensorId;
8
9#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
10pub enum HardwareCategory {
11    Gpu,
12    Storage,
13    Network,
14    Cpu,
15    Other,
16}
17
18impl HardwareCategory {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Self::Gpu => "gpu",
22            Self::Storage => "storage",
23            Self::Network => "network",
24            Self::Cpu => "cpu",
25            Self::Other => "other",
26        }
27    }
28}
29
30#[derive(Debug, Error, PartialEq)]
31pub enum KeyError {
32    #[error("hardware device key is empty")]
33    Empty,
34    #[error("hardware device key too long ({0} bytes, max 140)")]
35    TooLong(usize),
36    #[error(
37        "hardware device key missing scheme prefix (expected one of: pci, nvml, nvme, net, cpu, plugin)"
38    )]
39    NoScheme,
40    #[error("hardware device key has unknown scheme '{0}'")]
41    UnknownScheme(String),
42    #[error("hardware device key payload empty after '{0}:'")]
43    EmptyPayload(String),
44    #[error("hardware device key contains invalid character {0:?}")]
45    BadChar(char),
46}
47
48const ALLOWED_SCHEMES: &[&str] = &[
49    "pci", "nvml", "nvme", "net", "cpu", "system", "block", "hwmon", "fs", "amdgpu", "zram",
50    "i915", "plugin",
51];
52const KEY_MAX_LEN: usize = 140;
53
54#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
55pub struct HardwareDeviceKey(String);
56
57impl HardwareDeviceKey {
58    pub fn try_new(s: impl Into<String>) -> Result<Self, KeyError> {
59        let s = s.into();
60        if s.is_empty() {
61            return Err(KeyError::Empty);
62        }
63        if s.len() > KEY_MAX_LEN {
64            return Err(KeyError::TooLong(s.len()));
65        }
66        let (scheme, rest) = s.split_once(':').ok_or(KeyError::NoScheme)?;
67        if !ALLOWED_SCHEMES.contains(&scheme) {
68            return Err(KeyError::UnknownScheme(scheme.to_owned()));
69        }
70        if rest.is_empty() {
71            return Err(KeyError::EmptyPayload(scheme.to_owned()));
72        }
73        for c in rest.chars() {
74            let ok = c.is_ascii_alphanumeric() || matches!(c, '_' | ':' | '.' | '-');
75            if !ok {
76                return Err(KeyError::BadChar(c));
77            }
78        }
79        Ok(Self(s))
80    }
81
82    pub fn as_str(&self) -> &str {
83        &self.0
84    }
85
86    pub fn scheme(&self) -> &str {
87        self.0.split(':').next().unwrap_or("")
88    }
89}
90
91impl std::fmt::Display for HardwareDeviceKey {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.write_str(&self.0)
94    }
95}
96
97#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
98pub struct HardwareDevice {
99    pub key: HardwareDeviceKey,
100    pub category: HardwareCategory,
101    pub model: String,
102    pub vendor: Option<String>,
103    pub location: Option<String>,
104    pub plugin_id: String,
105    pub plugin_device_id: String,
106    pub sensor_ids: Vec<SensorId>,
107}
108
109#[derive(Debug, Error, PartialEq)]
110pub enum NicknameError {
111    #[error("nickname too long ({0} chars, max 64)")]
112    TooLong(usize),
113    #[error("nickname contains control char {0:?}")]
114    ControlChar(char),
115}
116
117pub const NICKNAME_MAX_CHARS: usize = 64;
118
119/// Validate and normalize a user-supplied nickname.
120/// * `Ok(None)` if empty after trimming (delete intent),
121/// * `Ok(Some(s))` with the trimmed value if valid,
122/// * `Err(_)` if rejected (length / control char).
123pub fn validate_nickname(input: &str) -> Result<Option<String>, NicknameError> {
124    let trimmed = input.trim();
125    if trimmed.is_empty() {
126        return Ok(None);
127    }
128    let char_count = trimmed.chars().count();
129    if char_count > NICKNAME_MAX_CHARS {
130        return Err(NicknameError::TooLong(char_count));
131    }
132    if let Some(c) = trimmed.chars().find(|c| c.is_control()) {
133        return Err(NicknameError::ControlChar(c));
134    }
135    Ok(Some(trimmed.to_owned()))
136}
137
138/// Parse a sysfs PCI ID file (`/sys/.../device/vendor` or `device/device`).
139/// These files contain a single line like `0x8086\n`. Returns `None` on
140/// any read or parse failure — non-PCI interfaces (`lo`, `veth*`, bond
141/// masters, WireGuard) have no such file and that's not an error.
142///
143/// Lives here so the xe and net plugins (and any future PCI-backed
144/// hardware plugin) read PCI IDs through one helper instead of three
145/// near-duplicate inline parses.
146pub fn parse_sysfs_pci_id(path: &std::path::Path) -> Option<u16> {
147    let raw = std::fs::read_to_string(path).ok()?;
148    let trimmed = raw.trim().strip_prefix("0x").unwrap_or(raw.trim());
149    u16::from_str_radix(trimmed, 16).ok()
150}
151
152/// Compute the display label for a single device given the full
153/// device list + nickname map. The rule:
154///
155///   1. If a nickname is set for this key, the label IS the nickname.
156///   2. Otherwise, if another un-nicknamed device shares this model
157///      string, append a location-derived suffix ("(payload after the
158///      key's scheme prefix)") so the user can tell them apart.
159///   3. Otherwise, the bare `model` string.
160///
161/// Both the daemon (`SensorInfo.device_label` decoration) and the GUI
162/// (`HardwareModel`'s page-title rendering) call this so they can
163/// never disagree on what a device is named.
164pub fn compute_device_label(
165    target: &HardwareDevice,
166    all_devices: &[HardwareDevice],
167    nicknames: &std::collections::HashMap<String, String>,
168) -> String {
169    if let Some(nick) = nicknames.get(target.key.as_str()) {
170        return nick.clone();
171    }
172    let needs_disambig = all_devices.iter().any(|d| {
173        d.key != target.key && d.model == target.model && !nicknames.contains_key(d.key.as_str())
174    });
175    if needs_disambig {
176        let suffix =
177            target.key.as_str().split_once(':').map(|(_, p)| p).unwrap_or(target.key.as_str());
178        format!("{} ({})", target.model, suffix)
179    } else {
180        target.model.clone()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn category_as_str_is_stable() {
190        assert_eq!(HardwareCategory::Gpu.as_str(), "gpu");
191        assert_eq!(HardwareCategory::Storage.as_str(), "storage");
192        assert_eq!(HardwareCategory::Network.as_str(), "network");
193        assert_eq!(HardwareCategory::Cpu.as_str(), "cpu");
194        assert_eq!(HardwareCategory::Other.as_str(), "other");
195    }
196
197    #[test]
198    fn key_accepts_valid_schemes() {
199        for s in [
200            "pci:0000:06:00.0",
201            "nvml:uuid:gpu-abc123-456",
202            "nvme:eui.001b448b41234567",
203            "nvme:nvme0",
204            "net:enp4s0",
205            "net:wg0",
206            "cpu:0",
207            "plugin:com.visorcraft.linsight.echo:demo",
208        ] {
209            assert!(HardwareDeviceKey::try_new(s).is_ok(), "should accept: {s}");
210        }
211    }
212
213    #[test]
214    fn key_rejects_invalid_forms() {
215        let long = "x".repeat(200);
216        for s in [
217            "",
218            "pci",
219            "pci:",
220            "FOO:bar",
221            "pci:0000:06:00.0 ",
222            "PCI:0000:06:00.0",
223            "pci:0000:06:00.0/extra",
224            long.as_str(),
225        ] {
226            assert!(HardwareDeviceKey::try_new(s).is_err(), "should reject: {s}");
227        }
228    }
229
230    #[test]
231    fn key_accepts_uppercase_in_payload() {
232        // Real-world keys from sysfs and tmpfs frequently contain uppercase
233        // letters (hwmon names like BAT0, ACAD; mount path tokens like
234        // S3Drive...). The validator must accept them in the payload while
235        // continuing to require a lowercase scheme prefix (enforced by the
236        // ALLOWED_SCHEMES table).
237        for s in
238            ["hwmon:BAT0", "hwmon:ACAD", "fs:tmp_.mount_S3DrivweY1lv", "nvml:uuid:GPU-abc123-456"]
239        {
240            assert!(HardwareDeviceKey::try_new(s).is_ok(), "should accept: {s}");
241        }
242    }
243
244    #[test]
245    fn key_scheme_extraction() {
246        let k = HardwareDeviceKey::try_new("pci:0000:06:00.0").unwrap();
247        assert_eq!(k.scheme(), "pci");
248        let k = HardwareDeviceKey::try_new("nvml:uuid:gpu-abc").unwrap();
249        assert_eq!(k.scheme(), "nvml");
250    }
251
252    #[test]
253    fn nickname_validation_accepts_normal_text() {
254        assert_eq!(validate_nickname("Battlemage"), Ok(Some("Battlemage".into())));
255        assert_eq!(validate_nickname("OS drive"), Ok(Some("OS drive".into())));
256        assert_eq!(validate_nickname("RTX 5080 \u{1F525}"), Ok(Some("RTX 5080 \u{1F525}".into())));
257    }
258
259    #[test]
260    fn nickname_empty_means_delete() {
261        assert_eq!(validate_nickname(""), Ok(None));
262        assert_eq!(validate_nickname("   "), Ok(None));
263        assert_eq!(validate_nickname("\t\t"), Ok(None));
264    }
265
266    #[test]
267    fn nickname_trims_outer_whitespace() {
268        assert_eq!(validate_nickname("  hello  "), Ok(Some("hello".into())));
269    }
270
271    #[test]
272    fn nickname_rejects_control_chars() {
273        assert!(matches!(validate_nickname("ab\nc"), Err(NicknameError::ControlChar('\n'))));
274        assert!(matches!(validate_nickname("ab\x00c"), Err(NicknameError::ControlChar('\x00'))));
275        assert!(matches!(validate_nickname("ab\x7fc"), Err(NicknameError::ControlChar('\x7f'))));
276    }
277
278    #[test]
279    fn nickname_rejects_too_long() {
280        let s = "x".repeat(65);
281        assert!(matches!(validate_nickname(&s), Err(NicknameError::TooLong(65))));
282        let s = "x".repeat(64);
283        assert!(validate_nickname(&s).is_ok());
284    }
285
286    #[test]
287    fn device_serde_round_trip() {
288        let dev = HardwareDevice {
289            key: HardwareDeviceKey::try_new("pci:0000:06:00.0").unwrap(),
290            category: HardwareCategory::Gpu,
291            model: "Intel Arc B-series".into(),
292            vendor: Some("Intel Corporation".into()),
293            location: Some("PCI 0000:06:00.0".into()),
294            plugin_id: "com.visorcraft.linsight.xe".into(),
295            plugin_device_id: "gpu0".into(),
296            sensor_ids: vec![SensorId::new("xe.gpu0.util")],
297        };
298        let s = serde_json::to_string(&dev).unwrap();
299        let back: HardwareDevice = serde_json::from_str(&s).unwrap();
300        assert_eq!(back, dev);
301    }
302}