Skip to main content

fxcp_core/operations/
container.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4//! Container/device-mapper detection functions.
5
6use std::path::Path;
7use tracing::debug;
8
9// ---------------------------------------------------------------------------
10// Container and storage stack detection
11// ---------------------------------------------------------------------------
12
13/// Container runtime information.
14#[derive(Debug, Clone)]
15pub struct ContainerInfo {
16    /// Whether the process is running inside a container
17    pub in_container: bool,
18    /// Container engine name (e.g. "podman", "docker")
19    pub engine: Option<String>,
20    /// Whether the container is running rootless
21    pub rootless: bool,
22}
23
24/// Device-mapper stack layers detected beneath a filesystem.
25#[derive(Debug, Clone)]
26pub struct DmStackInfo {
27    /// dm-crypt (LUKS) layer present
28    pub has_crypt: bool,
29    /// dm-integrity layer present
30    pub has_integrity: bool,
31    /// dm-cache layer present
32    pub has_cache: bool,
33    /// LVM thin provisioning layer present
34    pub has_thin: bool,
35    /// VDO (Virtual Data Optimizer) layer present
36    pub has_vdo: bool,
37    /// Stratis storage management layer present
38    pub has_stratis: bool,
39    /// LUKS sector size (512 for LUKS1, 4096 for LUKS2)
40    pub crypt_sector_size: u32,
41    /// dm-integrity tag size in bytes
42    pub integrity_tag_size: u32,
43    /// Thin pool data usage percentage
44    pub thin_pool_data_pct: f64,
45    /// Thin pool metadata usage percentage
46    pub thin_pool_meta_pct: f64,
47    /// Number of device-mapper layers in the stack
48    pub stack_depth: u8,
49    /// Physical block size of the base device
50    pub physical_block_size: u32,
51    /// Optimal I/O size reported by the base device
52    pub optimal_io_size: u32,
53    /// Sysfs block device name of the bottom-most device (e.g. "nvme0n1")
54    pub base_device: Option<String>,
55}
56
57impl Default for DmStackInfo {
58    fn default() -> Self {
59        Self {
60            has_crypt: false, has_integrity: false, has_cache: false,
61            has_thin: false, has_vdo: false, has_stratis: false,
62            crypt_sector_size: 0, integrity_tag_size: 0,
63            thin_pool_data_pct: 0.0, thin_pool_meta_pct: 0.0,
64            stack_depth: 0, physical_block_size: 512, optimal_io_size: 0,
65            base_device: None,
66        }
67    }
68}
69
70// ---------------------------------------------------------------------------
71// Container detection
72// ---------------------------------------------------------------------------
73
74pub(crate) fn detect_container() -> ContainerInfo {
75    // podman/toolbx: /run/.containerenv
76    if let Ok(content) = std::fs::read_to_string("/run/.containerenv") {
77        let engine = content.lines()
78            .find(|l| l.starts_with("engine="))
79            .map(|l| l.trim_start_matches("engine=").trim_matches('"').to_string());
80        let rootless = content.contains("rootless=1");
81        return ContainerInfo { in_container: true, engine, rootless };
82    }
83    // docker: /.dockerenv
84    if Path::new("/.dockerenv").exists() {
85        return ContainerInfo { in_container: true, engine: Some("docker".into()), rootless: false };
86    }
87    ContainerInfo { in_container: false, engine: None, rootless: false }
88}
89
90// ---------------------------------------------------------------------------
91// Device-mapper stack probing via /proc/self/mountinfo + sysfs
92// ---------------------------------------------------------------------------
93
94/// Resolve the backing block device for a path via the unified mount API.
95/// Works inside containers where stat().st_dev returns virtual device numbers.
96fn resolve_backing_device(path: &Path) -> Option<String> {
97    let entry = crate::mount_info::find_mount_for_path(path)?;
98    if entry.source.is_empty() || entry.source == "none" || entry.source == "overlay" {
99        return None;
100    }
101    debug!("resolve_backing_device: {:?} -> mount={} source={}", path, entry.mount_point, entry.source);
102    Some(entry.source)
103}
104
105/// Resolve a /dev/mapper/NAME or /dev/dm-N path to the sysfs block device name (e.g. "dm-0").
106fn resolve_sysfs_block_name(device_path: &str) -> Option<String> {
107    let dev_path = Path::new(device_path);
108    // /dev/mapper/NAME -> readlink to /dev/dm-N
109    let resolved = if device_path.starts_with("/dev/mapper/") {
110        std::fs::read_link(dev_path).ok()?
111    } else {
112        dev_path.to_path_buf()
113    };
114    // Extract "dm-0" from "/dev/dm-0"
115    resolved.file_name()?.to_str().map(String::from)
116}
117
118/// Probe the device-mapper stack beneath a filesystem path.
119pub(crate) fn probe_dm_stack(path: &Path) -> Option<DmStackInfo> {
120    let device = resolve_backing_device(path)?;
121    let block_name = resolve_sysfs_block_name(&device)?;
122
123    let mut info = DmStackInfo::default();
124    let mut current_dev = block_name.clone();
125
126    // Walk the dm stack
127    loop {
128        let dm_uuid_path = format!("/sys/block/{}/dm/uuid", current_dev);
129        if let Ok(uuid) = std::fs::read_to_string(&dm_uuid_path) {
130            let uuid = uuid.trim();
131            info.stack_depth += 1;
132
133            if uuid.starts_with("CRYPT-LUKS2-") {
134                info.has_crypt = true;
135                info.crypt_sector_size = 4096;
136            } else if uuid.starts_with("CRYPT-LUKS1-") || uuid.starts_with("CRYPT-") {
137                info.has_crypt = true;
138                info.crypt_sector_size = 512;
139            } else if uuid.starts_with("INTEGRITY-") {
140                info.has_integrity = true;
141                // Read tag size from dm table if available
142                info.integrity_tag_size = 4096;
143            } else if uuid.starts_with("LVM-") {
144                // Check if thin pool by looking for pool target
145                let table_path = format!("/sys/block/{}/dm/name", current_dev);
146                if let Ok(name) = std::fs::read_to_string(&table_path)
147                    && (name.trim().contains("tpool") || name.trim().contains("thin")) {
148                        info.has_thin = true;
149                    }
150            } else if uuid.starts_with("VDO-") {
151                info.has_vdo = true;
152            }
153
154            // Check for Stratis naming
155            let name_path = format!("/sys/block/{}/dm/name", current_dev);
156            if let Ok(name) = std::fs::read_to_string(&name_path)
157                && name.trim().contains("stratis") {
158                    info.has_stratis = true;
159                }
160        }
161
162        // Walk slaves to find underlying device
163        let slaves_path = format!("/sys/block/{}/slaves", current_dev);
164        if let Ok(entries) = std::fs::read_dir(&slaves_path) {
165            let slaves: Vec<String> = entries
166                .filter_map(|e| e.ok())
167                .map(|e| e.file_name().to_string_lossy().into_owned())
168                .collect();
169            if slaves.len() == 1 {
170                current_dev = slaves[0].clone();
171                continue; // Keep walking the stack
172            } else if slaves.is_empty() {
173                // Reached the base device
174                info.base_device = Some(current_dev.clone());
175                break;
176            } else {
177                // Multiple slaves (RAID, multipath)  --  take first, stop recursion
178                info.base_device = Some(slaves[0].clone());
179                break;
180            }
181        } else {
182            // No slaves directory  --  this is a physical device
183            info.base_device = Some(current_dev.clone());
184            break;
185        }
186    }
187
188    // Read queue properties from base device
189    if let Some(ref base) = info.base_device {
190        let pbs_path = format!("/sys/block/{}/queue/physical_block_size", base);
191        if let Ok(val) = std::fs::read_to_string(&pbs_path) {
192            info.physical_block_size = val.trim().parse().unwrap_or(512);
193        }
194        let oio_path = format!("/sys/block/{}/queue/optimal_io_size", base);
195        if let Ok(val) = std::fs::read_to_string(&oio_path) {
196            info.optimal_io_size = val.trim().parse().unwrap_or(0);
197        }
198    }
199
200    Some(info)
201}
202
203#[cfg(test)]
204mod tests {
205    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
206    use super::*;
207
208    #[test]
209    fn test_detect_container_not_in_container() {
210        let info = detect_container();
211        if !Path::new("/run/.containerenv").exists() && !Path::new("/.dockerenv").exists() {
212            assert!(!info.in_container);
213            assert!(info.engine.is_none());
214            assert!(!info.rootless);
215        }
216    }
217
218    #[test]
219    fn test_container_info_clone() {
220        let info = detect_container();
221        let _ = format!("{:?}", info);
222        let cloned = info.clone();
223        assert_eq!(cloned.in_container, info.in_container);
224        assert_eq!(cloned.engine, info.engine);
225        assert_eq!(cloned.rootless, info.rootless);
226    }
227
228    #[test]
229    fn test_resolve_sysfs_block_name_dev_dm() {
230        assert_eq!(resolve_sysfs_block_name("/dev/dm-0"), Some("dm-0".to_string()));
231    }
232
233    #[test]
234    fn test_resolve_sysfs_block_name_dev_sda() {
235        assert_eq!(resolve_sysfs_block_name("/dev/sda1"), Some("sda1".to_string()));
236    }
237
238    #[test]
239    fn test_resolve_sysfs_block_name_dev_nvme() {
240        assert_eq!(resolve_sysfs_block_name("/dev/nvme0n1p1"), Some("nvme0n1p1".to_string()));
241    }
242
243    #[test]
244    fn test_resolve_sysfs_block_name_mapper_returns_none_if_no_symlink() {
245        assert!(resolve_sysfs_block_name("/dev/mapper/nonexistent_device_xyz").is_none());
246    }
247
248    #[test]
249    fn test_dm_stack_info_default() {
250        let info = DmStackInfo::default();
251        assert!(!info.has_crypt);
252        assert!(!info.has_integrity);
253        assert!(!info.has_cache);
254        assert!(!info.has_thin);
255        assert!(!info.has_vdo);
256        assert!(!info.has_stratis);
257        assert_eq!(info.crypt_sector_size, 0);
258        assert_eq!(info.integrity_tag_size, 0);
259        assert_eq!(info.thin_pool_data_pct, 0.0);
260        assert_eq!(info.thin_pool_meta_pct, 0.0);
261        assert_eq!(info.stack_depth, 0);
262        assert_eq!(info.physical_block_size, 512);
263        assert_eq!(info.optimal_io_size, 0);
264        assert!(info.base_device.is_none());
265    }
266}