返回 CodeWhale
mutation.rs
根目录 / crates / tui / src / plugins / mutation.rs
1 //! Plugin mutation controller (#5182).
2 //!
3 //! All plugin install / update / uninstall writes go through this module.
4 //! Discovery stays read-only: this controller is the only writer of the user
5 //! plugins root, and every request is gated by the per-domain
6 //! [`crate::network_policy::NetworkPolicy`] before any network or disk
7 //! mutation happens. Trust and enablement are *not* mutations of this module —
8 //! they remain the registry's hash-bound receipt flow (`PluginRegistry::trust`
9 //! / `enable`), which installed bits must pass through like any other bundle.
10
11 use std::path::PathBuf;
12
13 use anyhow::{Context, Result, bail};
14
15 use crate::network_policy::NetworkPolicy;
16
17 use super::install::{self, PluginInstallOutcome, PluginInstallSource, PluginUpdateResult};
18 use super::registry::PluginRegistry;
19 use super::types::PluginScope;
20
21 /// A single plugin write operation.
22 #[derive(Debug, Clone)]
23 pub enum PluginMutationRequest {
24 /// Fetch (or copy) a bundle into the user plugins root. The bundle lands
25 /// disabled and untrusted; the caller should route to the trust review.
26 Install { source: PluginInstallSource },
27 /// Install only when the staged copy matches a prior content review.
28 /// A mismatch is rejected before the destination is created.
29 InstallExact {
30 source: PluginInstallSource,
31 expected_content_hash: String,
32 },
33 /// Re-download a previously installed bundle by name or id. A changed
34 /// bundle automatically invalidates its trust receipt at next discovery.
35 Update { selector: String },
36 /// Delete an installed bundle and prune its persisted state entry.
37 /// Requires the bundle to be disabled first.
38 Uninstall { selector: String },
39 }
40
41 /// Outcome of a [`PluginMutationRequest`]. The `NeedsApproval` /
42 /// `NetworkDenied` variants carry the blocked host and are returned without
43 /// side effects so the caller can route through its own approval flow.
44 #[derive(Debug, Clone, PartialEq, Eq)]
45 pub enum PluginMutationOutcome {
46 Installed,
47 Updated,
48 NoChange,
49 Uninstalled,
50 NeedsApproval(String),
51 NetworkDenied(String),
52 }
53
54 /// What a mutation did, for the caller to render.
55 #[derive(Debug, Clone)]
56 pub struct PluginMutationReceipt {
57 /// Installed/updated/removed plugin name (empty when blocked by policy).
58 pub name: String,
59 /// Final bundle path (present for install/update).
60 pub path: Option<PathBuf>,
61 /// Source payload hash before installer provenance is added.
62 pub content_hash: Option<String>,
63 /// Exact complete-tree hash after installer provenance is written.
64 pub installed_content_hash: Option<String>,
65 pub outcome: PluginMutationOutcome,
66 }
67
68 /// Inputs shared by mutation operations.
69 pub struct PluginMutationContext<'a> {
70 pub network: &'a NetworkPolicy,
71 pub max_size: u64,
72 }
73
74 /// Execute a mutation against the user plugins root described by `registry`.
75 ///
76 /// The registry is the source of truth for the (pre-dotenv) user plugins
77 /// root, for name-collision checks across scopes, and — on uninstall — for
78 /// the disabled precondition and the state-entry prune. Callers rediscover
79 /// after a successful mutation; the in-memory registry is not updated here.
80 pub async fn execute(
81 request: PluginMutationRequest,
82 ctx: &PluginMutationContext<'_>,
83 registry: &mut PluginRegistry,
84 ) -> Result<PluginMutationReceipt> {
85 match request {
86 PluginMutationRequest::Install { source } => {
87 install_plugin(source, None, ctx, registry).await
88 }
89 PluginMutationRequest::InstallExact {
90 source,
91 expected_content_hash,
92 } => install_plugin(source, Some(expected_content_hash.as_str()), ctx, registry).await,
93 PluginMutationRequest::Update { selector } => update_plugin(&selector, ctx, registry).await,
94 PluginMutationRequest::Uninstall { selector } => uninstall_plugin(&selector, registry),
95 }
96 }
97
98 fn user_plugins_dir(registry: &PluginRegistry) -> Result<PathBuf> {
99 registry
100 .user_plugins_dir()
101 .map(PathBuf::from)
102 .context("plugin registry has no user plugins root; install is fail-closed")
103 }
104
105 async fn install_plugin(
106 source: PluginInstallSource,
107 expected_content_hash: Option<&str>,
108 ctx: &PluginMutationContext<'_>,
109 registry: &mut PluginRegistry,
110 ) -> Result<PluginMutationReceipt> {
111 let plugins_dir = user_plugins_dir(registry)?;
112 // Pre-check name collisions across scopes: a builtin or workspace bundle
113 // with the same name would shadow (or be shadowed by) the install.
114 let name_conflict = |name: &str| -> Option<String> {
115 registry.get(name).map(|existing| {
116 format!(
117 "plugin name '{name}' is already used by the {} bundle at {}; \
118 choose a different name or remove that bundle first",
119 existing.scope.as_str(),
120 existing.canonical_root.display()
121 )
122 })
123 };
124 let outcome = match expected_content_hash {
125 Some(expected) => {
126 install::install_with_expected_content_hash(
127 source,
128 &plugins_dir,
129 ctx.max_size,
130 ctx.network,
131 &name_conflict,
132 expected,
133 )
134 .await?
135 }
136 None => {
137 install::install(
138 source,
139 &plugins_dir,
140 ctx.max_size,
141 ctx.network,
142 false,
143 &name_conflict,
144 )
145 .await?
146 }
147 };
148 Ok(match outcome {
149 PluginInstallOutcome::Installed(installed) => PluginMutationReceipt {
150 name: installed.name,
151 path: Some(installed.path),
152 content_hash: Some(installed.content_hash),
153 installed_content_hash: Some(installed.installed_content_hash),
154 outcome: PluginMutationOutcome::Installed,
155 },
156 PluginInstallOutcome::NeedsApproval(host) => blocked(host, true),
157 PluginInstallOutcome::NetworkDenied(host) => blocked(host, false),
158 })
159 }
160
161 async fn update_plugin(
162 selector: &str,
163 ctx: &PluginMutationContext<'_>,
164 registry: &mut PluginRegistry,
165 ) -> Result<PluginMutationReceipt> {
166 let plugin = registry
167 .get(selector)
168 .with_context(|| format!("Plugin bundle `{selector}` was not found"))?
169 .clone();
170 if plugin.scope != PluginScope::User {
171 bail!(
172 "only user-scope bundles installed via /plugin install can be updated; \
173 `{selector}` is a {} bundle",
174 plugin.scope.as_str()
175 );
176 }
177 let plugins_dir = user_plugins_dir(registry)?;
178 let outcome = install::update(plugin.name(), &plugins_dir, ctx.max_size, ctx.network).await?;
179 Ok(match outcome {
180 PluginUpdateResult::NoChange => PluginMutationReceipt {
181 name: plugin.name().to_string(),
182 path: None,
183 content_hash: None,
184 installed_content_hash: None,
185 outcome: PluginMutationOutcome::NoChange,
186 },
187 PluginUpdateResult::Updated(installed) => PluginMutationReceipt {
188 name: installed.name,
189 path: Some(installed.path),
190 content_hash: Some(installed.content_hash),
191 installed_content_hash: Some(installed.installed_content_hash),
192 outcome: PluginMutationOutcome::Updated,
193 },
194 PluginUpdateResult::NeedsApproval(host) => blocked(host, true),
195 PluginUpdateResult::NetworkDenied(host) => blocked(host, false),
196 })
197 }
198
199 fn uninstall_plugin(
200 selector: &str,
201 registry: &mut PluginRegistry,
202 ) -> Result<PluginMutationReceipt> {
203 let plugin = registry
204 .get(selector)
205 .with_context(|| format!("Plugin bundle `{selector}` was not found"))?
206 .clone();
207 if plugin.scope != PluginScope::User {
208 bail!(
209 "refusing to uninstall the {} bundle `{selector}`; remove it from its own root",
210 plugin.scope.as_str()
211 );
212 }
213 if plugin.enabled {
214 bail!("plugin `{selector}` is enabled; disable it first with /plugin disable {selector}");
215 }
216 let plugins_dir = user_plugins_dir(registry)?;
217 install::uninstall(plugin.name(), &plugins_dir)?;
218 registry
219 .prune_state_entry(selector)
220 .map_err(anyhow::Error::msg)?;
221 Ok(PluginMutationReceipt {
222 name: plugin.name().to_string(),
223 path: None,
224 content_hash: None,
225 installed_content_hash: None,
226 outcome: PluginMutationOutcome::Uninstalled,
227 })
228 }
229
230 fn blocked(host: String, needs_approval: bool) -> PluginMutationReceipt {
231 PluginMutationReceipt {
232 name: String::new(),
233 path: None,
234 content_hash: None,
235 installed_content_hash: None,
236 outcome: if needs_approval {
237 PluginMutationOutcome::NeedsApproval(host)
238 } else {
239 PluginMutationOutcome::NetworkDenied(host)
240 },
241 }
242 }
243
243 lines RUST