1use super::manifest::{AppEntry, hash_content};
2use anyhow::{Context, Result};
3use std::io::IsTerminal;
4#[cfg(unix)]
5use std::io::Write;
6#[cfg(unix)]
7use std::os::unix::fs::OpenOptionsExt;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10use tokio::fs;
11
12pub struct AdminLockGuard {
18 path: PathBuf,
19}
20
21impl Drop for AdminLockGuard {
22 fn drop(&mut self) {
23 let _ = std::fs::remove_dir(&self.path);
24 }
25}
26
27pub async fn admin_lock() -> Result<AdminLockGuard> {
32 let path = std::env::temp_dir().join("shine-admin.lock");
33 let deadline = Instant::now() + Duration::from_secs(30);
34 loop {
35 match fs::create_dir(&path).await {
36 Ok(()) => return Ok(AdminLockGuard { path }),
37 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
38 if Instant::now() >= deadline {
39 let _ = fs::remove_dir(&path).await;
41 continue;
42 }
43 tokio::time::sleep(Duration::from_millis(50)).await;
44 }
45 Err(e) => return Err(e).context("failed to acquire admin operation lock"),
46 }
47 }
48}
49
50#[derive(Debug)]
51pub enum InstallOutcome {
52 Installed { hash: u64 },
53 AlreadyManaged,
54 BackedUpAndInstalled { backup: PathBuf, hash: u64 },
55 DryRun,
56}
57
58#[derive(Debug)]
59pub enum UninstallOutcome {
60 Removed,
61 RestoredBackup { backup: PathBuf },
62 ForceRemoved,
63 ForceRestoredBackup { backup: PathBuf },
64 NotFound,
65 UserModified,
66 DryRun,
67}
68
69pub async fn install_bytes(
70 content: &[u8],
71 destination: &Path,
72 is_managed: bool,
73 dry_run: bool,
74 force: bool,
75) -> Result<InstallOutcome> {
76 if dry_run {
77 return Ok(InstallOutcome::DryRun);
78 }
79 install_bytes_impl(content, destination, is_managed, force).await
80}
81
82pub async fn install_bytes_admin(
83 content: &[u8],
84 destination: &Path,
85 is_managed: bool,
86 dry_run: bool,
87 force: bool,
88) -> Result<InstallOutcome> {
89 if dry_run {
90 return Ok(InstallOutcome::DryRun);
91 }
92 if !cfg!(unix) || std::env::var("USER").is_ok_and(|user| user == "root") {
93 return install_bytes_impl(content, destination, is_managed, force).await;
94 }
95 let _lock = admin_lock().await?;
96 if !crate::privilege::ensure_admin(1).await? {
97 anyhow::bail!("administrator permission was not granted");
98 }
99
100 let hash = hash_content(content);
101 if destination.exists() && is_managed && !force {
102 let existing = fs::read(destination).await.unwrap_or_default();
103 if hash_content(&existing) == hash {
104 return Ok(InstallOutcome::AlreadyManaged);
105 }
106 }
107
108 let temp = std::env::temp_dir().join(format!("shine-admin-{}", uuid::Uuid::new_v4()));
109 #[cfg(unix)]
110 {
111 let mut file = std::fs::OpenOptions::new()
112 .create_new(true)
113 .write(true)
114 .mode(0o600)
115 .open(&temp)
116 .with_context(|| format!("creating temporary file: {}", temp.display()))?;
117 file.write_all(content)
118 .with_context(|| format!("writing temporary file: {}", temp.display()))?;
119 }
120 let parent = destination
121 .parent()
122 .ok_or_else(|| anyhow::anyhow!("destination has no parent: {}", destination.display()))?;
123
124 let mut backup = None;
125 if destination.exists() && !is_managed {
126 let path = backup_path(destination);
127 let status = sudo_command()
128 .args(["mv", "--"])
129 .arg(destination)
130 .arg(&path)
131 .status()
132 .await
133 .context("failed to run sudo for app backup")?;
134 if !status.success() {
135 let _ = fs::remove_file(&temp).await;
136 anyhow::bail!("administrator permission was not granted");
137 }
138 backup = Some(path);
139 }
140
141 let status = sudo_command()
142 .arg("mkdir")
143 .arg("-p")
144 .arg(parent)
145 .status()
146 .await
147 .context("failed to create privileged destination directory")?;
148 if !status.success() {
149 let _ = fs::remove_file(&temp).await;
150 if let Some(backup) = &backup {
151 let _ = sudo_command()
152 .args(["mv", "--"])
153 .arg(backup)
154 .arg(destination)
155 .status()
156 .await;
157 }
158 anyhow::bail!("administrator permission was not granted");
159 }
160 let status = sudo_command()
161 .args(["install", "-m", "0644", "--"])
162 .arg(&temp)
163 .arg(destination)
164 .status()
165 .await
166 .context("failed to install privileged app configuration")?;
167 let _ = fs::remove_file(&temp).await;
168 if !status.success() {
169 if let Some(backup) = &backup {
170 let _ = sudo_command()
171 .args(["mv", "--"])
172 .arg(backup)
173 .arg(destination)
174 .status()
175 .await;
176 }
177 anyhow::bail!("failed to install privileged app configuration");
178 }
179
180 Ok(match backup {
181 Some(backup) => InstallOutcome::BackedUpAndInstalled { backup, hash },
182 None => InstallOutcome::Installed { hash },
183 })
184}
185
186pub fn sudo_command() -> tokio::process::Command {
189 let mut command = tokio::process::Command::new("sudo");
190 if !std::io::stdin().is_terminal() {
191 command.arg("-n");
192 }
193 command
194}
195
196async fn install_bytes_impl(
197 content: &[u8],
198 destination: &Path,
199 is_managed: bool,
200 force: bool,
201) -> Result<InstallOutcome> {
202 if let Some(parent) = destination.parent() {
203 fs::create_dir_all(parent)
204 .await
205 .with_context(|| format!("failed to create directory: {}", parent.display()))?;
206 }
207
208 let hash = hash_content(content);
209
210 if destination.exists() {
211 if is_managed {
212 let existing = fs::read(destination).await.unwrap_or_default();
213 if !force && hash_content(&existing) == hash {
214 return Ok(InstallOutcome::AlreadyManaged);
215 }
216 fs::write(destination, content)
217 .await
218 .with_context(|| format!("failed to overwrite: {}", destination.display()))?;
219 return Ok(InstallOutcome::Installed { hash });
220 }
221
222 let backup = backup_path(destination);
223 fs::rename(destination, &backup).await.with_context(|| {
224 format!(
225 "failed to back up {} to {}",
226 destination.display(),
227 backup.display()
228 )
229 })?;
230 fs::write(destination, content)
231 .await
232 .with_context(|| format!("failed to install to: {}", destination.display()))?;
233 return Ok(InstallOutcome::BackedUpAndInstalled { backup, hash });
234 }
235
236 fs::write(destination, content)
237 .await
238 .with_context(|| format!("failed to install to: {}", destination.display()))?;
239 Ok(InstallOutcome::Installed { hash })
240}
241
242pub async fn uninstall_entry(
243 entry: &AppEntry,
244 dry_run: bool,
245 force: bool,
246) -> Result<UninstallOutcome> {
247 if dry_run {
248 return Ok(UninstallOutcome::DryRun);
249 }
250
251 if !entry.destination.exists() {
252 return Ok(UninstallOutcome::NotFound);
253 }
254
255 let current = fs::read(&entry.destination)
256 .await
257 .with_context(|| format!("reading: {}", entry.destination.display()))?;
258 let user_modified = hash_content(¤t) != entry.content_hash;
259 if user_modified && !force {
260 return Ok(UninstallOutcome::UserModified);
261 }
262
263 fs::remove_file(&entry.destination)
264 .await
265 .with_context(|| format!("removing: {}", entry.destination.display()))?;
266
267 if let Some(backup) = &entry.backup
268 && backup.exists()
269 {
270 fs::rename(backup, &entry.destination)
271 .await
272 .with_context(|| format!("restoring backup: {}", backup.display()))?;
273 return Ok(if user_modified {
274 UninstallOutcome::ForceRestoredBackup {
275 backup: backup.clone(),
276 }
277 } else {
278 UninstallOutcome::RestoredBackup {
279 backup: backup.clone(),
280 }
281 });
282 }
283
284 Ok(if user_modified {
285 UninstallOutcome::ForceRemoved
286 } else {
287 UninstallOutcome::Removed
288 })
289}
290
291pub async fn uninstall_entry_admin(
292 entry: &AppEntry,
293 dry_run: bool,
294 force: bool,
295) -> Result<UninstallOutcome> {
296 if dry_run {
297 return Ok(UninstallOutcome::DryRun);
298 }
299 if !cfg!(unix) || std::env::var("USER").is_ok_and(|user| user == "root") {
300 return uninstall_entry(entry, false, force).await;
301 }
302 let _lock = admin_lock().await?;
303 if !crate::privilege::ensure_admin(1).await? {
304 anyhow::bail!("administrator permission was not granted");
305 }
306 if !entry.destination.exists() {
307 return Ok(UninstallOutcome::NotFound);
308 }
309 let current = fs::read(&entry.destination)
310 .await
311 .with_context(|| format!("reading: {}", entry.destination.display()))?;
312 let user_modified = hash_content(¤t) != entry.content_hash;
313 if user_modified && !force {
314 return Ok(UninstallOutcome::UserModified);
315 }
316 let status = sudo_command()
317 .args(["rm", "-f", "--"])
318 .arg(&entry.destination)
319 .status()
320 .await
321 .context("failed to remove privileged app configuration")?;
322 if !status.success() {
323 anyhow::bail!("administrator permission was not granted");
324 }
325 if let Some(backup) = &entry.backup
326 && backup.exists()
327 {
328 let status = sudo_command()
329 .args(["mv", "--"])
330 .arg(backup)
331 .arg(&entry.destination)
332 .status()
333 .await
334 .context("failed to restore privileged app backup")?;
335 if !status.success() {
336 anyhow::bail!("failed to restore privileged app backup");
337 }
338 return Ok(if user_modified {
339 UninstallOutcome::ForceRestoredBackup {
340 backup: backup.clone(),
341 }
342 } else {
343 UninstallOutcome::RestoredBackup {
344 backup: backup.clone(),
345 }
346 });
347 }
348 Ok(if user_modified {
349 UninstallOutcome::ForceRemoved
350 } else {
351 UninstallOutcome::Removed
352 })
353}
354
355fn backup_path(dest: &Path) -> PathBuf {
356 let name = dest.file_name().and_then(|n| n.to_str()).unwrap_or("file");
357 dest.with_file_name(format!("{name}.shine.bak"))
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::install_core::AppInstallStrategy;
364 use crate::install_core::manifest::AppEntry;
365
366 async fn make_temp_dir() -> PathBuf {
367 crate::test_support::make_temp_dir("shine-fileops").await
368 }
369
370 fn entry_for(dest: &Path, hash: u64) -> AppEntry {
371 AppEntry {
372 source: "app/test/f".to_string(),
373 destination: dest.to_path_buf(),
374 backup: None,
375 content_hash: hash,
376 install_strategy: AppInstallStrategy::Copy,
377 uses_env: false,
378 requires_admin: false,
379 }
380 }
381
382 #[tokio::test]
383 async fn install_to_empty_destination() {
384 let dir = make_temp_dir().await;
385 let dest = dir.join("dest.toml");
386
387 let outcome = install_bytes(b"content", &dest, false, false, false)
388 .await
389 .unwrap();
390 assert!(matches!(outcome, InstallOutcome::Installed { .. }));
391 assert!(dest.exists());
392 assert_eq!(fs::read(&dest).await.unwrap(), b"content");
393 fs::remove_dir_all(&dir).await.unwrap();
394 }
395
396 #[tokio::test]
397 async fn install_creates_parent_directories() {
398 let dir = make_temp_dir().await;
399 let dest = dir.join("deep/nested/dest.toml");
400
401 install_bytes(b"content", &dest, false, false, false)
402 .await
403 .unwrap();
404 assert!(dest.exists());
405 fs::remove_dir_all(&dir).await.unwrap();
406 }
407
408 #[tokio::test]
409 async fn install_backs_up_unmanaged_existing_file() {
410 let dir = make_temp_dir().await;
411 let dest = dir.join("dest.toml");
412 fs::write(&dest, b"user content").await.unwrap();
413
414 let outcome = install_bytes(b"new content", &dest, false, false, false)
415 .await
416 .unwrap();
417 let backup = match outcome {
418 InstallOutcome::BackedUpAndInstalled { backup, .. } => backup,
419 other => panic!("expected BackedUpAndInstalled, got {other:?}"),
420 };
421 assert!(backup.exists());
422 assert_eq!(fs::read(&backup).await.unwrap(), b"user content");
423 assert_eq!(fs::read(&dest).await.unwrap(), b"new content");
424 fs::remove_dir_all(&dir).await.unwrap();
425 }
426
427 #[tokio::test]
428 async fn install_already_managed_same_content_returns_already_managed() {
429 let dir = make_temp_dir().await;
430 let dest = dir.join("dest.toml");
431 fs::write(&dest, b"content").await.unwrap();
432
433 let outcome = install_bytes(b"content", &dest, true, false, false)
434 .await
435 .unwrap();
436 assert!(matches!(outcome, InstallOutcome::AlreadyManaged));
437 fs::remove_dir_all(&dir).await.unwrap();
438 }
439
440 #[tokio::test]
441 async fn install_already_managed_different_content_overwrites() {
442 let dir = make_temp_dir().await;
443 let dest = dir.join("dest.toml");
444 fs::write(&dest, b"old").await.unwrap();
445
446 let outcome = install_bytes(b"updated", &dest, true, false, false)
447 .await
448 .unwrap();
449 assert!(matches!(outcome, InstallOutcome::Installed { .. }));
450 assert_eq!(fs::read(&dest).await.unwrap(), b"updated");
451 fs::remove_dir_all(&dir).await.unwrap();
452 }
453
454 #[tokio::test]
455 async fn install_dry_run_does_not_write() {
456 let dir = make_temp_dir().await;
457 let dest = dir.join("dest.toml");
458
459 let outcome = install_bytes(b"content", &dest, false, true, false)
460 .await
461 .unwrap();
462 assert!(matches!(outcome, InstallOutcome::DryRun));
463 assert!(!dest.exists());
464 fs::remove_dir_all(&dir).await.unwrap();
465 }
466
467 #[tokio::test]
468 async fn uninstall_removes_matching_file() {
469 let dir = make_temp_dir().await;
470 let dest = dir.join("dest.toml");
471 let content = b"managed content";
472 fs::write(&dest, content).await.unwrap();
473 let entry = entry_for(&dest, hash_content(content));
474
475 let outcome = uninstall_entry(&entry, false, false).await.unwrap();
476 assert!(matches!(outcome, UninstallOutcome::Removed));
477 assert!(!dest.exists());
478 fs::remove_dir_all(&dir).await.unwrap();
479 }
480
481 #[tokio::test]
482 async fn uninstall_restores_backup() {
483 let dir = make_temp_dir().await;
484 let dest = dir.join("dest.toml");
485 let backup = dir.join("dest.toml.shine.bak");
486 let content = b"managed";
487 fs::write(&dest, content).await.unwrap();
488 fs::write(&backup, b"original").await.unwrap();
489
490 let entry = AppEntry {
491 source: "app/test/dest.toml".to_string(),
492 destination: dest.clone(),
493 backup: Some(backup.clone()),
494 content_hash: hash_content(content),
495 install_strategy: AppInstallStrategy::Copy,
496 uses_env: false,
497 requires_admin: false,
498 };
499 let outcome = uninstall_entry(&entry, false, false).await.unwrap();
500 assert!(matches!(outcome, UninstallOutcome::RestoredBackup { .. }));
501 assert!(!backup.exists());
502 assert_eq!(fs::read(&dest).await.unwrap(), b"original");
503 fs::remove_dir_all(&dir).await.unwrap();
504 }
505
506 #[tokio::test]
507 async fn uninstall_skips_when_not_found() {
508 let dir = make_temp_dir().await;
509 let dest = dir.join("missing.toml");
510 let entry = entry_for(&dest, 0);
511
512 let outcome = uninstall_entry(&entry, false, false).await.unwrap();
513 assert!(matches!(outcome, UninstallOutcome::NotFound));
514 fs::remove_dir_all(&dir).await.unwrap();
515 }
516
517 #[tokio::test]
518 async fn uninstall_skips_user_modified_file() {
519 let dir = make_temp_dir().await;
520 let dest = dir.join("dest.toml");
521 fs::write(&dest, b"user modified").await.unwrap();
522 let entry = entry_for(&dest, hash_content(b"original content"));
523
524 let outcome = uninstall_entry(&entry, false, false).await.unwrap();
525 assert!(matches!(outcome, UninstallOutcome::UserModified));
526 assert!(dest.exists(), "user-modified file must not be removed");
527 fs::remove_dir_all(&dir).await.unwrap();
528 }
529
530 #[tokio::test]
531 async fn uninstall_force_removes_user_modified_file() {
532 let dir = make_temp_dir().await;
533 let dest = dir.join("dest.toml");
534 fs::write(&dest, b"user modified").await.unwrap();
535 let entry = entry_for(&dest, hash_content(b"original content"));
536
537 let outcome = uninstall_entry(&entry, false, true).await.unwrap();
538 assert!(matches!(outcome, UninstallOutcome::ForceRemoved));
539 assert!(!dest.exists(), "force should remove user-modified file");
540 fs::remove_dir_all(&dir).await.unwrap();
541 }
542
543 #[tokio::test]
544 async fn uninstall_force_restores_backup_after_user_modified_file() {
545 let dir = make_temp_dir().await;
546 let dest = dir.join("dest.toml");
547 let backup = dir.join("dest.toml.shine.bak");
548 fs::write(&dest, b"user modified").await.unwrap();
549 fs::write(&backup, b"original").await.unwrap();
550
551 let entry = AppEntry {
552 source: "app/test/dest.toml".to_string(),
553 destination: dest.clone(),
554 backup: Some(backup.clone()),
555 content_hash: hash_content(b"managed"),
556 install_strategy: AppInstallStrategy::Copy,
557 uses_env: false,
558 requires_admin: false,
559 };
560
561 let outcome = uninstall_entry(&entry, false, true).await.unwrap();
562 assert!(matches!(
563 outcome,
564 UninstallOutcome::ForceRestoredBackup { .. }
565 ));
566 assert!(!backup.exists());
567 assert_eq!(fs::read(&dest).await.unwrap(), b"original");
568 fs::remove_dir_all(&dir).await.unwrap();
569 }
570
571 #[tokio::test]
572 async fn uninstall_dry_run_leaves_file_intact() {
573 let dir = make_temp_dir().await;
574 let dest = dir.join("dest.toml");
575 let content = b"managed";
576 fs::write(&dest, content).await.unwrap();
577 let entry = entry_for(&dest, hash_content(content));
578
579 let outcome = uninstall_entry(&entry, true, false).await.unwrap();
580 assert!(matches!(outcome, UninstallOutcome::DryRun));
581 assert!(dest.exists());
582 fs::remove_dir_all(&dir).await.unwrap();
583 }
584
585 #[test]
586 fn backup_path_appends_shine_bak() {
587 let p = PathBuf::from("/home/user/.gitconfig");
588 let b = backup_path(&p);
589 assert_eq!(b, PathBuf::from("/home/user/.gitconfig.shine.bak"));
590 }
591}