Skip to main content

shine_core/install/
file_ops.rs

1use super::manifest::{AppEntry, hash_content};
2#[cfg(test)]
3use crate::runtime::RealHost;
4use crate::runtime::{FileSystemHost, HostError};
5use anyhow::Result;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug)]
9pub enum InstallOutcome {
10    Installed { hash: u64 },
11    AlreadyManaged,
12    BackedUpAndInstalled { backup: PathBuf, hash: u64 },
13    DryRun,
14}
15
16#[derive(Debug)]
17pub enum UninstallOutcome {
18    Removed,
19    RestoredBackup { backup: PathBuf },
20    ForceRemoved,
21    ForceRestoredBackup { backup: PathBuf },
22    NotFound,
23    UserModified,
24    DryRun,
25}
26
27#[cfg(test)]
28pub async fn install_bytes(
29    content: &[u8],
30    destination: &Path,
31    is_managed: bool,
32    dry_run: bool,
33    force: bool,
34) -> Result<InstallOutcome> {
35    install_bytes_with_host(&RealHost, content, destination, is_managed, dry_run, force).await
36}
37
38pub async fn install_bytes_with_host<H: FileSystemHost>(
39    host: &H,
40    content: &[u8],
41    destination: &Path,
42    is_managed: bool,
43    dry_run: bool,
44    force: bool,
45) -> Result<InstallOutcome> {
46    if dry_run {
47        return Ok(InstallOutcome::DryRun);
48    }
49    if let Some(parent) = destination.parent() {
50        host.create_dir_all(parent)
51            .await
52            .map_err(|error| host_context(error, "failed to create directory"))?;
53    }
54
55    let hash = hash_content(content);
56    if path_exists(host, destination).await? {
57        if is_managed {
58            let existing = host.read(destination).await.unwrap_or_default();
59            if !force && hash_content(&existing) == hash {
60                return Ok(InstallOutcome::AlreadyManaged);
61            }
62            host.write(destination, content)
63                .await
64                .map_err(|error| host_context(error, "failed to overwrite"))?;
65            return Ok(InstallOutcome::Installed { hash });
66        }
67
68        let backup = backup_path(destination);
69        if path_exists(host, &backup).await? {
70            anyhow::bail!(
71                "refusing to replace existing managed backup {}",
72                backup.display()
73            );
74        }
75        host.rename(destination, &backup)
76            .await
77            .map_err(|error| host_context(error, "failed to back up destination"))?;
78        host.write(destination, content)
79            .await
80            .map_err(|error| host_context(error, "failed to install destination"))?;
81        return Ok(InstallOutcome::BackedUpAndInstalled { backup, hash });
82    }
83
84    host.write(destination, content)
85        .await
86        .map_err(|error| host_context(error, "failed to install destination"))?;
87    Ok(InstallOutcome::Installed { hash })
88}
89
90#[cfg(test)]
91pub async fn uninstall_entry(
92    entry: &AppEntry,
93    dry_run: bool,
94    force: bool,
95) -> Result<UninstallOutcome> {
96    uninstall_entry_with_host(&RealHost, entry, dry_run, force).await
97}
98
99pub async fn uninstall_entry_with_host<H: FileSystemHost>(
100    host: &H,
101    entry: &AppEntry,
102    dry_run: bool,
103    force: bool,
104) -> Result<UninstallOutcome> {
105    if dry_run {
106        return Ok(UninstallOutcome::DryRun);
107    }
108    if !path_exists(host, &entry.destination).await? {
109        return Ok(UninstallOutcome::NotFound);
110    }
111
112    let current = host
113        .read(&entry.destination)
114        .await
115        .map_err(|error| host_context(error, "reading managed resource"))?;
116    let user_modified = hash_content(&current) != entry.content_hash;
117    if user_modified && !force {
118        return Ok(UninstallOutcome::UserModified);
119    }
120
121    host.remove_file(&entry.destination)
122        .await
123        .map_err(|error| host_context(error, "removing managed resource"))?;
124    if let Some(backup) = &entry.backup
125        && path_exists(host, backup).await?
126    {
127        host.rename(backup, &entry.destination)
128            .await
129            .map_err(|error| host_context(error, "restoring managed backup"))?;
130        return Ok(if user_modified {
131            UninstallOutcome::ForceRestoredBackup {
132                backup: backup.clone(),
133            }
134        } else {
135            UninstallOutcome::RestoredBackup {
136                backup: backup.clone(),
137            }
138        });
139    }
140
141    Ok(if user_modified {
142        UninstallOutcome::ForceRemoved
143    } else {
144        UninstallOutcome::Removed
145    })
146}
147
148async fn path_exists(host: &impl FileSystemHost, path: &Path) -> Result<bool> {
149    match host.metadata(path).await {
150        Ok(_) => Ok(true),
151        Err(error) if error.is_not_found() => Ok(false),
152        Err(error) => Err(error.into_anyhow("inspecting managed resource")),
153    }
154}
155
156fn host_context(error: HostError, context: &'static str) -> anyhow::Error {
157    error.into_anyhow(context).context(context)
158}
159
160pub fn backup_path(dest: &Path) -> PathBuf {
161    let name = dest
162        .file_name()
163        .and_then(|name| name.to_str())
164        .unwrap_or("file");
165    dest.with_file_name(format!("{name}.shine.bak"))
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::install::AppInstallStrategy;
172    use crate::runtime::{FileSystemObservationHost, HostOperation, InMemoryHost};
173
174    fn entry(destination: &str, bytes: &[u8]) -> AppEntry {
175        AppEntry {
176            source: "app/test/file".to_string(),
177            destination: PathBuf::from(destination),
178            backup: None,
179            content_hash: hash_content(bytes),
180            install_strategy: AppInstallStrategy::Copy,
181            uses_env: false,
182            requires_admin: false,
183        }
184    }
185
186    #[tokio::test]
187    async fn in_memory_install_noop_update_and_uninstall_chain() {
188        let host = InMemoryHost::new();
189        let destination = Path::new("/home/test/config");
190        let installed = install_bytes_with_host(&host, b"one", destination, false, false, false)
191            .await
192            .unwrap();
193        assert!(matches!(installed, InstallOutcome::Installed { .. }));
194
195        let unchanged = install_bytes_with_host(&host, b"one", destination, true, false, false)
196            .await
197            .unwrap();
198        assert!(matches!(unchanged, InstallOutcome::AlreadyManaged));
199
200        let removed =
201            uninstall_entry_with_host(&host, &entry("/home/test/config", b"one"), false, false)
202                .await
203                .unwrap();
204        assert!(matches!(removed, UninstallOutcome::Removed));
205        assert!(host.operations().iter().any(|operation| matches!(
206            operation,
207            HostOperation::Remove(path) if path == destination
208        )));
209    }
210
211    #[tokio::test]
212    async fn in_memory_uninstall_preserves_user_modification() {
213        let host = InMemoryHost::new();
214        host.put_file("/home/test/config", b"changed".to_vec());
215        let outcome =
216            uninstall_entry_with_host(&host, &entry("/home/test/config", b"managed"), false, false)
217                .await
218                .unwrap();
219        assert!(matches!(outcome, UninstallOutcome::UserModified));
220        assert_eq!(
221            host.read(Path::new("/home/test/config")).await.unwrap(),
222            b"changed"
223        );
224    }
225
226    #[tokio::test]
227    async fn in_memory_install_preserves_an_existing_backup() {
228        let host = InMemoryHost::new();
229        let destination = Path::new("/home/test/config");
230        let backup = backup_path(destination);
231        host.put_file(destination, b"user-original".to_vec());
232        host.put_file(&backup, b"older-backup".to_vec());
233
234        let error = install_bytes_with_host(&host, b"managed", destination, false, false, false)
235            .await
236            .unwrap_err();
237
238        assert!(error.to_string().contains("existing managed backup"));
239        assert_eq!(host.read(destination).await.unwrap(), b"user-original");
240        assert_eq!(host.read(&backup).await.unwrap(), b"older-backup");
241    }
242}