Skip to main content

rigtop_core/
smart.rs

1//! SMART health from smartctl JSON files.
2//!
3//! Reading SMART data needs root, and rigtop never runs as root nor loosens
4//! device permissions. Instead a root systemd timer (see `contrib/collect/`)
5//! runs `smartctl -j -a` for every drive and writes one JSON file per device
6//! into a directory (default `/var/lib/rigtop/smart`). This module only parses
7//! those files.
8//!
9//! Files are re-parsed only when their modification time changes, so polling
10//! every second costs a `stat` per drive.
11
12use std::collections::HashMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::time::SystemTime;
16
17use serde_json::Value;
18
19use crate::snapshot::SmartDevice;
20
21/// NVMe "data units" are 1000 blocks of 512 bytes.
22const NVME_DATA_UNIT_BYTES: u64 = 512_000;
23
24/// smartctl exit status bits meaning the data could not be read at all
25/// (bit 0: command line error, bit 1: device open failed).
26const EXIT_FATAL_MASK: u64 = 0b11;
27
28/// Reads and caches SMART result files from a directory.
29#[derive(Debug)]
30pub struct SmartReader {
31    dir: PathBuf,
32    /// Parsed result and the modification time it was parsed at, per file.
33    cache: HashMap<PathBuf, (SystemTime, SmartDevice)>,
34}
35
36impl SmartReader {
37    /// Creates a reader for `dir`. The directory may not exist (SMART collection
38    /// not installed); then no devices are reported.
39    pub fn new(dir: impl Into<PathBuf>) -> Self {
40        Self {
41            dir: dir.into(),
42            cache: HashMap::new(),
43        }
44    }
45
46    /// Returns the SMART state of every `*.json` file in the directory, sorted by device.
47    pub fn read(&mut self) -> Vec<SmartDevice> {
48        let mut paths: Vec<PathBuf> = match fs::read_dir(&self.dir) {
49            Ok(entries) => entries
50                .filter_map(|e| e.ok())
51                .map(|e| e.path())
52                .filter(|p| p.extension().is_some_and(|ext| ext == "json"))
53                .collect(),
54            Err(_) => Vec::new(),
55        };
56        paths.sort();
57
58        // Forget files that disappeared (drive removed).
59        self.cache.retain(|path, _| paths.contains(path));
60
61        paths
62            .into_iter()
63            .filter_map(|path| {
64                let mtime = fs::metadata(&path).and_then(|m| m.modified()).ok()?;
65                if let Some((cached_mtime, device)) = self.cache.get(&path)
66                    && *cached_mtime == mtime
67                {
68                    return Some(device.clone());
69                }
70                let device = parse_file(&path);
71                self.cache.insert(path, (mtime, device.clone()));
72                Some(device)
73            })
74            .collect()
75    }
76}
77
78/// Parses one result file. Unreadable or malformed files become an entry with
79/// `error` set, so that a broken collector is visible rather than silent.
80fn parse_file(path: &Path) -> SmartDevice {
81    // The collector names files after the device ("nvme0.json"), with a
82    // suffix for drives behind RAID controllers ("0_megaraid_3.json") whose
83    // device node is shared, so the file name is the better identifier.
84    let name = path
85        .file_stem()
86        .map(|s| s.to_string_lossy().into_owned())
87        .unwrap_or_default();
88    let parsed = fs::read_to_string(path)
89        .map_err(|e| e.to_string())
90        .and_then(|text| serde_json::from_str::<Value>(&text).map_err(|e| e.to_string()));
91    match parsed {
92        Ok(json) => parse_json(&json, &name),
93        Err(err) => SmartDevice {
94            device: name,
95            error: Some(format!("cannot read SMART result: {err}")),
96            ..Default::default()
97        },
98    }
99}
100
101/// Extracts the fields rigtop shows from `smartctl -j -a` output.
102///
103/// `device` names the drive; when empty, the last component of the JSON's
104/// `device.name` (e.g. `/dev/nvme0` -> `nvme0`) is used.
105pub fn parse_json(json: &Value, device: &str) -> SmartDevice {
106    let u64_at = |pointer: &str| json.pointer(pointer).and_then(Value::as_u64);
107    let u32_at = |pointer: &str| u64_at(pointer).and_then(|v| u32::try_from(v).ok());
108    let str_at = |pointer: &str| json.pointer(pointer).and_then(Value::as_str);
109
110    let device = if device.is_empty() {
111        str_at("/device/name")
112            .and_then(|name| name.rsplit('/').next())
113            .unwrap_or_default()
114            .to_owned()
115    } else {
116        device.to_owned()
117    };
118
119    let exit_status = u64_at("/smartctl/exit_status").unwrap_or(0);
120    let error = (exit_status & EXIT_FATAL_MASK != 0).then(|| {
121        // Prefer smartctl's own message (e.g. "... Permission denied").
122        json.pointer("/smartctl/messages")
123            .and_then(Value::as_array)
124            .and_then(|messages| {
125                messages
126                    .iter()
127                    .find(|m| m.get("severity").and_then(Value::as_str) == Some("error"))
128                    .and_then(|m| m.get("string").and_then(Value::as_str))
129            })
130            .map(str::to_owned)
131            .unwrap_or_else(|| format!("smartctl failed (exit status {exit_status})"))
132    });
133
134    // ATA attributes are a table of {id, raw: {value}} entries.
135    let ata_raw = |id: u64| {
136        json.pointer("/ata_smart_attributes/table")
137            .and_then(Value::as_array)?
138            .iter()
139            .find(|attr| attr.get("id").and_then(Value::as_u64) == Some(id))?
140            .pointer("/raw/value")
141            .and_then(Value::as_u64)
142    };
143
144    let nvme = "/nvme_smart_health_information_log";
145    SmartDevice {
146        device,
147        protocol: str_at("/device/protocol").unwrap_or_default().to_owned(),
148        model: str_at("/model_name").unwrap_or_default().trim().to_owned(),
149        passed: json
150            .pointer("/smart_status/passed")
151            .and_then(Value::as_bool),
152        temperature_c: u64_at("/temperature/current").map(|t| t as f32),
153        power_on_hours: u64_at("/power_on_time/hours"),
154        percentage_used: u32_at(&format!("{nvme}/percentage_used")),
155        available_spare_percent: u32_at(&format!("{nvme}/available_spare")),
156        available_spare_threshold_percent: u32_at(&format!("{nvme}/available_spare_threshold")),
157        critical_warning: u32_at(&format!("{nvme}/critical_warning")),
158        media_errors: u64_at(&format!("{nvme}/media_errors")),
159        reallocated_sectors: ata_raw(5),
160        pending_sectors: ata_raw(197),
161        offline_uncorrectable: ata_raw(198),
162        data_written_bytes: u64_at(&format!("{nvme}/data_units_written"))
163            .map(|units| units.saturating_mul(NVME_DATA_UNIT_BYTES)),
164        collected_at_ms: u64_at("/local_time/time_t").map(|t| t.saturating_mul(1000)),
165        error,
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    /// Trimmed `smartctl -j -a /dev/nvme0` output (smartctl 7.5).
174    const NVME_JSON: &str = r#"{
175      "json_format_version": [1, 0],
176      "smartctl": {"version": [7, 5], "exit_status": 0},
177      "local_time": {"time_t": 1792775225, "asctime": "Sat Oct 24 02:07:05 2026 JST"},
178      "device": {"name": "/dev/nvme0", "info_name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe"},
179      "model_name": "Samsung SSD 990 PRO 2TB",
180      "serial_number": "S000000000000",
181      "smart_status": {"passed": true, "nvme": {"value": 0}},
182      "nvme_smart_health_information_log": {
183        "critical_warning": 0, "temperature": 45, "available_spare": 100,
184        "available_spare_threshold": 10, "percentage_used": 2,
185        "data_units_read": 12345678, "data_units_written": 2000000,
186        "power_on_hours": 3210, "unsafe_shutdowns": 12, "media_errors": 0,
187        "num_err_log_entries": 0
188      },
189      "temperature": {"current": 45},
190      "power_on_time": {"hours": 3210}
191    }"#;
192
193    /// Trimmed `smartctl -j -a /dev/sda` output for a SATA disk with problems.
194    const ATA_JSON: &str = r#"{
195      "smartctl": {"exit_status": 64},
196      "local_time": {"time_t": 1792775225},
197      "device": {"name": "/dev/sda", "type": "sat", "protocol": "ATA"},
198      "model_name": "WDC WD40EFRX-68N32N0",
199      "smart_status": {"passed": true},
200      "ata_smart_attributes": {"revision": 16, "table": [
201        {"id": 5, "name": "Reallocated_Sector_Ct", "raw": {"value": 8, "string": "8"}},
202        {"id": 9, "name": "Power_On_Hours", "raw": {"value": 40000, "string": "40000"}},
203        {"id": 197, "name": "Current_Pending_Sector", "raw": {"value": 0, "string": "0"}},
204        {"id": 198, "name": "Offline_Uncorrectable", "raw": {"value": 0, "string": "0"}}
205      ]},
206      "temperature": {"current": 38},
207      "power_on_time": {"hours": 40000}
208    }"#;
209
210    /// smartctl output when run without root.
211    const DENIED_JSON: &str = r#"{
212      "smartctl": {"messages": [{"string": "Smartctl open device: /dev/nvme0 failed: Permission denied", "severity": "error"}], "exit_status": 2},
213      "local_time": {"time_t": 1792775225},
214      "device": {"name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe"}
215    }"#;
216
217    fn parse(text: &str) -> SmartDevice {
218        parse_json(&serde_json::from_str(text).unwrap(), "")
219    }
220
221    #[test]
222    fn parses_nvme_health() {
223        let d = parse(NVME_JSON);
224        assert_eq!(d.device, "nvme0");
225        assert_eq!(d.protocol, "NVMe");
226        assert_eq!(d.model, "Samsung SSD 990 PRO 2TB");
227        assert_eq!(d.passed, Some(true));
228        assert_eq!(d.temperature_c, Some(45.0));
229        assert_eq!(d.percentage_used, Some(2));
230        assert_eq!(d.available_spare_percent, Some(100));
231        assert_eq!(d.media_errors, Some(0));
232        assert_eq!(d.data_written_bytes, Some(2_000_000 * 512_000));
233        assert_eq!(d.collected_at_ms, Some(1_792_775_225_000));
234        assert_eq!(d.reallocated_sectors, None);
235        assert!(d.error.is_none());
236        assert!(!d.needs_attention());
237    }
238
239    #[test]
240    fn parses_ata_attributes() {
241        let d = parse(ATA_JSON);
242        assert_eq!(d.device, "sda");
243        assert_eq!(d.reallocated_sectors, Some(8));
244        assert_eq!(d.pending_sectors, Some(0));
245        assert_eq!(d.power_on_hours, Some(40000));
246        // Exit status 64 (error log entries) is a warning, not a read failure.
247        assert!(d.error.is_none());
248        assert!(d.needs_attention());
249    }
250
251    #[test]
252    fn open_failure_is_reported_as_error() {
253        let d = parse(DENIED_JSON);
254        assert_eq!(d.passed, None);
255        assert!(d.error.as_deref().unwrap().contains("Permission denied"));
256        assert!(d.needs_attention());
257    }
258
259    #[test]
260    fn reader_caches_by_mtime_and_reports_broken_files() {
261        let tmp = tempfile::tempdir().unwrap();
262        let dir = tmp.path();
263        fs::write(dir.join("nvme0.json"), NVME_JSON).unwrap();
264        fs::write(dir.join("sdb.json"), "{ truncated").unwrap();
265        fs::write(dir.join("0_megaraid_3.json"), DENIED_JSON).unwrap();
266        fs::write(dir.join("README"), "not a result").unwrap();
267
268        let mut reader = SmartReader::new(dir);
269        let devices = reader.read();
270        let names: Vec<&str> = devices.iter().map(|d| d.device.as_str()).collect();
271        // Devices are named after their files, not the shared device node.
272        assert_eq!(names, vec!["0_megaraid_3", "nvme0", "sdb"]);
273        // A malformed file still shows up, carrying the error.
274        assert!(devices[2].error.is_some());
275
276        // Removing a file drops it from the result and the cache.
277        fs::remove_file(dir.join("sdb.json")).unwrap();
278        let devices = reader.read();
279        assert_eq!(devices.len(), 2);
280        assert_eq!(reader.cache.len(), 2);
281    }
282
283    #[test]
284    fn missing_directory_yields_nothing() {
285        let tmp = tempfile::tempdir().unwrap();
286        let mut reader = SmartReader::new(tmp.path().join("absent"));
287        assert!(reader.read().is_empty());
288    }
289}