返回 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 /// Re-download a previously installed bundle by name or id. A changed
28 /// bundle automatically invalidates its trust receipt at next discovery.
29 Update { selector: String },
30 /// Delete an installed bundle and prune its persisted state entry.
31 /// Requires the bundle to be disabled first.
32 Uninstall { selector: String },
33 }
34
35 /// Outcome of a [`PluginMutationRequest`]. The `NeedsApproval` /
36 /// `NetworkDenied` variants carry the blocked host and are returned without
37 /// side effects so the caller can route through its own approval flow.
38 #[derive(Debug, Clone, PartialEq, Eq)]
39 pub enum PluginMutationOutcome {
40 Installed,
41 Updated,
42 NoChange,
43 Uninstalled,
44 NeedsApproval(String),
45 NetworkDenied(String),
46 }
47
48 /// What a mutation did, for the caller to render.
49 #[derive(Debug, Clone)]
50 pub struct PluginMutationReceipt {
51 /// Installed/updated/removed plugin name (empty when blocked by policy).
52 pub name: String,
53 /// Final bundle path (present for install/update).
54 pub path: Option<PathBuf>,
55 /// Whole-bundle content hash of the installed tree (informational; trust
56 /// receipts always bind to the discovery-time hash).
57 pub content_hash: Option<String>,
58 pub outcome: PluginMutationOutcome,
59 }
60
61 /// Inputs shared by mutation operations.
62 pub struct PluginMutationContext<'a> {
63 pub network: &'a NetworkPolicy,
64 pub max_size: u64,
65 }
66
67 /// Execute a mutation against the user plugins root described by `registry`.
68 ///
69 /// The registry is the source of truth for the (pre-dotenv) user plugins
70 /// root, for name-collision checks across scopes, and — on uninstall — for
71 /// the disabled precondition and the state-entry prune. Callers rediscover
72 /// after a successful mutation; the in-memory registry is not updated here.
73 pub async fn execute(
74 request: PluginMutationRequest,
75 ctx: &PluginMutationContext<'_>,
76 registry: &mut PluginRegistry,
77 ) -> Result<PluginMutationReceipt> {
78 match request {
79 PluginMutationRequest::Install { source } => install_plugin(source, ctx, registry).await,
80 PluginMutationRequest::Update { selector } => update_plugin(&selector, ctx, registry).await,
81 PluginMutationRequest::Uninstall { selector } => uninstall_plugin(&selector, registry),
82 }
83 }
84
85 fn user_plugins_dir(registry: &PluginRegistry) -> Result<PathBuf> {
86 registry
87 .user_plugins_dir()
88 .map(PathBuf::from)
89 .context("plugin registry has no user plugins root; install is fail-closed")
90 }
91
92 async fn install_plugin(
93 source: PluginInstallSource,
94 ctx: &PluginMutationContext<'_>,
95 registry: &mut PluginRegistry,
96 ) -> Result<PluginMutationReceipt> {
97 let plugins_dir = user_plugins_dir(registry)?;
98 // Pre-check name collisions across scopes: a builtin or workspace bundle
99 // with the same name would shadow (or be shadowed by) the install.
100 let name_conflict = |name: &str| -> Option<String> {
101 registry.get(name).map(|existing| {
102 format!(
103 "plugin name '{name}' is already used by the {} bundle at {}; \
104 choose a different name or remove that bundle first",
105 existing.scope.as_str(),
106 existing.canonical_root.display()
107 )
108 })
109 };
110 let outcome = install::install(
111 source,
112 &plugins_dir,
113 ctx.max_size,
114 ctx.network,
115 false,
116 &name_conflict,
117 )
118 .await?;
119 Ok(match outcome {
120 PluginInstallOutcome::Installed(installed) => PluginMutationReceipt {
121 name: installed.name,
122 path: Some(installed.path),
123 content_hash: Some(installed.content_hash),
124 outcome: PluginMutationOutcome::Installed,
125 },
126 PluginInstallOutcome::NeedsApproval(host) => blocked(host, true),
127 PluginInstallOutcome::NetworkDenied(host) => blocked(host, false),
128 })
129 }
130
131 async fn update_plugin(
132 selector: &str,
133 ctx: &PluginMutationContext<'_>,
134 registry: &mut PluginRegistry,
135 ) -> Result<PluginMutationReceipt> {
136 let plugin = registry
137 .get(selector)
138 .with_context(|| format!("Plugin bundle `{selector}` was not found"))?
139 .clone();
140 if plugin.scope != PluginScope::User {
141 bail!(
142 "only user-scope bundles installed via /plugin install can be updated; \
143 `{selector}` is a {} bundle",
144 plugin.scope.as_str()
145 );
146 }
147 let plugins_dir = user_plugins_dir(registry)?;
148 let outcome = install::update(plugin.name(), &plugins_dir, ctx.max_size, ctx.network).await?;
149 Ok(match outcome {
150 PluginUpdateResult::NoChange => PluginMutationReceipt {
151 name: plugin.name().to_string(),
152 path: None,
153 content_hash: None,
154 outcome: PluginMutationOutcome::NoChange,
155 },
156 PluginUpdateResult::Updated(installed) => PluginMutationReceipt {
157 name: installed.name,
158 path: Some(installed.path),
159 content_hash: Some(installed.content_hash),
160 outcome: PluginMutationOutcome::Updated,
161 },
162 PluginUpdateResult::NeedsApproval(host) => blocked(host, true),
163 PluginUpdateResult::NetworkDenied(host) => blocked(host, false),
164 })
165 }
166
167 fn uninstall_plugin(
168 selector: &str,
169 registry: &mut PluginRegistry,
170 ) -> Result<PluginMutationReceipt> {
171 let plugin = registry
172 .get(selector)
173 .with_context(|| format!("Plugin bundle `{selector}` was not found"))?
174 .clone();
175 if plugin.scope != PluginScope::User {
176 bail!(
177 "refusing to uninstall the {} bundle `{selector}`; remove it from its own root",
178 plugin.scope.as_str()
179 );
180 }
181 if plugin.enabled {
182 bail!("plugin `{selector}` is enabled; disable it first with /plugin disable {selector}");
183 }
184 let plugins_dir = user_plugins_dir(registry)?;
185 install::uninstall(plugin.name(), &plugins_dir)?;
186 registry
187 .prune_state_entry(selector)
188 .map_err(anyhow::Error::msg)?;
189 Ok(PluginMutationReceipt {
190 name: plugin.name().to_string(),
191 path: None,
192 content_hash: None,
193 outcome: PluginMutationOutcome::Uninstalled,
194 })
195 }
196
197 fn blocked(host: String, needs_approval: bool) -> PluginMutationReceipt {
198 PluginMutationReceipt {
199 name: String::new(),
200 path: None,
201 content_hash: None,
202 outcome: if needs_approval {
203 PluginMutationOutcome::NeedsApproval(host)
204 } else {
205 PluginMutationOutcome::NetworkDenied(host)
206 },
207 }
208 }
209
209 lines RUST