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(¤t) != 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}