Skip to main content

ryra_core/data/
classify.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::{Error, Result};
4use crate::manifest;
5
6/// Classify top-level children of a service home dir into `(data, ephemeral)`.
7///
8/// Source of truth is `service.manifest` — the per-install render manifest
9/// written by core, listing every file `Step::WriteFile` emitted. A
10/// top-level child is **ephemeral** if it's the manifest itself, the `.env`
11/// file (deliberately excluded from the manifest because it carries
12/// rotated secrets), or if any manifest entry's path equals or lives
13/// inside it. Everything else is **data**: bind-mount dirs the registry
14/// declared (`db-data/`, `storage-data/`), user-dropped files, and runtime
15/// artifacts a container wrote.
16///
17/// When the manifest is absent — a pre-manifest install, or an orphan
18/// left after `--preserve` (where the manifest itself was wiped along
19/// with the rest of the ephemerals) — the home dir contains only data by
20/// construction, so every top-level child is reported as data. That's
21/// the safe default: better to over-preserve than to wipe something we
22/// don't recognise.
23///
24/// Both vecs are sorted by path for deterministic output. All entries
25/// are absolute paths rooted at `home_dir`.
26pub fn classify_home_dir(home_dir: &Path) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
27    let mut data = Vec::new();
28    let mut ephemeral = Vec::new();
29    if !home_dir.exists() {
30        return Ok((data, ephemeral));
31    }
32
33    let manifest_path = home_dir.join(manifest::MANIFEST_FILENAME);
34    let manifest_entries: Vec<PathBuf> = if manifest_path.exists() {
35        let content =
36            std::fs::read_to_string(&manifest_path).map_err(|source| Error::FileRead {
37                path: manifest_path.clone(),
38                source,
39            })?;
40        let (entries, _envs) = manifest::parse(&content)?;
41        entries.into_iter().map(|e| e.path).collect()
42    } else {
43        Vec::new()
44    };
45    let have_manifest = manifest_path.exists();
46
47    let entries = std::fs::read_dir(home_dir).map_err(|source| Error::FileRead {
48        path: home_dir.to_path_buf(),
49        source,
50    })?;
51    for entry in entries {
52        let entry = entry.map_err(|source| Error::FileRead {
53            path: home_dir.to_path_buf(),
54            source,
55        })?;
56        let path = entry.path();
57
58        if !have_manifest {
59            // Orphan or pre-manifest install: nothing in the dir is
60            // recognisably ryra-generated, so preserve all of it.
61            data.push(path);
62            continue;
63        }
64
65        // The manifest itself and `.env` are excluded from the manifest's
66        // own listing by design (chicken-and-egg for the manifest;
67        // rotated secrets for `.env`). Both are ryra-managed and
68        // regenerable, so they're ephemeral.
69        if path == manifest_path || path.file_name().and_then(|n| n.to_str()) == Some(".env") {
70            ephemeral.push(path);
71            continue;
72        }
73
74        // A top-level child is ephemeral if it equals a manifest entry
75        // (file case) or contains one (directory case, e.g. `configs/`
76        // holding `configs/scripts/foo.sh`).
77        let is_ephemeral = manifest_entries
78            .iter()
79            .any(|m| m == &path || m.starts_with(&path));
80        if is_ephemeral {
81            ephemeral.push(path);
82        } else {
83            data.push(path);
84        }
85    }
86    data.sort();
87    ephemeral.sort();
88    Ok((data, ephemeral))
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use crate::manifest::{ManifestEntry, format};
95    use std::fs;
96
97    fn write_manifest(home: &Path, paths: &[&Path]) {
98        let entries: Vec<ManifestEntry> = paths
99            .iter()
100            .map(|p| ManifestEntry {
101                path: p.to_path_buf(),
102                sha256: "0".repeat(64),
103            })
104            .collect();
105        fs::write(
106            home.join(manifest::MANIFEST_FILENAME),
107            format(&entries, &[]),
108        )
109        .unwrap();
110    }
111
112    #[test]
113    fn classifies_manifest_listed_files_as_ephemeral() {
114        let dir = tempfile::tempdir().unwrap();
115        let home = dir.path();
116
117        // Files ryra generated: .container, .network, metadata.toml, configs/.
118        fs::write(home.join("svc.container"), "[Container]").unwrap();
119        fs::write(home.join("svc.network"), "[Network]").unwrap();
120        fs::write(home.join("metadata.toml"), "").unwrap();
121        fs::create_dir(home.join("configs")).unwrap();
122        fs::write(home.join("configs").join("nginx.conf"), "").unwrap();
123        // .env is not in the manifest by design but is still ephemeral.
124        fs::write(home.join(".env"), "FOO=bar").unwrap();
125
126        // User data: bind-mount dirs the registry declared.
127        fs::create_dir(home.join("db-data")).unwrap();
128        fs::create_dir(home.join("storage-data")).unwrap();
129
130        write_manifest(
131            home,
132            &[
133                &home.join("svc.container"),
134                &home.join("svc.network"),
135                &home.join("metadata.toml"),
136                &home.join("configs").join("nginx.conf"),
137            ],
138        );
139
140        let (data, eph) = classify_home_dir(home).unwrap();
141        let eph_names: Vec<String> = eph
142            .iter()
143            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
144            .collect();
145        let data_names: Vec<String> = data
146            .iter()
147            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
148            .collect();
149        assert_eq!(
150            eph_names,
151            vec![
152                ".env".to_string(),
153                "configs".to_string(),
154                "metadata.toml".to_string(),
155                "service.manifest".to_string(),
156                "svc.container".to_string(),
157                "svc.network".to_string(),
158            ]
159        );
160        assert_eq!(
161            data_names,
162            vec!["db-data".to_string(), "storage-data".to_string()]
163        );
164    }
165
166    #[test]
167    fn user_dropped_files_are_preserved_as_data() {
168        // A file the user manually placed in the home dir isn't in the
169        // manifest, so the classifier treats it as data.
170        let dir = tempfile::tempdir().unwrap();
171        let home = dir.path();
172        fs::write(home.join("svc.container"), "").unwrap();
173        fs::write(home.join("my-notes.txt"), "remember to back this up").unwrap();
174        write_manifest(home, &[&home.join("svc.container")]);
175
176        let (data, eph) = classify_home_dir(home).unwrap();
177        let data_names: Vec<String> = data
178            .iter()
179            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
180            .collect();
181        let eph_names: Vec<String> = eph
182            .iter()
183            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
184            .collect();
185        assert_eq!(data_names, vec!["my-notes.txt".to_string()]);
186        assert_eq!(
187            eph_names,
188            vec!["service.manifest".to_string(), "svc.container".to_string()]
189        );
190    }
191
192    #[test]
193    fn missing_manifest_classifies_everything_as_data() {
194        // Pre-manifest install or post-preserve orphan: no manifest, so
195        // everything in the home dir is treated as data (safe default).
196        let dir = tempfile::tempdir().unwrap();
197        let home = dir.path();
198        fs::create_dir(home.join("db-data")).unwrap();
199        fs::write(home.join("leftover.txt"), "").unwrap();
200
201        let (data, eph) = classify_home_dir(home).unwrap();
202        assert!(eph.is_empty());
203        let data_names: Vec<String> = data
204            .iter()
205            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
206            .collect();
207        assert_eq!(
208            data_names,
209            vec!["db-data".to_string(), "leftover.txt".to_string()]
210        );
211    }
212
213    #[test]
214    fn missing_home_dir_returns_empty() {
215        let (data, eph) = classify_home_dir(Path::new("/nonexistent-xyz-123")).unwrap();
216        assert!(data.is_empty());
217        assert!(eph.is_empty());
218    }
219}