Skip to main content

cli/install_core/
file_ops.rs

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
12/// Cross-process advisory lock serialising privileged (sudo) filesystem
13/// mutations. `nextest` runs each test in its own OS process, so an
14/// in-process `Mutex` cannot prevent concurrent test processes from racing
15/// on a real, shared system path (e.g. `/etc/docker/daemon.json`); this
16/// lock closes that window for both tests and real concurrent invocations.
17pub 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
27/// Acquires the cross-process advisory lock serializing privileged (sudo)
28/// filesystem mutations. Shared beyond this module by other privileged
29/// writes (e.g. the self-install binary copy in `self_install.rs`) that
30/// need to serialize against concurrent `sudo`-driven writes.
31pub 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                    // Stale lock from a crashed process: reclaim it.
40                    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
186/// Builds a `sudo` command, passing `-n` (non-interactive) when stdin isn't
187/// a TTY so a scripted invocation fails fast instead of hanging on a prompt.
188pub 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(&current) != 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(&current) != 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}