Skip to main content

ryra_core/data/
volumes.rs

1//! Map podman named volumes to owning services.
2//!
3//! Authoritative source: `.volume` quadlet files in the user's
4//! `~/.config/containers/systemd/` directory. Each `foo.volume` becomes
5//! podman volume `systemd-foo`. Volume ownership is derived from the
6//! filename prefix matching a known service name.
7
8use std::path::{Path, PathBuf};
9
10use crate::error::{Error, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct VolumeRef {
14    /// Podman volume name as it appears in `podman volume ls` (includes
15    /// the `systemd-` prefix quadlet adds).
16    pub name: String,
17    /// Absolute path to the `.volume` quadlet file, if we found one.
18    /// `None` means the volume exists in podman but no quadlet claims
19    /// it — treat as truly orphaned.
20    pub quadlet_file: Option<PathBuf>,
21    /// Service name the volume belongs to, if a match was found.
22    /// `None` means the filename doesn't prefix-match any known service.
23    pub owner: Option<String>,
24}
25
26/// Parse every `*.volume` quadlet file in `quadlet_dir` and compute its
27/// podman name (`systemd-<stem>`) and owning service.
28///
29/// `known_services` is the set of service names from preferences.toml plus any
30/// service dir under `~/.local/share/services/`. Pass both so we match
31/// volumes of orphaned services (home dir exists, preferences.toml entry gone).
32pub fn parse_volume_quadlets(
33    quadlet_dir: &Path,
34    known_services: &[String],
35) -> Result<Vec<VolumeRef>> {
36    let mut out = Vec::new();
37    if !quadlet_dir.is_dir() {
38        return Ok(out);
39    }
40    for entry in std::fs::read_dir(quadlet_dir).map_err(|source| Error::FileRead {
41        path: quadlet_dir.to_path_buf(),
42        source,
43    })? {
44        let entry = entry.map_err(|source| Error::FileRead {
45            path: quadlet_dir.to_path_buf(),
46            source,
47        })?;
48        let path = entry.path();
49        if path.extension().and_then(|e| e.to_str()) != Some("volume") {
50            continue;
51        }
52        let stem = match path.file_stem().and_then(|s| s.to_str()) {
53            Some(s) => s.to_string(),
54            None => continue,
55        };
56        let owner = match_owner(&stem, known_services);
57        out.push(VolumeRef {
58            name: format!("systemd-{stem}"),
59            quadlet_file: Some(path),
60            owner,
61        });
62    }
63    out.sort_by(|a, b| a.name.cmp(&b.name));
64    Ok(out)
65}
66
67/// Return the longest known service name that is a prefix of `stem`
68/// (matching full tokens split by `-`). Handles cases like
69/// `nextcloud-db-data` (owned by `nextcloud`, not `nextcloud-db`).
70pub fn match_owner(stem: &str, known_services: &[String]) -> Option<String> {
71    known_services
72        .iter()
73        .filter(|s| stem == s.as_str() || stem.starts_with(&format!("{s}-")))
74        .max_by_key(|s| s.len())
75        .cloned()
76}
77
78/// Call `podman volume ls --format '{{.Name}}'` and return names.
79/// Returns an empty list if podman is not installed / fails; callers
80/// should treat that as "no volumes to consider" rather than an error.
81pub fn list_podman_volumes() -> Vec<String> {
82    let out = std::process::Command::new("podman")
83        .args(["volume", "ls", "--format", "{{.Name}}"])
84        .output();
85    match out {
86        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
87            .lines()
88            .map(|s| s.trim().to_string())
89            .filter(|s| !s.is_empty())
90            .collect(),
91        _ => Vec::new(),
92    }
93}
94
95/// Given the set of volumes that quadlet files claim + the set that
96/// podman reports, return the union. Entries present in podman but
97/// with no matching quadlet get `quadlet_file: None`.
98pub fn reconcile(quadlet_refs: Vec<VolumeRef>, podman_names: Vec<String>) -> Vec<VolumeRef> {
99    let mut out: Vec<VolumeRef> = quadlet_refs;
100    let seen: std::collections::HashSet<String> = out.iter().map(|r| r.name.clone()).collect();
101    for name in podman_names {
102        if seen.contains(&name) {
103            continue;
104        }
105        // Podman-only entries have no quadlet file and no owner here. The
106        // caller holds the list of known services and is responsible for
107        // attributing ownership via match_owner() after reconcile() returns.
108        out.push(VolumeRef {
109            name,
110            quadlet_file: None,
111            owner: None,
112        });
113    }
114    out.sort_by(|a, b| a.name.cmp(&b.name));
115    out
116}
117
118/// Query podman for a volume's on-disk mountpoint. Returns None if
119/// the volume doesn't exist or podman isn't available.
120pub fn mountpoint_of(volume_name: &str) -> Option<PathBuf> {
121    let out = std::process::Command::new("podman")
122        .args([
123            "volume",
124            "inspect",
125            volume_name,
126            "--format",
127            "{{.Mountpoint}}",
128        ])
129        .output()
130        .ok()?;
131    if !out.status.success() {
132        return None;
133    }
134    let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
135    if s.is_empty() {
136        None
137    } else {
138        Some(PathBuf::from(s))
139    }
140}
141
142/// Compute a volume's on-disk size in bytes.
143///
144/// Rootless podman stores volumes under subuid-owned directories the
145/// host user can't stat — a direct filesystem walk returns `EACCES`.
146/// `podman unshare` enters the user's subuid namespace where the walk
147/// succeeds. `du -sb` is POSIX-portable (every supported distro ships
148/// coreutils).
149///
150/// Returns `None` if podman or `du` isn't available, the volume
151/// doesn't exist, or the output can't be parsed.
152pub fn volume_size_bytes(volume_name: &str) -> Option<u64> {
153    let mp = mountpoint_of(volume_name)?;
154    let out = std::process::Command::new("podman")
155        .args(["unshare", "du", "-sb", "--"])
156        .arg(&mp)
157        .output()
158        .ok()?;
159    if !out.status.success() {
160        return None;
161    }
162    // `du -sb` prints `<bytes>\t<path>\n`
163    let stdout = String::from_utf8_lossy(&out.stdout);
164    stdout.split_whitespace().next()?.parse::<u64>().ok()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use std::fs;
171
172    #[test]
173    fn match_owner_exact_and_prefix() {
174        let known = vec!["nextcloud".into(), "ente".into(), "caddy".into()];
175        assert_eq!(
176            match_owner("nextcloud", &known).as_deref(),
177            Some("nextcloud")
178        );
179        assert_eq!(
180            match_owner("nextcloud-db-data", &known).as_deref(),
181            Some("nextcloud")
182        );
183        assert_eq!(
184            match_owner("ente-minio-data", &known).as_deref(),
185            Some("ente")
186        );
187        assert_eq!(match_owner("unrelated-vol", &known), None);
188    }
189
190    #[test]
191    fn match_owner_longest_wins() {
192        let known = vec!["nextcloud".into(), "nextcloud-db".into()];
193        assert_eq!(
194            match_owner("nextcloud-db-data", &known).as_deref(),
195            Some("nextcloud-db")
196        );
197    }
198
199    #[test]
200    fn match_owner_no_false_prefix() {
201        // "caddyfile" must not match service "caddy"
202        let known = vec!["caddy".into()];
203        assert_eq!(match_owner("caddyfile-data", &known), None);
204    }
205
206    #[test]
207    fn parse_volume_quadlets_reads_volume_files() {
208        let dir = tempfile::tempdir().unwrap();
209        fs::write(dir.path().join("nextcloud-db-data.volume"), "[Volume]").unwrap();
210        fs::write(dir.path().join("ente-minio-data.volume"), "[Volume]").unwrap();
211        fs::write(dir.path().join("nextcloud.container"), "[Container]").unwrap(); // ignored
212
213        let known = vec!["nextcloud".into(), "ente".into()];
214        let vols = parse_volume_quadlets(dir.path(), &known).unwrap();
215        assert_eq!(vols.len(), 2);
216        assert_eq!(vols[0].name, "systemd-ente-minio-data");
217        assert_eq!(vols[0].owner.as_deref(), Some("ente"));
218        assert_eq!(vols[1].name, "systemd-nextcloud-db-data");
219        assert_eq!(vols[1].owner.as_deref(), Some("nextcloud"));
220        assert!(vols[0].quadlet_file.is_some());
221    }
222}
223
224#[cfg(test)]
225mod tests_reconcile {
226    use super::*;
227
228    #[test]
229    fn reconcile_adds_podman_only_volumes() {
230        let quadlet = vec![VolumeRef {
231            name: "systemd-nextcloud-db-data".into(),
232            quadlet_file: Some("/fake/nextcloud-db-data.volume".into()),
233            owner: Some("nextcloud".into()),
234        }];
235        let podman = vec![
236            "systemd-nextcloud-db-data".to_string(), // already known
237            "systemd-ghost-volume".to_string(),      // unknown
238        ];
239        let merged = reconcile(quadlet, podman);
240        assert_eq!(merged.len(), 2);
241        let ghost = merged
242            .iter()
243            .find(|r| r.name == "systemd-ghost-volume")
244            .unwrap();
245        assert!(ghost.quadlet_file.is_none());
246        assert!(ghost.owner.is_none());
247    }
248}