返回 CodeWhale
place.rs
根目录 / crates / tui / src / plugins / install / place.rs
1 //! Place a staged bundle at its final path, and the containment guards.
2 //!
3 //! [`finalize_install`] is the only writer of the destination directory: it
4 //! renames the staged tree into place atomically, restores the backup on any
5 //! failure, and writes `.installed-from` **last** so a partial install never
6 //! leaves a stale marker. [`ensure_target_within_plugins_dir`] mirrors
7 //! discovery's fail-closed rule that a bundle must resolve to a direct child
8 //! of the plugins root.
9
10 use std::fs;
11 use std::path::{Path, PathBuf};
12
13 use anyhow::{Context, Result, bail};
14
15 use crate::skills::install::{self as skill_install, validate_skill_name_segment};
16
17 use super::stage::StagedPlugin;
18 use super::{INSTALLED_FROM_MARKER, InstalledPlugin, PluginInstallError, PluginInstallOutcome};
19
20 pub(super) fn finalize_install(
21 staged: StagedPlugin,
22 spec: &str,
23 url: Option<&str>,
24 source_checksum: &str,
25 user_plugins_dir: &Path,
26 update: bool,
27 ) -> Result<PluginInstallOutcome> {
28 let final_path = user_plugins_dir.join(&staged.name);
29 let mut backup_path: Option<PathBuf> = None;
30 if final_path.exists() {
31 if !update {
32 let has_marker = final_path.join(INSTALLED_FROM_MARKER).exists();
33 let _ = fs::remove_dir_all(&staged.staged_path);
34 if has_marker {
35 return Err(PluginInstallError::AlreadyInstalled(staged.name).into());
36 }
37 return Err(PluginInstallError::NotInstalledHere(staged.name).into());
38 }
39 if !final_path.join(INSTALLED_FROM_MARKER).exists() {
40 let _ = fs::remove_dir_all(&staged.staged_path);
41 return Err(PluginInstallError::NotInstalledHere(staged.name).into());
42 }
43 let backup = user_plugins_dir.join(format!("{}.bak", staged.name));
44 if backup.exists() {
45 fs::remove_dir_all(&backup).ok();
46 }
47 fs::rename(&final_path, &backup).with_context(|| {
48 format!(
49 "failed to backup existing plugin at {}",
50 final_path.display()
51 )
52 })?;
53 if let Err(error) = fs::rename(&staged.staged_path, &final_path) {
54 fs::rename(&backup, &final_path).ok();
55 return Err(error).context("failed to install staged plugin");
56 }
57 backup_path = Some(backup);
58 } else if let Err(error) = fs::rename(&staged.staged_path, &final_path) {
59 let _ = fs::remove_dir_all(&staged.staged_path);
60 return Err(error).context("failed to install staged plugin");
61 }
62
63 // Discovery fail-closed rule: the installed bundle must canonicalize to a
64 // direct child of the user plugins root.
65 if let Err(error) = ensure_target_within_plugins_dir(&final_path, user_plugins_dir) {
66 let _ = fs::remove_dir_all(&final_path);
67 if let Some(backup) = backup_path.take() {
68 let _ = fs::rename(&backup, &final_path);
69 }
70 return Err(error);
71 }
72
73 // Write the marker last so a partial install never leaves a stale
74 // `.installed-from` on disk.
75 if let Err(error) = skill_install::write_installed_from_v2(
76 &final_path,
77 spec,
78 url,
79 source_checksum,
80 &staged.content_hash,
81 &staged.name,
82 ) {
83 let _ = fs::remove_dir_all(&final_path);
84 if let Some(backup) = backup_path.take() {
85 let _ = fs::rename(&backup, &final_path);
86 }
87 return Err(error);
88 }
89 if let Some(backup) = backup_path {
90 fs::remove_dir_all(&backup).ok();
91 }
92
93 Ok(PluginInstallOutcome::Installed(InstalledPlugin {
94 name: staged.name,
95 path: final_path,
96 content_hash: staged.content_hash,
97 source_checksum: source_checksum.to_string(),
98 }))
99 }
100
101 // ─────────────────────────────────────────────────────────────────────────────
102 // Path guards
103 // ─────────────────────────────────────────────────────────────────────────────
104
105 pub(super) fn plugin_target_path(name: &str, user_plugins_dir: &Path) -> Result<PathBuf> {
106 let name = validate_skill_name_segment(name)
107 .map_err(|error| anyhow::anyhow!("plugin name is not a safe directory name: {error:#}"))?;
108 Ok(user_plugins_dir.join(name))
109 }
110
111 /// The resolved bundle must be a direct child of the resolved plugins root,
112 /// matching discovery's fail-closed containment rule.
113 pub(super) fn ensure_target_within_plugins_dir(
114 target: &Path,
115 user_plugins_dir: &Path,
116 ) -> Result<()> {
117 let root = fs::canonicalize(user_plugins_dir).with_context(|| {
118 format!(
119 "failed to resolve plugins directory {}",
120 user_plugins_dir.display()
121 )
122 })?;
123 let target = fs::canonicalize(target)
124 .with_context(|| format!("failed to resolve {}", target.display()))?;
125 if target.parent() != Some(root.as_path()) {
126 bail!(
127 "plugin path {} escapes plugins directory {}",
128 target.display(),
129 root.display()
130 );
131 }
132 Ok(())
133 }
134
134 lines RUST