Skip to main content

shine_core/install/
manifest.rs

1use crate::runtime::{FileSystemHost, FileSystemObservationHost};
2use anyhow::{Result, bail};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5
6const MANIFEST_FILE: &str = "app-manifest.toml";
7pub const APP_MANIFEST_SCHEMA_VERSION: u32 = 1;
8
9#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
10pub struct AppManifest {
11    #[serde(default = "legacy_manifest_schema_version")]
12    pub schema_version: u32,
13    #[serde(default)]
14    pub entries: Vec<AppEntry>,
15}
16
17fn legacy_manifest_schema_version() -> u32 {
18    0
19}
20
21impl Default for AppManifest {
22    fn default() -> Self {
23        Self {
24            schema_version: APP_MANIFEST_SCHEMA_VERSION,
25            entries: Vec::new(),
26        }
27    }
28}
29
30#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
31#[serde(tag = "mode", rename_all = "kebab-case")]
32pub enum AppInstallStrategy {
33    #[default]
34    Copy,
35    JsonMerge {
36        managed_keys: Vec<String>,
37    },
38}
39
40impl AppInstallStrategy {
41    pub fn is_copy(&self) -> bool {
42        matches!(self, Self::Copy)
43    }
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
47pub struct AppEntry {
48    pub source: String,
49    pub destination: PathBuf,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub backup: Option<PathBuf>,
52    pub content_hash: u64,
53    #[serde(default, skip_serializing_if = "AppInstallStrategy::is_copy")]
54    pub install_strategy: AppInstallStrategy,
55    /// True when the `template` transform was applied during install.
56    /// Used by config upgrade to skip files that never used env vars.
57    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
58    pub uses_env: bool,
59    /// True when installing/removing this file requires elevated (sudo) permissions.
60    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
61    pub requires_admin: bool,
62}
63
64pub fn hash_content(bytes: &[u8]) -> u64 {
65    // FNV-1a: stable across Rust versions, unlike DefaultHasher
66    const FNV_OFFSET: u64 = 14695981039346656037;
67    const FNV_PRIME: u64 = 1099511628211;
68    bytes.iter().fold(FNV_OFFSET, |hash, &byte| {
69        (hash ^ (byte as u64)).wrapping_mul(FNV_PRIME)
70    })
71}
72
73impl AppManifest {
74    pub async fn load(host: &impl FileSystemObservationHost, shine_dir: &Path) -> Result<Self> {
75        let path = shine_dir.join(MANIFEST_FILE);
76        let mut manifest: Self = match host.read(&path).await {
77            Ok(bytes) => toml::from_slice(&bytes)?,
78            Err(error) if error.is_not_found() => Self::default(),
79            Err(error) => return Err(error.into_anyhow("failed to read app manifest")),
80        };
81        match manifest.schema_version {
82            0 => manifest.schema_version = APP_MANIFEST_SCHEMA_VERSION,
83            APP_MANIFEST_SCHEMA_VERSION => {}
84            version => bail!(
85                "app manifest schema version {version} is newer than this Shine supports ({APP_MANIFEST_SCHEMA_VERSION})"
86            ),
87        }
88        Ok(manifest)
89    }
90
91    pub async fn save(&self, host: &impl FileSystemHost, shine_dir: &Path) -> Result<()> {
92        if self.schema_version != APP_MANIFEST_SCHEMA_VERSION {
93            bail!(
94                "cannot write app manifest schema version {}; expected {APP_MANIFEST_SCHEMA_VERSION}",
95                self.schema_version
96            );
97        }
98        let bytes = toml::to_string_pretty(self)?;
99        host.write_atomic(&shine_dir.join(MANIFEST_FILE), bytes.as_bytes())
100            .await
101            .map_err(|error| error.into_anyhow("failed to write app manifest"))
102    }
103
104    pub fn upsert(&mut self, entry: AppEntry) {
105        self.entries.retain(|existing| {
106            existing.destination != entry.destination && existing.source != entry.source
107        });
108        self.entries.push(entry);
109    }
110
111    pub fn remove_by_dest(&mut self, dest: &Path) -> Option<AppEntry> {
112        if let Some(pos) = self.entries.iter().position(|e| e.destination == dest) {
113            Some(self.entries.remove(pos))
114        } else {
115            None
116        }
117    }
118
119    pub fn find_by_dest(&self, dest: &Path) -> Option<&AppEntry> {
120        self.entries.iter().find(|e| e.destination == dest)
121    }
122
123    pub fn find_by_source(&self, source: &str) -> Option<&AppEntry> {
124        // Current writes replace an entry by source, but older releases could
125        // leave both sides of a relocation behind. Those manifests append the
126        // newer receipt, so it must win until a later mutation rewrites the
127        // manifest through `upsert` and removes the stale receipt.
128        self.entries
129            .iter()
130            .rev()
131            .find(|entry| entry.source == source)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use tokio::fs;
139
140    async fn make_temp_dir() -> PathBuf {
141        let path = std::env::temp_dir().join(format!("shine-manifest-{}", uuid::Uuid::new_v4()));
142        fs::create_dir_all(&path).await.unwrap();
143        path
144    }
145
146    fn sample_entry(dest: &str) -> AppEntry {
147        AppEntry {
148            source: format!(
149                "app/test/{}",
150                Path::new(dest).file_name().unwrap().to_string_lossy()
151            ),
152            destination: PathBuf::from(dest),
153            backup: None,
154            content_hash: 42,
155            install_strategy: AppInstallStrategy::Copy,
156            uses_env: false,
157            requires_admin: false,
158        }
159    }
160
161    #[tokio::test]
162    async fn load_returns_empty_when_missing() {
163        let dir = make_temp_dir().await;
164        let manifest = AppManifest::load(&crate::runtime::RealHost, &dir)
165            .await
166            .unwrap();
167        assert!(manifest.entries.is_empty());
168        fs::remove_dir_all(&dir).await.unwrap();
169    }
170
171    #[tokio::test]
172    async fn save_and_load_roundtrip() {
173        let dir = make_temp_dir().await;
174        let mut manifest = AppManifest::default();
175        manifest.upsert(sample_entry("/tmp/foo.toml"));
176        manifest
177            .save(&crate::runtime::RealHost, &dir)
178            .await
179            .unwrap();
180
181        let loaded = AppManifest::load(&crate::runtime::RealHost, &dir)
182            .await
183            .unwrap();
184        assert_eq!(loaded.schema_version, APP_MANIFEST_SCHEMA_VERSION);
185        assert_eq!(loaded.entries.len(), 1);
186        assert_eq!(
187            loaded.entries[0].destination,
188            PathBuf::from("/tmp/foo.toml")
189        );
190        fs::remove_dir_all(&dir).await.unwrap();
191    }
192
193    #[tokio::test]
194    async fn legacy_unversioned_manifest_normalizes_and_writes_version_one() {
195        let dir = make_temp_dir().await;
196        fs::write(
197            dir.join(MANIFEST_FILE),
198            r#"[[entries]]
199source = "app/test/foo.toml"
200destination = "/tmp/foo.toml"
201content_hash = 7
202"#,
203        )
204        .await
205        .unwrap();
206
207        let manifest = AppManifest::load(&crate::runtime::RealHost, &dir)
208            .await
209            .unwrap();
210        assert_eq!(manifest.schema_version, APP_MANIFEST_SCHEMA_VERSION);
211        let after_read = fs::read_to_string(dir.join(MANIFEST_FILE)).await.unwrap();
212        assert!(!after_read.contains("schema_version"));
213        manifest
214            .save(&crate::runtime::RealHost, &dir)
215            .await
216            .unwrap();
217
218        let written = fs::read_to_string(dir.join(MANIFEST_FILE)).await.unwrap();
219        assert!(written.contains("schema_version = 1"));
220        fs::remove_dir_all(&dir).await.unwrap();
221    }
222
223    #[tokio::test]
224    async fn future_manifest_version_fails_before_use() {
225        let dir = make_temp_dir().await;
226        fs::write(dir.join(MANIFEST_FILE), "schema_version = 2\n")
227            .await
228            .unwrap();
229
230        let error = AppManifest::load(&crate::runtime::RealHost, &dir)
231            .await
232            .unwrap_err();
233        assert!(error.to_string().contains("newer than this Shine supports"));
234        fs::remove_dir_all(&dir).await.unwrap();
235    }
236
237    #[tokio::test]
238    async fn upsert_adds_new_entry() {
239        let dir = make_temp_dir().await;
240        let mut manifest = AppManifest::default();
241        manifest.upsert(sample_entry("/tmp/a.toml"));
242        manifest.upsert(sample_entry("/tmp/b.toml"));
243        manifest
244            .save(&crate::runtime::RealHost, &dir)
245            .await
246            .unwrap();
247
248        let loaded = AppManifest::load(&crate::runtime::RealHost, &dir)
249            .await
250            .unwrap();
251        assert_eq!(loaded.entries.len(), 2);
252        fs::remove_dir_all(&dir).await.unwrap();
253    }
254
255    #[test]
256    fn upsert_updates_existing_entry_by_destination() {
257        let mut manifest = AppManifest::default();
258        manifest.upsert(AppEntry {
259            source: "app/x/foo.toml".to_string(),
260            destination: PathBuf::from("/tmp/foo.toml"),
261            backup: None,
262            content_hash: 1,
263            install_strategy: AppInstallStrategy::Copy,
264            uses_env: false,
265            requires_admin: false,
266        });
267        manifest.upsert(AppEntry {
268            source: "app/x/foo.toml".to_string(),
269            destination: PathBuf::from("/tmp/foo.toml"),
270            backup: None,
271            content_hash: 2,
272            install_strategy: AppInstallStrategy::Copy,
273            uses_env: false,
274            requires_admin: false,
275        });
276        assert_eq!(manifest.entries.len(), 1);
277        assert_eq!(manifest.entries[0].content_hash, 2);
278    }
279
280    #[test]
281    fn upsert_relocates_existing_entry_by_source() {
282        let mut manifest = AppManifest::default();
283        let old = sample_entry("/tmp/old.toml");
284        let mut relocated = sample_entry("/tmp/new.toml");
285        relocated.source = old.source.clone();
286        manifest.upsert(old.clone());
287        manifest.upsert(relocated);
288        assert_eq!(manifest.entries.len(), 1);
289        assert_eq!(manifest.entries[0].destination, Path::new("/tmp/new.toml"));
290        assert!(manifest.find_by_source(&old.source).is_some());
291    }
292
293    #[test]
294    fn find_by_source_prefers_the_latest_legacy_relocation_receipt() {
295        let source = "app/clash-verge/merge.yaml".to_string();
296        let old = AppEntry {
297            source: source.clone(),
298            destination: PathBuf::from("C:/Users/example/.config/clash-verge/merge.yaml"),
299            backup: None,
300            content_hash: 1,
301            install_strategy: AppInstallStrategy::Copy,
302            uses_env: false,
303            requires_admin: false,
304        };
305        let current = AppEntry {
306            source: source.clone(),
307            destination: PathBuf::from("C:/Users/example/.shine/clash-verge/merge.yaml"),
308            backup: None,
309            content_hash: 1,
310            install_strategy: AppInstallStrategy::Copy,
311            uses_env: false,
312            requires_admin: false,
313        };
314        let manifest = AppManifest {
315            schema_version: 0,
316            entries: vec![old, current.clone()],
317        };
318
319        assert_eq!(manifest.find_by_source(&source), Some(&current));
320    }
321
322    #[test]
323    fn remove_by_dest_removes_matching_entry() {
324        let mut manifest = AppManifest::default();
325        manifest.upsert(sample_entry("/tmp/a.toml"));
326        manifest.upsert(sample_entry("/tmp/b.toml"));
327        let removed = manifest.remove_by_dest(Path::new("/tmp/a.toml"));
328        assert!(removed.is_some());
329        assert_eq!(manifest.entries.len(), 1);
330    }
331
332    #[test]
333    fn remove_by_dest_is_no_op_for_missing_entry() {
334        let mut manifest = AppManifest::default();
335        manifest.upsert(sample_entry("/tmp/a.toml"));
336        let removed = manifest.remove_by_dest(Path::new("/tmp/nonexistent.toml"));
337        assert!(removed.is_none());
338        assert_eq!(manifest.entries.len(), 1);
339    }
340
341    #[test]
342    fn find_by_dest_returns_entry() {
343        let mut manifest = AppManifest::default();
344        manifest.upsert(sample_entry("/tmp/a.toml"));
345        assert!(manifest.find_by_dest(Path::new("/tmp/a.toml")).is_some());
346        assert!(
347            manifest
348                .find_by_dest(Path::new("/tmp/other.toml"))
349                .is_none()
350        );
351    }
352
353    #[test]
354    fn hash_content_is_deterministic() {
355        let h1 = hash_content(b"hello");
356        let h2 = hash_content(b"hello");
357        assert_eq!(h1, h2);
358    }
359
360    #[test]
361    fn hash_content_differs_for_different_inputs() {
362        let h1 = hash_content(b"hello");
363        let h2 = hash_content(b"world");
364        assert_ne!(h1, h2);
365    }
366
367    #[test]
368    fn install_strategy_defaults_to_copy() {
369        let entry: AppEntry = toml::from_str(
370            r#"
371source = "app/test/foo.toml"
372destination = "/tmp/foo.toml"
373content_hash = 7
374"#,
375        )
376        .unwrap();
377
378        assert_eq!(entry.install_strategy, AppInstallStrategy::Copy);
379    }
380}