1use std::fs::{self, File};
22use std::os::fd::AsRawFd;
23use std::path::{Path, PathBuf};
24
25use super::mounts::MountEntry;
26use super::{SysRoots, read_trimmed, read_u64, sorted_entries};
27use crate::snapshot::{BtrfsAllocation, BtrfsDeviceStats, BtrfsFilesystem, BtrfsScrub};
28
29pub const BTRFS_PROGS_STATUS_DIR: &str = "/var/lib/btrfs";
31
32const ALLOCATION_KINDS: &[&str] = &["data", "metadata", "system"];
34
35pub fn read_filesystems(roots: &SysRoots, mounts: &[MountEntry]) -> Vec<BtrfsFilesystem> {
38 let base = roots.sys.join("fs/btrfs");
39 sorted_entries(&base)
40 .into_iter()
41 .filter(|entry| entry != "features")
43 .map(|fsid| read_filesystem(&base.join(&fsid), fsid, mounts))
44 .collect()
45}
46
47fn read_filesystem(dir: &Path, fsid: String, mounts: &[MountEntry]) -> BtrfsFilesystem {
48 let devices = sorted_entries(&dir.join("devices"));
49 let mut device_stats: Vec<BtrfsDeviceStats> = sorted_entries(&dir.join("devinfo"))
50 .into_iter()
51 .filter_map(|devid| read_device_stats(&dir.join("devinfo").join(&devid), &devid))
52 .collect();
53 let allocations = ALLOCATION_KINDS
54 .iter()
55 .flat_map(|kind| read_allocations(&dir.join("allocation").join(kind), kind))
56 .collect();
57
58 let mut mount_points: Vec<String> = mounts
60 .iter()
61 .filter(|m| m.fs_type == "btrfs" && devices.contains(&device_basename(&m.device)))
62 .map(|m| m.mount_point.clone())
63 .collect();
64 mount_points.sort_by_key(|m| (m.len(), m.clone()));
65 mount_points.dedup();
66
67 if let Some(mount) = mount_points.first().and_then(|m| File::open(m).ok()) {
68 apply_dev_info(&mut device_stats, |devid| dev_info(&mount, devid));
69 }
70
71 BtrfsFilesystem {
72 label: read_trimmed(&dir.join("label")).unwrap_or_default(),
73 fsid,
74 mount_points,
75 devices,
76 device_stats,
77 allocations,
78 scrub: None,
79 }
80}
81
82pub fn read_scrub(dirs: &[PathBuf], fsid: &str) -> Option<BtrfsScrub> {
85 let name = format!("scrub.status.{fsid}");
86 dirs.iter()
87 .find_map(|dir| fs::read_to_string(dir.join(&name)).ok())
88 .and_then(|text| parse_scrub_status(&text))
89}
90
91pub(crate) fn parse_scrub_status(text: &str) -> Option<BtrfsScrub> {
101 let mut scrub = BtrfsScrub::default();
102 let mut started: Option<u64> = None;
103 let (mut all_finished, mut any_canceled, mut devices) = (true, false, 0);
104 for line in text.lines().filter(|l| l.contains('|')) {
105 devices += 1;
106 let mut finished = false;
107 for pair in line.split('|').skip(1) {
109 let Some((key, value)) = pair.split_once(':') else {
110 continue;
111 };
112 let Ok(value) = value.trim().parse::<u64>() else {
113 continue;
114 };
115 match key {
116 "data_bytes_scrubbed" | "tree_bytes_scrubbed" => scrub.bytes_scrubbed += value,
117 "read_errors" | "csum_errors" | "verify_errors" | "super_errors" => {
118 scrub.errors += value
119 }
120 "corrected_errors" => scrub.corrected += value,
121 "uncorrectable_errors" => scrub.uncorrectable += value,
122 "t_start" if value > 0 => started = Some(started.map_or(value, |s| s.min(value))),
124 "duration" => scrub.duration_secs = scrub.duration_secs.max(value),
125 "canceled" => any_canceled |= value != 0,
126 "finished" => finished = value != 0,
127 _ => {}
128 }
129 }
130 all_finished &= finished;
131 }
132 let started = started.filter(|_| devices > 0)?;
133 scrub.started_at_ms = started.saturating_mul(1000);
134 scrub.state = if any_canceled {
135 "canceled"
136 } else if all_finished {
137 "finished"
138 } else {
139 "unfinished"
140 }
141 .to_owned();
142 Some(scrub)
143}
144
145#[derive(Debug, Clone, PartialEq)]
147pub(crate) struct DevInfo {
148 pub path: String,
150 pub total_bytes: u64,
151 pub used_bytes: u64,
152}
153
154pub(crate) fn apply_dev_info(
157 stats: &mut [BtrfsDeviceStats],
158 mut lookup: impl FnMut(u64) -> Option<DevInfo>,
159) {
160 for device in stats {
161 let Some(info) = lookup(device.devid) else {
162 continue;
163 };
164 if let Some(name) = Path::new(&info.path).file_name() {
166 device.name = name.to_string_lossy().into_owned();
167 }
168 device.size_bytes = Some(info.total_bytes);
169 device.used_bytes = Some(info.used_bytes);
170 }
171}
172
173const BTRFS_IOC_DEV_INFO: u32 = 0xD000_941E;
176const DEV_INFO_SIZE: usize = 4096;
179const DEV_INFO_USED: usize = 24;
180const DEV_INFO_TOTAL: usize = 32;
181const DEV_INFO_PATH: usize = 3072;
182
183fn dev_info(mount: &File, devid: u64) -> Option<DevInfo> {
185 let mut args = [0u8; DEV_INFO_SIZE];
186 args[..8].copy_from_slice(&devid.to_ne_bytes());
187 let result = unsafe {
190 libc::ioctl(
191 mount.as_raw_fd(),
192 BTRFS_IOC_DEV_INFO as _,
193 args.as_mut_ptr(),
194 )
195 };
196 if result != 0 {
197 return None;
198 }
199 let u64_at = |at: usize| u64::from_ne_bytes(args[at..at + 8].try_into().unwrap());
200 let path = &args[DEV_INFO_PATH..];
201 let end = path.iter().position(|&b| b == 0).unwrap_or(path.len());
202 Some(DevInfo {
203 path: String::from_utf8_lossy(&path[..end]).into_owned(),
204 total_bytes: u64_at(DEV_INFO_TOTAL),
205 used_bytes: u64_at(DEV_INFO_USED),
206 })
207}
208
209fn read_device_stats(dir: &Path, devid: &str) -> Option<BtrfsDeviceStats> {
211 let mut stats = BtrfsDeviceStats {
212 devid: devid.parse().ok()?,
213 missing: read_u64(&dir.join("missing")).is_some_and(|v| v != 0),
214 ..Default::default()
215 };
216 let text = fs::read_to_string(dir.join("error_stats")).unwrap_or_default();
218 for line in text.lines() {
219 let Some((name, value)) = line.split_once(' ') else {
220 continue;
221 };
222 let Ok(value) = value.trim().parse::<u64>() else {
223 continue;
224 };
225 match name {
226 "write_errs" => stats.write_errs = value,
227 "read_errs" => stats.read_errs = value,
228 "flush_errs" => stats.flush_errs = value,
229 "corruption_errs" => stats.corruption_errs = value,
230 "generation_errs" => stats.generation_errs = value,
231 _ => {}
232 }
233 }
234 Some(stats)
235}
236
237fn read_allocations(dir: &Path, kind: &str) -> Vec<BtrfsAllocation> {
240 sorted_entries(dir)
241 .into_iter()
242 .filter(|entry| dir.join(entry).is_dir())
243 .filter_map(|profile| {
244 let profile_dir = dir.join(&profile);
245 Some(BtrfsAllocation {
246 kind: kind.to_owned(),
247 total_bytes: read_u64(&profile_dir.join("total_bytes"))?,
248 used_bytes: read_u64(&profile_dir.join("used_bytes"))?,
249 profile,
250 })
251 })
252 .collect()
253}
254
255fn device_basename(device: &str) -> String {
258 let path = Path::new(device);
259 let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
261 resolved
262 .file_name()
263 .map(|n| n.to_string_lossy().into_owned())
264 .unwrap_or_default()
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::linux::testutil::write;
271
272 const FSID: &str = "acb63bf0-ab89-4069-bb64-d3352482c507";
273
274 fn fixture(root: &Path) -> SysRoots {
275 let base = format!("sys/fs/btrfs/{FSID}");
276 write(root, "sys/fs/btrfs/features/raid1c34", "0\n");
277 write(root, &format!("{base}/label"), "\n");
278 for dev in ["nvme0n1", "nvme1n1p1", "nvme2n1"] {
279 write(root, &format!("{base}/devices/{dev}/size"), "0\n");
280 }
281 for devid in 1..=3 {
282 let corruption = if devid == 2 { 3 } else { 0 };
283 write(
284 root,
285 &format!("{base}/devinfo/{devid}/error_stats"),
286 &format!(
287 "write_errs 0\nread_errs 0\nflush_errs 0\ncorruption_errs {corruption}\ngeneration_errs 0\n"
288 ),
289 );
290 write(root, &format!("{base}/devinfo/{devid}/missing"), "0\n");
291 }
292 write(
293 root,
294 &format!("{base}/allocation/data/raid0/total_bytes"),
295 "1000\n",
296 );
297 write(
298 root,
299 &format!("{base}/allocation/data/raid0/used_bytes"),
300 "900\n",
301 );
302 write(
304 root,
305 &format!("{base}/allocation/data/total_bytes"),
306 "1000\n",
307 );
308 write(
309 root,
310 &format!("{base}/allocation/metadata/raid1c3/total_bytes"),
311 "30\n",
312 );
313 write(
314 root,
315 &format!("{base}/allocation/metadata/raid1c3/used_bytes"),
316 "10\n",
317 );
318 SysRoots {
319 sys: root.join("sys"),
320 proc: root.join("proc"),
321 }
322 }
323
324 fn mount(device: &str, mount_point: &str, fs_type: &str) -> MountEntry {
325 MountEntry {
326 device: device.to_owned(),
327 mount_point: mount_point.to_owned(),
328 fs_type: fs_type.to_owned(),
329 }
330 }
331
332 #[test]
333 fn parses_scrub_status_across_devices() {
334 let text = "scrub status:1\n\
335acb6:1|data_extents_scrubbed:10|data_bytes_scrubbed:1000|tree_bytes_scrubbed:24|read_errors:0|csum_errors:2|verify_errors:0|super_errors:0|uncorrectable_errors:0|corrected_errors:2|last_physical:0|t_start:1792700000|t_resumed:0|duration:3600|canceled:0|finished:1\n\
336acb6:2|data_bytes_scrubbed:2000|csum_errors:0|t_start:1792700005|duration:4000|canceled:0|finished:1\n";
337 let scrub = parse_scrub_status(text).unwrap();
338 assert_eq!(scrub.started_at_ms, 1_792_700_000_000);
339 assert_eq!(scrub.duration_secs, 4000);
340 assert_eq!(scrub.state, "finished");
341 assert_eq!(scrub.bytes_scrubbed, 3024);
342 assert_eq!(scrub.errors, 2);
343 assert_eq!(scrub.corrected, 2);
344 }
345
346 #[test]
347 fn scrub_state_and_missing_status() {
348 let running = "scrub status:1\nfs:1|t_start:100|finished:0|canceled:0\nfs:2|t_start:100|finished:1|canceled:0\n";
349 assert_eq!(parse_scrub_status(running).unwrap().state, "unfinished");
350 let canceled = "scrub status:1\nfs:1|t_start:100|finished:0|canceled:1\n";
351 assert_eq!(parse_scrub_status(canceled).unwrap().state, "canceled");
352 assert!(parse_scrub_status("scrub status:1\n").is_none());
353
354 let tmp = tempfile::tempdir().unwrap();
356 let dir = tmp.path().join("copies");
357 fs::create_dir_all(&dir).unwrap();
358 fs::write(dir.join("scrub.status.abc"), canceled).unwrap();
359 let dirs = vec![tmp.path().join("absent"), dir];
360 assert_eq!(read_scrub(&dirs, "abc").unwrap().state, "canceled");
361 assert!(read_scrub(&dirs, "other").is_none());
362 }
363
364 #[test]
365 fn reads_devices_errors_and_profiles() {
366 let tmp = tempfile::tempdir().unwrap();
367 let roots = fixture(tmp.path());
368 let mounts = vec![
369 mount("/dev/nvme1n1p1", "/home", "btrfs"),
370 mount("/dev/nvme1n1p1", "/", "btrfs"),
371 mount("/dev/sda1", "/boot", "ext4"),
372 ];
373
374 let filesystems = read_filesystems(&roots, &mounts);
375 assert_eq!(filesystems.len(), 1);
376 let fs = &filesystems[0];
377 assert_eq!(fs.fsid, FSID);
378 assert_eq!(fs.devices, vec!["nvme0n1", "nvme1n1p1", "nvme2n1"]);
379 assert_eq!(fs.mount_points, vec!["/", "/home"]);
380
381 assert_eq!(fs.device_stats.len(), 3);
382 assert_eq!(fs.device_stats[1].devid, 2);
383 assert_eq!(fs.device_stats[1].corruption_errs, 3);
384 assert!(fs.has_problems());
385
386 let profiles: Vec<(&str, &str)> = fs
387 .allocations
388 .iter()
389 .map(|a| (a.kind.as_str(), a.profile.as_str()))
390 .collect();
391 assert_eq!(profiles, vec![("data", "raid0"), ("metadata", "raid1c3")]);
392 assert_eq!(fs.allocations[0].used_bytes, 900);
393 }
394
395 #[test]
396 fn device_names_come_from_dev_info() {
397 let mut stats: Vec<BtrfsDeviceStats> = (1..=3)
398 .map(|devid| BtrfsDeviceStats {
399 devid,
400 ..Default::default()
401 })
402 .collect();
403 apply_dev_info(&mut stats, |devid| match devid {
404 1 => Some(DevInfo {
405 path: "/dev/nvme2n1p1".to_owned(),
406 total_bytes: 2000,
407 used_bytes: 500,
408 }),
409 2 => Some(DevInfo {
411 path: String::new(),
412 total_bytes: 2000,
413 used_bytes: 0,
414 }),
415 _ => None,
416 });
417 assert_eq!(stats[0].label(), "nvme2n1p1");
418 assert_eq!(stats[0].size_bytes, Some(2000));
419 assert_eq!(stats[1].label(), "devid 2");
420 assert_eq!(stats[2].label(), "devid 3");
421 assert_eq!(stats[2].size_bytes, None);
422 }
423
424 #[test]
427 fn dev_info_on_a_real_btrfs_root() {
428 let mounts = fs::read_to_string("/proc/self/mounts").unwrap_or_default();
429 if !mounts
430 .lines()
431 .any(|l| l.split_whitespace().nth(1) == Some("/") && l.contains(" btrfs "))
432 {
433 return;
434 }
435 let root = File::open("/").unwrap();
436 let info = dev_info(&root, 1).expect("devid 1 of the root filesystem");
437 assert!(info.path.starts_with("/dev/"), "{info:?}");
438 assert!(
439 info.total_bytes > 0 && info.used_bytes <= info.total_bytes,
440 "{info:?}"
441 );
442 }
443}