1use crate::metrics::{DiskMetrics, DiskMountMetrics, DiskType};
2use std::collections::HashMap;
3use std::ffi::CString;
4use std::time::Instant;
5
6type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
7
8const SECTOR_BYTES: u64 = 512;
9
10fn sysfs_read(path: &str) -> Option<String> {
15 std::fs::read_to_string(path)
16 .ok()
17 .map(|s| s.trim().to_string())
18 .filter(|s| !s.is_empty())
19}
20
21fn block_attr(device: &str, attr: &str) -> Option<String> {
22 sysfs_read(&format!("/sys/block/{}/{}", device, attr))
23}
24
25#[derive(Clone)]
30struct DeviceInfo {
31 model: Option<String>,
32 vendor: Option<String>,
33 serial: Option<String>,
34 device_type: Option<DiskType>,
35 capacity_bytes: Option<u64>,
36 sector_size: u32,
39}
40
41fn read_device_info(device: &str) -> DeviceInfo {
42 let model = block_attr(device, "device/model");
43 let vendor = block_attr(device, "device/vendor");
44 let serial = block_attr(device, "device/serial").or_else(|| block_attr(device, "device/wwid"));
45
46 let device_type = if device.starts_with("nvme") {
47 Some(DiskType::Nvme)
48 } else {
49 match block_attr(device, "queue/rotational").as_deref() {
50 Some("0") => Some(DiskType::Ssd),
51 Some("1") => Some(DiskType::Hdd),
52 _ => None,
53 }
54 };
55
56 let capacity_bytes = block_attr(device, "size")
59 .and_then(|s| s.parse::<u64>().ok())
60 .map(|sectors| sectors * SECTOR_BYTES);
61
62 let sector_size = block_attr(device, "queue/hw_sector_size")
66 .and_then(|s| s.parse::<u32>().ok())
67 .filter(|&v| v >= 512)
68 .unwrap_or(u32::try_from(SECTOR_BYTES).unwrap_or(512));
69
70 DeviceInfo {
71 model,
72 vendor,
73 serial,
74 device_type,
75 capacity_bytes,
76 sector_size,
77 }
78}
79
80fn discover_devices() -> HashMap<String, DeviceInfo> {
83 let Ok(entries) = std::fs::read_dir("/sys/block") else {
84 return HashMap::new();
85 };
86 entries
87 .flatten()
88 .filter_map(|e| {
89 let name = e.file_name().to_string_lossy().to_string();
90 if name.starts_with("loop") || name.starts_with("ram") {
91 return None;
92 }
93 let info = read_device_info(&name);
94 Some((name, info))
95 })
96 .collect()
97}
98
99fn device_source_prefixes(device_name: &str) -> Vec<String> {
120 let primary = format!("/dev/{}", device_name);
121 let mut prefixes = vec![primary.clone()];
122
123 if device_name.starts_with("dm-") {
124 if let Some(name) = sysfs_read(&format!("/sys/block/{}/dm/name", device_name)) {
125 prefixes.push(format!("/dev/mapper/{}", name));
126 }
127 }
128
129 if dev_root_is_on_device(device_name, &primary) {
130 prefixes.push("/dev/root".to_string());
131 }
132
133 prefixes
134}
135
136fn dev_root_is_on_device(device_name: &str, primary: &str) -> bool {
152 if let Ok(target) = std::fs::read_link("/dev/root") {
154 let t = target.to_string_lossy();
155 let resolved = if t.starts_with('/') {
156 t.to_string()
157 } else {
158 format!("/dev/{}", t)
159 };
160 return resolved.starts_with(primary);
161 }
162
163 let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").unwrap_or_default();
165 let Some(root_devnum) = mountinfo.lines().find_map(|line| {
166 let mut f = line.splitn(6, ' ');
169 f.next()?; f.next()?; let devnum = f.next()?.to_string(); f.next()?; let mpt = f.next()?; if mpt != "/" {
175 return None;
176 }
177 let sep = line.find(" - ")?;
179 let source = line[sep + 3..].split_whitespace().nth(1)?;
180 if source == "/dev/root" {
181 Some(devnum)
182 } else {
183 None
184 }
185 }) else {
186 return false;
187 };
188
189 let sys_base = format!("/sys/block/{}", device_name);
191 let Ok(entries) = std::fs::read_dir(&sys_base) else {
192 return false;
193 };
194 entries.flatten().any(|e| {
195 let pname = e.file_name().to_string_lossy().to_string();
196 pname.starts_with(device_name)
197 && sysfs_read(&format!("{}/{}/dev", sys_base, pname))
198 .map_or(false, |dev| dev == root_devnum)
199 })
200}
201
202fn statvfs_space(path: &str) -> Option<(u64, u64, u64)> {
203 let cpath = CString::new(path).ok()?;
204 unsafe {
205 let mut buf: libc::statvfs = std::mem::zeroed();
206 if libc::statvfs(cpath.as_ptr(), &mut buf) != 0 {
207 return None;
208 }
209 let bs = if buf.f_frsize > 0 {
211 buf.f_frsize as u64
212 } else {
213 buf.f_bsize as u64
214 };
215 let total = buf.f_blocks * bs;
216 let avail = buf.f_bavail * bs;
217 let used = total.saturating_sub(buf.f_bfree * bs);
218 Some((total, used, avail))
219 }
220}
221
222fn mounts_for_device(device_name: &str) -> Vec<DiskMountMetrics> {
235 let content = match std::fs::read_to_string("/proc/mounts") {
236 Ok(c) => c,
237 Err(_) => return vec![],
238 };
239 let prefixes = device_source_prefixes(device_name);
240 let mut seen_sources: std::collections::HashSet<String> = std::collections::HashSet::new();
241 let mut result = Vec::new();
242
243 for line in content.lines() {
244 if !prefixes.iter().any(|p| line.starts_with(p.as_str())) {
245 continue;
246 }
247 let mut parts = line.split_whitespace();
248 let (Some(source), Some(mount_point), Some(filesystem)) =
249 (parts.next(), parts.next(), parts.next())
250 else {
251 continue;
252 };
253
254 if mount_point.starts_with("/proc")
256 || mount_point.starts_with("/sys")
257 || mount_point.starts_with("/dev")
258 || mount_point.starts_with("/run")
259 {
260 continue;
261 }
262
263 if !seen_sources.insert(source.to_string()) {
266 continue;
267 }
268
269 let Some((total, used, avail)) = statvfs_space(mount_point) else {
270 continue;
271 };
272
273 if total == 0 {
275 continue;
276 }
277
278 let used_pct = used as f64 / total as f64 * 100.0;
279 result.push(DiskMountMetrics {
280 mount_point: mount_point.to_string(),
281 filesystem: filesystem.to_string(),
282 total_bytes: total,
283 used_bytes: used,
284 available_bytes: avail,
285 used_pct,
286 });
287 }
288 result
289}
290
291fn collect_zfs_spaces(timeout: std::time::Duration) -> Vec<(String, DiskMountMetrics)> {
307 if !std::path::Path::new("/proc/spl/kstat/zfs").exists() {
311 return vec![];
312 }
313
314 let (tx, rx) = std::sync::mpsc::channel();
317 std::thread::spawn(move || {
318 let _ = tx.send(
319 std::process::Command::new("zpool")
320 .args(["list", "-Hp", "-o", "name,size,allocated,free"])
321 .output(),
322 );
323 });
324 let out = match rx.recv_timeout(timeout) {
325 Ok(Ok(o)) if o.status.success() => o,
326 _ => return vec![],
327 };
328 let stdout = match std::str::from_utf8(&out.stdout) {
329 Ok(s) => s,
330 Err(_) => return vec![],
331 };
332
333 let mount_map = zfs_pool_mount_map();
334 let mut result = Vec::new();
335
336 for line in stdout.lines() {
337 let mut parts = line.split('\t');
338 let (Some(name), Some(size), Some(allocated), Some(free)) =
339 (parts.next(), parts.next(), parts.next(), parts.next())
340 else {
341 continue;
342 };
343 let total: u64 = match size.parse() {
344 Ok(v) => v,
345 Err(_) => continue,
346 };
347 if total == 0 {
348 continue;
349 }
350 let used: u64 = allocated.parse().unwrap_or(0);
351 let avail: u64 = free.parse().unwrap_or(0);
352 let mount_point = mount_map.get(name).cloned().unwrap_or_default();
353 result.push((
354 name.to_string(),
355 DiskMountMetrics {
356 mount_point,
357 filesystem: "zfs".to_string(),
358 total_bytes: total,
359 used_bytes: used,
360 available_bytes: avail,
361 used_pct: used as f64 / total as f64 * 100.0,
362 },
363 ));
364 }
365 result
366}
367
368fn zfs_pool_mount_map() -> HashMap<String, String> {
372 let content = std::fs::read_to_string("/proc/mounts").unwrap_or_default();
373 let mut map: HashMap<String, (usize, String)> = HashMap::new();
374 for line in content.lines() {
375 let mut parts = line.split_whitespace();
376 let (Some(source), Some(mount_point), Some(fs_type)) =
377 (parts.next(), parts.next(), parts.next())
378 else {
379 continue;
380 };
381 if fs_type != "zfs" {
382 continue;
383 }
384 let pool_name = source.split('/').next().unwrap_or(source).to_string();
385 let depth = source.split('/').count();
386 let entry = map.entry(pool_name).or_insert((usize::MAX, String::new()));
387 if depth < entry.0 {
388 *entry = (depth, mount_point.to_string());
389 }
390 }
391 map.into_iter().map(|(k, (_, v))| (k, v)).collect()
392}
393
394struct Snapshot {
399 instant: Instant,
400 sectors_read: HashMap<String, u64>,
401 sectors_written: HashMap<String, u64>,
402}
403
404pub struct DiskCollector {
405 device_cache: HashMap<String, DeviceInfo>,
407 prev: Option<Snapshot>,
408 zfs_timeout: std::time::Duration,
412}
413
414impl DiskCollector {
415 pub fn new(interval: std::time::Duration) -> Self {
416 Self {
417 device_cache: discover_devices(),
418 prev: None,
419 zfs_timeout: interval / 2,
420 }
421 }
422
423 pub fn collect(&mut self) -> Result<Vec<DiskMetrics>> {
424 let diskstats = procfs::diskstats()?;
425 let now = Instant::now();
426
427 let block_set: std::collections::HashSet<String> = std::fs::read_dir("/sys/block")
432 .map(|dir| {
433 dir.flatten()
434 .filter_map(|e| {
435 let name = e.file_name().to_string_lossy().to_string();
436 if name.starts_with("loop") || name.starts_with("ram") {
437 None
438 } else {
439 Some(name)
440 }
441 })
442 .collect()
443 })
444 .unwrap_or_default();
445
446 let devs: Vec<_> = diskstats
447 .iter()
448 .filter(|d| block_set.contains(&d.name))
449 .collect();
450
451 let sectors_read: HashMap<String, u64> = devs
452 .iter()
453 .map(|d| (d.name.clone(), d.sectors_read))
454 .collect();
455 let sectors_written: HashMap<String, u64> = devs
456 .iter()
457 .map(|d| (d.name.clone(), d.sectors_written))
458 .collect();
459
460 let mut metrics: Vec<DiskMetrics> = devs
461 .iter()
462 .map(|d| {
463 let info = self.device_cache.get(&d.name);
464
465 let sector_size: u32 = info
466 .map_or(u32::try_from(SECTOR_BYTES).unwrap_or(512), |i| {
467 i.sector_size
468 });
469 let sector_size_f64 = f64::from(sector_size);
470 let sector_size_u64 = u64::from(sector_size);
471
472 let (read_bps, write_bps) = match &self.prev {
473 None => (0.0, 0.0),
474 Some(prev) => {
475 let secs = (now - prev.instant).as_secs_f64().max(0.001);
476 let sr = sectors_read[&d.name];
477 let sw = sectors_written[&d.name];
478 let psr = prev.sectors_read.get(&d.name).copied().unwrap_or(sr);
479 let psw = prev.sectors_written.get(&d.name).copied().unwrap_or(sw);
480 (
482 sr.saturating_sub(psr) as f64 * sector_size_f64 / secs,
483 sw.saturating_sub(psw) as f64 * sector_size_f64 / secs,
484 )
485 }
486 };
487
488 DiskMetrics {
489 device: d.name.clone(),
490 model: info.and_then(|i| i.model.clone()),
491 vendor: info.and_then(|i| i.vendor.clone()),
492 serial: info.and_then(|i| i.serial.clone()),
493 device_type: info.and_then(|i| i.device_type.clone()),
494 capacity_bytes: info.and_then(|i| i.capacity_bytes),
495 mounts: mounts_for_device(&d.name),
496 read_bytes_per_sec: read_bps,
497 write_bytes_per_sec: write_bps,
498 read_bytes_total: sectors_read[&d.name] * sector_size_u64,
499 write_bytes_total: sectors_written[&d.name] * sector_size_u64,
500 }
501 })
502 .collect();
503
504 for (pool_name, mount) in collect_zfs_spaces(self.zfs_timeout) {
508 let total = mount.total_bytes;
509 metrics.push(DiskMetrics {
510 device: format!("zfs:{}", pool_name),
511 model: None,
512 vendor: None,
513 serial: None,
514 device_type: None,
515 capacity_bytes: Some(total),
516 mounts: vec![mount],
517 read_bytes_per_sec: 0.0,
522 write_bytes_per_sec: 0.0,
523 read_bytes_total: 0,
524 write_bytes_total: 0,
525 });
526 }
527
528 metrics.sort_by(|a, b| a.device.cmp(&b.device));
529 self.prev = Some(Snapshot {
530 instant: now,
531 sectors_read,
532 sectors_written,
533 });
534 Ok(metrics)
535 }
536}
537
538#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
550 fn test_sector_size_4k_gives_8x_bytes() {
551 let sector_delta: u64 = 1000;
552 let sector_size_512: u32 = 512;
553 let sector_size_4096: u32 = 4096;
554
555 let bytes_512 = sector_delta * u64::from(sector_size_512);
556 let bytes_4096 = sector_delta * u64::from(sector_size_4096);
557
558 assert_eq!(
559 bytes_4096,
560 bytes_512 * 8,
561 "4K sector should produce 8x the bytes of 512-byte sector"
562 );
563 }
564
565 #[test]
568 fn test_sector_size_fallback_is_512() {
569 let info = read_device_info("__nonexistent_device__");
570 assert_eq!(info.sector_size, 512);
571 }
572
573 #[test]
575 fn test_disk_first_collect_rates_zero() {
576 let mut collector = DiskCollector::new(std::time::Duration::from_secs(1));
577 let metrics = collector.collect().expect("first collect() should succeed");
578 metrics.iter().for_each(|d| {
579 assert_eq!(
580 d.read_bytes_per_sec, 0.0,
581 "read_bytes_per_sec must be 0.0 on first collect for {}",
582 d.device
583 );
584 assert_eq!(
585 d.write_bytes_per_sec, 0.0,
586 "write_bytes_per_sec must be 0.0 on first collect for {}",
587 d.device
588 );
589 });
590 }
591
592 #[test]
594 fn test_disk_second_collect_rates_nonneg() {
595 let mut collector = DiskCollector::new(std::time::Duration::from_secs(1));
596 let _ = collector.collect().expect("first collect() failed");
597 let metrics = collector.collect().expect("second collect() failed");
598 metrics.iter().for_each(|d| {
599 assert!(
600 d.read_bytes_per_sec >= 0.0,
601 "read_bytes_per_sec must be >= 0.0 for {}",
602 d.device
603 );
604 assert!(
605 d.write_bytes_per_sec >= 0.0,
606 "write_bytes_per_sec must be >= 0.0 for {}",
607 d.device
608 );
609 });
610 }
611
612 #[test]
614 fn test_disk_results_sorted_by_device() {
615 let mut collector = DiskCollector::new(std::time::Duration::from_secs(1));
616 let metrics = collector.collect().expect("collect() failed");
617 let names: Vec<&str> = metrics.iter().map(|d| d.device.as_str()).collect();
618 let mut sorted = names.clone();
619 sorted.sort();
620 assert_eq!(names, sorted, "disk metrics must be sorted by device name");
621 }
622
623 #[test]
625 fn test_disk_totals_nondecreasing() {
626 let mut collector = DiskCollector::new(std::time::Duration::from_secs(1));
627 let first = collector.collect().expect("first collect() failed");
628 let second = collector.collect().expect("second collect() failed");
629 let first_map: std::collections::HashMap<&str, (u64, u64)> = first
630 .iter()
631 .map(|d| (d.device.as_str(), (d.read_bytes_total, d.write_bytes_total)))
632 .collect();
633 second.iter().for_each(|d| {
634 if let Some(&(prev_r, prev_w)) = first_map.get(d.device.as_str()) {
635 assert!(
636 d.read_bytes_total >= prev_r,
637 "read_bytes_total decreased for {}: {} < {}",
638 d.device,
639 d.read_bytes_total,
640 prev_r
641 );
642 assert!(
643 d.write_bytes_total >= prev_w,
644 "write_bytes_total decreased for {}: {} < {}",
645 d.device,
646 d.write_bytes_total,
647 prev_w
648 );
649 }
650 });
651 }
652
653 #[test]
656 fn test_read_device_info_nonexistent_all_none() {
657 let info = read_device_info("__nonexistent__");
658 assert!(
659 info.model.is_none(),
660 "model must be None for missing device"
661 );
662 assert!(
663 info.vendor.is_none(),
664 "vendor must be None for missing device"
665 );
666 assert!(
667 info.serial.is_none(),
668 "serial must be None for missing device"
669 );
670 assert!(
671 info.device_type.is_none(),
672 "device_type must be None for missing device"
673 );
674 assert!(
675 info.capacity_bytes.is_none(),
676 "capacity_bytes must be None for missing device"
677 );
678 }
679}