Skip to main content

guth_cli/
lib.rs

1//! Shared external-plugin discovery and dispatch for Guth.
2
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeSet;
6use std::ffi::{OsStr, OsString};
7use std::fs;
8use std::io::{self, Read, Write};
9use std::os::fd::AsRawFd;
10use std::os::unix::ffi::{OsStrExt, OsStringExt};
11use std::path::{Path, PathBuf};
12use std::process::{Child, Command, ExitStatus, Stdio};
13use std::sync::mpsc::{self, Receiver};
14use std::thread;
15use std::time::{Duration, Instant};
16use uuid::{Uuid, Variant, Version};
17
18pub const MANIFEST_SCHEMA: u32 = 1;
19pub const ACTION_MANIFEST_SCHEMA: u32 = 2;
20pub const ACTION_PROTOCOL_VERSION: u32 = 1;
21pub const ACTION_CONTRIBUTION_CAPABILITY: &str = "gui-actions";
22pub const ACTION_PROTOCOL_ARGUMENT: &str = "--guth-action-protocol";
23pub const MANIFEST_BYTES_LIMIT: u64 = 64 * 1024;
24pub const PLUGIN_LIMIT: usize = 64;
25pub const ENABLED_PLUGIN_BYTES_LIMIT: u64 = 8 * 1024;
26pub const PLUGIN_DIAGNOSTIC_LIMIT: usize = 64;
27pub const PLUGIN_ACTION_LIMIT: usize = 32;
28pub const PLUGIN_ACTION_SELECTION_LIMIT: usize = 256;
29pub const PLUGIN_ACTION_IO_BYTES_LIMIT: usize = 48 * 1024;
30pub const PLUGIN_ACTION_INVOKE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
31const PLUGIN_DIRECTORY_ENTRY_LIMIT: usize = 4096;
32const PLUGIN_OUTPUT_BYTES_LIMIT: usize = 64 * 1024;
33const PLUGIN_PATH_BYTES_LIMIT: usize = 4096;
34const PLUGIN_ACTION_EXTENSION_LIMIT: usize = 32;
35const PLUGIN_ACTION_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
36const PLUGIN_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
37const PLUGIN_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(25);
38const PLUGIN_OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
39const PLUGIN_SPAWN_BUSY_RETRIES: usize = 6;
40const PLUGIN_SPAWN_BUSY_RETRY_DELAY: Duration = Duration::from_millis(10);
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43#[serde(deny_unknown_fields)]
44pub struct PluginManifest {
45    pub schema: u32,
46    pub id: Uuid,
47    pub name: String,
48    pub version: String,
49    pub executable: PathBuf,
50    pub description: String,
51    pub capabilities: Vec<String>,
52}
53
54impl PluginManifest {
55    /// Validates the schema-1 command-manifest wire contract.
56    ///
57    /// Schema-2 GUI-action documents must be represented by and validated as
58    /// [`PluginActionManifest`]. Discovery projects either schema into this
59    /// metadata type; action-specific dispatch validates a schema-2 projection
60    /// together with its contribution declaration.
61    pub fn validate(&self) -> io::Result<()> {
62        if self.schema != MANIFEST_SCHEMA {
63            return Err(invalid_data("unsupported plugin manifest schema"));
64        }
65        self.validate_common()
66    }
67
68    fn validate_common(&self) -> io::Result<()> {
69        validate_plugin_id(self.id)?;
70        validate_text(&self.name, "plugin name", 80)?;
71        validate_text(&self.version, "plugin version", 32)?;
72        validate_text(&self.description, "plugin description", 280)?;
73        if !self.executable.is_absolute() {
74            return Err(invalid_data("plugin executable must be an absolute path"));
75        }
76        if self.capabilities.is_empty() || self.capabilities.len() > 16 {
77            return Err(invalid_data("plugin must declare 1 to 16 capabilities"));
78        }
79        let mut seen = BTreeSet::new();
80        for capability in &self.capabilities {
81            validate_token(capability, "plugin capability")?;
82            if !seen.insert(capability) {
83                return Err(invalid_data("plugin capabilities must be unique"));
84            }
85        }
86        Ok(())
87    }
88
89    pub fn supports(&self, capability: &str) -> bool {
90        self.capabilities
91            .iter()
92            .any(|candidate| candidate == capability)
93    }
94}
95
96/// A schema-2 plugin manifest that opts into Guth's external GUI-action protocol.
97///
98/// Schema-1 [`PluginManifest`] documents remain unchanged. Keeping this as a
99/// separate wire type means older plugin authors do not need to add a field to
100/// existing Rust struct literals, while discovery can normalize both schemas to
101/// [`PluginManifest`] for metadata and enabled-state APIs.
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
103#[serde(deny_unknown_fields)]
104pub struct PluginActionManifest {
105    pub schema: u32,
106    pub id: Uuid,
107    pub name: String,
108    pub version: String,
109    pub executable: PathBuf,
110    pub description: String,
111    pub capabilities: Vec<String>,
112    pub contributions: PluginContributions,
113}
114
115impl PluginActionManifest {
116    pub fn validate(&self) -> io::Result<()> {
117        if self.schema != ACTION_MANIFEST_SCHEMA {
118            return Err(invalid_data(
119                "GUI action manifests must use manifest schema 2",
120            ));
121        }
122        let plugin = self.plugin();
123        plugin.validate_common()?;
124        if !plugin.supports(ACTION_CONTRIBUTION_CAPABILITY) {
125            return Err(invalid_data(
126                "GUI action manifests must declare the gui-actions capability",
127            ));
128        }
129        self.contributions.validate()
130    }
131
132    pub fn plugin(&self) -> PluginManifest {
133        PluginManifest {
134            schema: self.schema,
135            id: self.id,
136            name: self.name.clone(),
137            version: self.version.clone(),
138            executable: self.executable.clone(),
139            description: self.description.clone(),
140            capabilities: self.capabilities.clone(),
141        }
142    }
143}
144
145#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
146#[serde(deny_unknown_fields)]
147pub struct PluginContributions {
148    pub actions: PluginActionContribution,
149}
150
151impl PluginContributions {
152    fn validate(&self) -> io::Result<()> {
153        self.actions.validate()
154    }
155}
156
157#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
158#[serde(deny_unknown_fields)]
159pub struct PluginActionContribution {
160    pub protocol: u32,
161}
162
163impl PluginActionContribution {
164    pub fn validate(&self) -> io::Result<()> {
165        if self.protocol != ACTION_PROTOCOL_VERSION {
166            return Err(invalid_data("unsupported GUI action protocol version"));
167        }
168        Ok(())
169    }
170}
171
172#[derive(Clone, Debug, Eq, PartialEq)]
173pub struct DiscoveredActionContribution {
174    pub plugin_id: Uuid,
175    pub declaration: PluginActionContribution,
176}
177
178/// Lossless, bounded representation of a Linux path on the JSON protocol wire.
179/// `display` is presentation-only; `unix_bytes_hex` is the authoritative path.
180#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
181#[serde(deny_unknown_fields)]
182pub struct PluginProtocolPath {
183    pub display: String,
184    pub unix_bytes_hex: String,
185}
186
187impl PluginProtocolPath {
188    pub fn from_path(path: &Path) -> io::Result<Self> {
189        validate_protocol_path(path)?;
190        Ok(Self {
191            display: path.to_string_lossy().into_owned(),
192            unix_bytes_hex: encode_hex(path.as_os_str().as_bytes()),
193        })
194    }
195
196    pub fn to_path_buf(&self) -> io::Result<PathBuf> {
197        let bytes = decode_hex(&self.unix_bytes_hex)?;
198        let path = PathBuf::from(OsString::from_vec(bytes));
199        validate_protocol_path(&path)?;
200        if self.display != path.to_string_lossy() {
201            return Err(invalid_data(
202                "plugin protocol path display text does not match its bytes",
203            ));
204        }
205        Ok(path)
206    }
207
208    fn validate(&self) -> io::Result<()> {
209        self.to_path_buf().map(|_| ())
210    }
211}
212
213#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
214#[serde(rename_all = "kebab-case")]
215pub enum PluginActionTargetKind {
216    File,
217    Directory,
218    Symlink,
219    Other,
220}
221
222#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
223#[serde(deny_unknown_fields)]
224pub struct PluginActionTarget {
225    pub path: PluginProtocolPath,
226    pub kind: PluginActionTargetKind,
227    pub extension: Option<String>,
228}
229
230impl PluginActionTarget {
231    pub fn from_path(path: &Path, kind: PluginActionTargetKind) -> io::Result<Self> {
232        let extension = path
233            .extension()
234            .and_then(OsStr::to_str)
235            .filter(|extension| validate_extension(extension).is_ok())
236            .map(str::to_ascii_lowercase);
237        Ok(Self {
238            path: PluginProtocolPath::from_path(path)?,
239            kind,
240            extension,
241        })
242    }
243
244    fn validate(&self) -> io::Result<()> {
245        self.path.validate()?;
246        if let Some(extension) = &self.extension {
247            validate_extension(extension)?;
248            if extension.bytes().any(|byte| byte.is_ascii_uppercase()) {
249                return Err(invalid_data(
250                    "plugin action target extensions must be lowercase",
251                ));
252            }
253        }
254        Ok(())
255    }
256}
257
258#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
259#[serde(deny_unknown_fields)]
260pub struct PluginActionContext {
261    pub current_directory: PluginProtocolPath,
262    pub selection: Vec<PluginActionTarget>,
263}
264
265impl PluginActionContext {
266    pub fn new(current_directory: &Path, selection: Vec<PluginActionTarget>) -> io::Result<Self> {
267        let context = Self {
268            current_directory: PluginProtocolPath::from_path(current_directory)?,
269            selection,
270        };
271        context.validate()?;
272        Ok(context)
273    }
274
275    pub fn validate(&self) -> io::Result<()> {
276        self.current_directory.validate()?;
277        if self.selection.len() > PLUGIN_ACTION_SELECTION_LIMIT {
278            return Err(invalid_data(format!(
279                "plugin action selection exceeds the {PLUGIN_ACTION_SELECTION_LIMIT}-item limit"
280            )));
281        }
282        let mut paths = BTreeSet::new();
283        for target in &self.selection {
284            target.validate()?;
285            if !paths.insert(&target.path.unix_bytes_hex) {
286                return Err(invalid_data("plugin action selection paths must be unique"));
287            }
288        }
289        Ok(())
290    }
291}
292
293#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
294#[serde(rename_all = "kebab-case")]
295pub enum PluginActionPlacement {
296    ContextMenu,
297    CommandPalette,
298}
299
300#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
301#[serde(rename_all = "kebab-case")]
302pub enum PluginActionIcon {
303    Plugin,
304    Tool,
305    Convert,
306    Sync,
307    Share,
308    Archive,
309}
310
311#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
312#[serde(rename_all = "kebab-case")]
313pub enum PluginActionCategory {
314    Tools,
315    Organize,
316    Convert,
317    Sync,
318    Share,
319}
320
321#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
322#[serde(deny_unknown_fields)]
323pub struct PluginActionMatcher {
324    pub min_selection: u16,
325    pub max_selection: u16,
326    pub kinds: Vec<PluginActionTargetKind>,
327    pub extensions: Vec<String>,
328}
329
330impl PluginActionMatcher {
331    pub fn validate(&self) -> io::Result<()> {
332        if self.min_selection > self.max_selection
333            || usize::from(self.max_selection) > PLUGIN_ACTION_SELECTION_LIMIT
334        {
335            return Err(invalid_data("invalid plugin action selection bounds"));
336        }
337        if self.kinds.len() > 4 || !all_unique(&self.kinds) {
338            return Err(invalid_data(
339                "plugin action target kinds must be unique and bounded",
340            ));
341        }
342        if self.extensions.len() > PLUGIN_ACTION_EXTENSION_LIMIT {
343            return Err(invalid_data("too many plugin action extensions"));
344        }
345        let mut extensions = BTreeSet::new();
346        for extension in &self.extensions {
347            validate_extension(extension)?;
348            if extension.bytes().any(|byte| byte.is_ascii_uppercase())
349                || !extensions.insert(extension)
350            {
351                return Err(invalid_data(
352                    "plugin action extensions must be unique lowercase values",
353                ));
354            }
355        }
356        if !self.extensions.is_empty() && self.max_selection == 0 {
357            return Err(invalid_data(
358                "extension-matched plugin actions must accept a selection",
359            ));
360        }
361        Ok(())
362    }
363
364    pub fn matches(&self, context: &PluginActionContext) -> bool {
365        if self.validate().is_err() || context.validate().is_err() {
366            return false;
367        }
368        let selection_count = context.selection.len();
369        if selection_count < usize::from(self.min_selection)
370            || selection_count > usize::from(self.max_selection)
371        {
372            return false;
373        }
374        if !self.kinds.is_empty()
375            && context
376                .selection
377                .iter()
378                .any(|target| !self.kinds.contains(&target.kind))
379        {
380            return false;
381        }
382        if !self.extensions.is_empty() && context.selection.is_empty() {
383            return false;
384        }
385        self.extensions.is_empty()
386            || context.selection.iter().all(|target| {
387                target
388                    .extension
389                    .as_ref()
390                    .is_some_and(|extension| self.extensions.contains(extension))
391            })
392    }
393}
394
395#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
396#[serde(deny_unknown_fields)]
397pub struct PluginActionMetadata {
398    pub id: String,
399    pub label: String,
400    pub description: String,
401    pub category: PluginActionCategory,
402    pub icon: PluginActionIcon,
403    pub placements: Vec<PluginActionPlacement>,
404    pub matcher: PluginActionMatcher,
405    pub destructive: bool,
406    pub confirmation: Option<String>,
407}
408
409impl PluginActionMetadata {
410    pub fn validate(&self) -> io::Result<()> {
411        validate_token(&self.id, "plugin action ID")?;
412        validate_text(&self.label, "plugin action label", 80)?;
413        validate_text(&self.description, "plugin action description", 280)?;
414        if self.placements.is_empty() || self.placements.len() > 2 || !all_unique(&self.placements)
415        {
416            return Err(invalid_data(
417                "plugin action placements must contain 1 to 2 unique values",
418            ));
419        }
420        if let Some(confirmation) = &self.confirmation {
421            validate_text(confirmation, "plugin action confirmation", 180)?;
422        }
423        if self.destructive && self.confirmation.is_none() {
424            return Err(invalid_data(
425                "destructive plugin actions must provide confirmation text",
426            ));
427        }
428        self.matcher.validate()
429    }
430
431    pub fn matches_context(&self, context: &PluginActionContext) -> bool {
432        self.validate().is_ok() && self.matcher.matches(context)
433    }
434}
435
436#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
437#[serde(rename_all = "kebab-case")]
438pub enum PluginActionOperation {
439    Probe,
440    Invoke,
441}
442
443impl PluginActionOperation {
444    fn argument(self) -> &'static str {
445        match self {
446            Self::Probe => "probe",
447            Self::Invoke => "invoke",
448        }
449    }
450}
451
452#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
453#[serde(deny_unknown_fields)]
454pub struct PluginActionProbeRequest {
455    pub protocol: u32,
456    pub plugin_id: Uuid,
457    pub operation: PluginActionOperation,
458    pub context: PluginActionContext,
459}
460
461impl PluginActionProbeRequest {
462    /// Validates a decoded probe request against the executable's own plugin ID.
463    pub fn validate(&self, expected_plugin_id: Uuid) -> io::Result<()> {
464        validate_protocol_identity(self.protocol, self.plugin_id, expected_plugin_id)?;
465        if self.operation != PluginActionOperation::Probe {
466            return Err(invalid_data(
467                "plugin action probe request has the wrong operation",
468            ));
469        }
470        self.context.validate()
471    }
472}
473
474/// Parses the bounded JSON request received by a plugin in `probe` mode.
475///
476/// Pass a locked stdin handle and the executable's manifest UUID. The helper
477/// reads at most [`PLUGIN_ACTION_IO_BYTES_LIMIT`] plus one sentinel byte, then
478/// rejects oversized or malformed JSON, protocol/UUID confusion, an `invoke`
479/// operation, and invalid or duplicate context paths.
480pub fn parse_plugin_action_probe_request(
481    input: impl Read,
482    expected_plugin_id: Uuid,
483) -> io::Result<PluginActionProbeRequest> {
484    let request: PluginActionProbeRequest = parse_plugin_protocol_request(input, "probe")?;
485    request.validate(expected_plugin_id)?;
486    Ok(request)
487}
488
489#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
490#[serde(deny_unknown_fields)]
491pub struct PluginActionProbeResponse {
492    pub protocol: u32,
493    pub plugin_id: Uuid,
494    pub actions: Vec<PluginActionMetadata>,
495}
496
497impl PluginActionProbeResponse {
498    pub fn validate(&self, expected_plugin_id: Uuid) -> io::Result<()> {
499        validate_protocol_identity(self.protocol, self.plugin_id, expected_plugin_id)?;
500        if self.actions.len() > PLUGIN_ACTION_LIMIT {
501            return Err(invalid_data(format!(
502                "plugin action probe exceeds the {PLUGIN_ACTION_LIMIT}-action limit"
503            )));
504        }
505        let mut ids = BTreeSet::new();
506        for action in &self.actions {
507            action.validate()?;
508            if !ids.insert(&action.id) {
509                return Err(invalid_data("plugin action IDs must be unique"));
510            }
511        }
512        Ok(())
513    }
514}
515
516#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
517#[serde(deny_unknown_fields)]
518pub struct PluginActionInvokeRequest {
519    pub protocol: u32,
520    pub plugin_id: Uuid,
521    pub operation: PluginActionOperation,
522    pub action_id: String,
523    pub context: PluginActionContext,
524}
525
526impl PluginActionInvokeRequest {
527    /// Validates a decoded invocation, including membership in the plugin's
528    /// bounded list of action IDs returned by its probe implementation.
529    pub fn validate(
530        &self,
531        expected_plugin_id: Uuid,
532        allowed_action_ids: &[&str],
533    ) -> io::Result<()> {
534        validate_protocol_identity(self.protocol, self.plugin_id, expected_plugin_id)?;
535        if self.operation != PluginActionOperation::Invoke {
536            return Err(invalid_data(
537                "plugin action invocation request has the wrong operation",
538            ));
539        }
540        validate_token(&self.action_id, "plugin action invocation ID")?;
541        validate_allowed_action_ids(allowed_action_ids)?;
542        if !allowed_action_ids.contains(&self.action_id.as_str()) {
543            return Err(invalid_data(
544                "plugin action invocation requested an unsupported action ID",
545            ));
546        }
547        self.context.validate()
548    }
549}
550
551/// Parses the bounded JSON request received by a plugin in `invoke` mode.
552///
553/// `allowed_action_ids` must contain the plugin's complete set of at most
554/// [`PLUGIN_ACTION_LIMIT`] unique, valid action IDs. This prevents a validly
555/// shaped request from dispatching an undeclared action.
556pub fn parse_plugin_action_invoke_request(
557    input: impl Read,
558    expected_plugin_id: Uuid,
559    allowed_action_ids: &[&str],
560) -> io::Result<PluginActionInvokeRequest> {
561    let request: PluginActionInvokeRequest = parse_plugin_protocol_request(input, "invocation")?;
562    request.validate(expected_plugin_id, allowed_action_ids)?;
563    Ok(request)
564}
565
566#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
567#[serde(rename_all = "kebab-case")]
568pub enum PluginActionOutcome {
569    Completed,
570    Cancelled,
571    Failed,
572}
573
574#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
575#[serde(deny_unknown_fields)]
576pub struct PluginActionResult {
577    pub protocol: u32,
578    pub plugin_id: Uuid,
579    pub action_id: String,
580    pub outcome: PluginActionOutcome,
581    pub message: Option<String>,
582    pub refresh: bool,
583}
584
585impl PluginActionResult {
586    pub fn validate(&self, expected_plugin_id: Uuid, expected_action_id: &str) -> io::Result<()> {
587        validate_protocol_identity(self.protocol, self.plugin_id, expected_plugin_id)?;
588        validate_token(&self.action_id, "plugin action result ID")?;
589        if self.action_id != expected_action_id {
590            return Err(invalid_data(
591                "plugin action result does not match the invoked action",
592            ));
593        }
594        if let Some(message) = &self.message {
595            validate_text(message, "plugin action result message", 280)?;
596        }
597        if self.outcome == PluginActionOutcome::Failed && self.message.is_none() {
598            return Err(invalid_data(
599                "failed plugin action results must include a message",
600            ));
601        }
602        Ok(())
603    }
604}
605
606#[derive(Clone, Debug, Eq, PartialEq)]
607pub struct PluginDiagnostic {
608    pub path: PathBuf,
609    pub message: String,
610}
611
612#[derive(Clone, Debug, Default, Eq, PartialEq)]
613pub struct PluginDiscoveryReport {
614    pub plugins: Vec<PluginManifest>,
615    pub action_contributions: Vec<DiscoveredActionContribution>,
616    pub diagnostics: Vec<PluginDiagnostic>,
617    pub diagnostics_limited: bool,
618    /// Whether an entry error or safety limit prevented discovery from
619    /// examining every candidate. Partial reports are suitable for
620    /// diagnostics, but must not be used to rewrite enabled-plugin state.
621    pub scan_truncated: bool,
622}
623
624pub fn plugin_data_dir() -> PathBuf {
625    std::env::var_os("XDG_DATA_HOME")
626        .map(PathBuf::from)
627        .unwrap_or_else(|| home_dir().join(".local/share"))
628        .join("guth/plugins")
629}
630
631pub fn enabled_plugins_path() -> PathBuf {
632    std::env::var_os("XDG_CONFIG_HOME")
633        .map(PathBuf::from)
634        .unwrap_or_else(|| home_dir().join(".config"))
635        .join("guth/enabled-plugins.conf")
636}
637
638/// Loads the persisted enabled IDs without discovering plugins or pruning stale entries.
639pub fn load_enabled_plugins() -> io::Result<BTreeSet<Uuid>> {
640    load_enabled_plugins_from(&enabled_plugins_path())
641}
642
643fn load_enabled_plugins_from(path: &Path) -> io::Result<BTreeSet<Uuid>> {
644    let file = match open_verified_file(path, false, true) {
645        Ok(file) => file,
646        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
647        Err(error) => return Err(error),
648    };
649    let metadata = file.metadata()?;
650    if metadata.len() > ENABLED_PLUGIN_BYTES_LIMIT {
651        return Err(invalid_data("enabled plugin file is too large"));
652    }
653    let mut bytes = Vec::with_capacity(metadata.len() as usize);
654    file.take(ENABLED_PLUGIN_BYTES_LIMIT + 1)
655        .read_to_end(&mut bytes)?;
656    let text = std::str::from_utf8(&bytes)
657        .map_err(|_| invalid_data("enabled plugin file must be UTF-8"))?;
658    let mut enabled = BTreeSet::new();
659    for line in text.lines() {
660        if line.is_empty() {
661            continue;
662        }
663        let id = parse_plugin_id(line)?;
664        if !enabled.insert(id) || enabled.len() > PLUGIN_LIMIT {
665            return Err(invalid_data("enabled plugin file contains invalid entries"));
666        }
667    }
668    Ok(enabled)
669}
670
671pub fn set_plugin_enabled(id: Uuid, enabled: bool) -> io::Result<()> {
672    validate_plugin_id(id)?;
673    let path = enabled_plugins_path();
674    let directory_file = lock_enabled_plugin_directory(&path)?;
675    let mut ids = load_enabled_plugins_from(&path)?;
676    if enabled {
677        if ids.len() >= PLUGIN_LIMIT && !ids.contains(&id) {
678            return Err(invalid_data("enabled plugin limit reached"));
679        }
680        ids.insert(id);
681    } else {
682        ids.remove(&id);
683    }
684    write_enabled_plugins_locked(&ids, &path, &directory_file)
685}
686
687pub fn prune_uninstalled_enabled_plugins() -> io::Result<BTreeSet<Uuid>> {
688    let plugin_directory = plugin_data_dir();
689    if let Some(parent) = plugin_directory.parent() {
690        create_private_dir(parent)?;
691    }
692    let plugin_directory_file = create_private_dir(&plugin_directory)?;
693    rustix::fs::flock(
694        &plugin_directory_file,
695        rustix::fs::FlockOperation::LockExclusive,
696    )
697    .map_err(errno_error)?;
698    let installed = discover_plugins_in(&plugin_directory)?
699        .into_iter()
700        .map(|plugin| plugin.id)
701        .collect::<BTreeSet<_>>();
702    let path = enabled_plugins_path();
703    let directory_file = lock_enabled_plugin_directory(&path)?;
704    let mut ids = load_enabled_plugins_from(&path)?;
705    let original_len = ids.len();
706    ids.retain(|id| installed.contains(id));
707    if ids.len() != original_len {
708        write_enabled_plugins_locked(&ids, &path, &directory_file)?;
709    }
710    Ok(ids)
711}
712
713pub fn discover_plugins() -> io::Result<Vec<PluginManifest>> {
714    require_complete_discovery(discover_plugins_with_diagnostics()?)
715}
716
717pub fn discover_plugins_in(directory: &Path) -> io::Result<Vec<PluginManifest>> {
718    require_complete_discovery(discover_plugins_in_with_diagnostics(directory)?)
719}
720
721pub fn discover_plugins_with_diagnostics() -> io::Result<PluginDiscoveryReport> {
722    discover_plugins_in_with_diagnostics(&plugin_data_dir())
723}
724
725pub fn discover_plugins_in_with_diagnostics(directory: &Path) -> io::Result<PluginDiscoveryReport> {
726    let mut manifests = Vec::new();
727    let mut action_contributions = Vec::new();
728    let mut diagnostics = Vec::new();
729    let mut diagnostics_limited = false;
730    let mut scan_truncated = false;
731    let entries = match fs::read_dir(directory) {
732        Ok(entries) => entries,
733        Err(error) if error.kind() == io::ErrorKind::NotFound => {
734            return Ok(PluginDiscoveryReport::default())
735        }
736        Err(error) => return Err(error),
737    };
738    let mut paths = Vec::new();
739    for (index, entry) in entries.enumerate() {
740        if index >= PLUGIN_DIRECTORY_ENTRY_LIMIT {
741            push_plugin_diagnostic(
742                &mut diagnostics,
743                &mut diagnostics_limited,
744                directory.to_path_buf(),
745                format!(
746                    "Plugin directory scan stopped after {PLUGIN_DIRECTORY_ENTRY_LIMIT} entries; remove stale or unrelated files and refresh"
747                ),
748            );
749            diagnostics_limited = true;
750            scan_truncated = true;
751            break;
752        }
753        let entry = match entry {
754            Ok(entry) => entry,
755            Err(error) => {
756                scan_truncated = true;
757                push_plugin_diagnostic(
758                    &mut diagnostics,
759                    &mut diagnostics_limited,
760                    directory.to_path_buf(),
761                    format!("Could not inspect directory entry: {error}"),
762                );
763                continue;
764            }
765        };
766        let path = entry.path();
767        let is_json = path
768            .extension()
769            .and_then(|extension| extension.to_str())
770            .is_some_and(|extension| extension.eq_ignore_ascii_case("json"));
771        if !is_json {
772            continue;
773        }
774        if path
775            .extension()
776            .is_some_and(|extension| extension != "json")
777        {
778            push_plugin_diagnostic(
779                &mut diagnostics,
780                &mut diagnostics_limited,
781                path,
782                "Manifest filename must use the lowercase .json extension".to_string(),
783            );
784            continue;
785        }
786        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
787            push_plugin_diagnostic(
788                &mut diagnostics,
789                &mut diagnostics_limited,
790                path,
791                "Manifest filename must be a canonical lowercase UUIDv7 followed by .json"
792                    .to_string(),
793            );
794            continue;
795        };
796        let Ok(id) = Uuid::parse_str(stem) else {
797            push_plugin_diagnostic(
798                &mut diagnostics,
799                &mut diagnostics_limited,
800                path,
801                "Manifest filename must be a canonical lowercase UUIDv7 followed by .json"
802                    .to_string(),
803            );
804            continue;
805        };
806        if validate_plugin_id(id).is_err() || id.to_string() != stem {
807            push_plugin_diagnostic(
808                &mut diagnostics,
809                &mut diagnostics_limited,
810                path,
811                "Manifest filename must be a canonical lowercase UUIDv7 followed by .json"
812                    .to_string(),
813            );
814            continue;
815        }
816        paths.push((id, path));
817    }
818    paths.sort();
819    if paths.len() > PLUGIN_LIMIT {
820        scan_truncated = true;
821        push_plugin_diagnostic(
822            &mut diagnostics,
823            &mut diagnostics_limited,
824            directory.to_path_buf(),
825            format!(
826                "Plugin limit reached: discovered {} candidates, loaded at most {PLUGIN_LIMIT}",
827                paths.len()
828            ),
829        );
830    }
831    for (expected_id, path) in paths.into_iter().take(PLUGIN_LIMIT) {
832        let file = match open_verified_file(&path, false, true) {
833            Ok(file) => file,
834            Err(error) => {
835                push_plugin_diagnostic(
836                    &mut diagnostics,
837                    &mut diagnostics_limited,
838                    path,
839                    format!("Manifest is not a safe private file: {error}"),
840                );
841                continue;
842            }
843        };
844        let metadata = match file.metadata() {
845            Ok(metadata) => metadata,
846            Err(error) => {
847                push_plugin_diagnostic(
848                    &mut diagnostics,
849                    &mut diagnostics_limited,
850                    path,
851                    format!("Could not inspect manifest metadata: {error}"),
852                );
853                continue;
854            }
855        };
856        if metadata.len() > MANIFEST_BYTES_LIMIT {
857            push_plugin_diagnostic(
858                &mut diagnostics,
859                &mut diagnostics_limited,
860                path,
861                format!("Manifest exceeds the {MANIFEST_BYTES_LIMIT}-byte limit"),
862            );
863            continue;
864        }
865        let mut bytes = Vec::with_capacity(metadata.len() as usize);
866        if let Err(error) = file.take(MANIFEST_BYTES_LIMIT + 1).read_to_end(&mut bytes) {
867            push_plugin_diagnostic(
868                &mut diagnostics,
869                &mut diagnostics_limited,
870                path,
871                format!("Could not read manifest: {error}"),
872            );
873            continue;
874        }
875        #[derive(Deserialize)]
876        struct SchemaProbe {
877            schema: u32,
878        }
879        let schema = match serde_json::from_slice::<SchemaProbe>(&bytes) {
880            Ok(probe) => probe.schema,
881            Err(error) => {
882                push_plugin_diagnostic(
883                    &mut diagnostics,
884                    &mut diagnostics_limited,
885                    path,
886                    format!("Manifest JSON is invalid: {error}"),
887                );
888                continue;
889            }
890        };
891        let (manifest, action_contribution) = match schema {
892            MANIFEST_SCHEMA => match serde_json::from_slice::<PluginManifest>(&bytes) {
893                Ok(manifest) => (manifest, None),
894                Err(error) => {
895                    push_plugin_diagnostic(
896                        &mut diagnostics,
897                        &mut diagnostics_limited,
898                        path,
899                        format!("Manifest JSON is invalid: {error}"),
900                    );
901                    continue;
902                }
903            },
904            ACTION_MANIFEST_SCHEMA => {
905                let action_manifest = match serde_json::from_slice::<PluginActionManifest>(&bytes) {
906                    Ok(manifest) => manifest,
907                    Err(error) => {
908                        push_plugin_diagnostic(
909                            &mut diagnostics,
910                            &mut diagnostics_limited,
911                            path,
912                            format!("Manifest JSON is invalid: {error}"),
913                        );
914                        continue;
915                    }
916                };
917                if let Err(error) = action_manifest.validate() {
918                    push_plugin_diagnostic(
919                        &mut diagnostics,
920                        &mut diagnostics_limited,
921                        path,
922                        format!("Manifest contract is invalid: {error}"),
923                    );
924                    continue;
925                }
926                let declaration = action_manifest.contributions.actions.clone();
927                (action_manifest.plugin(), Some(declaration))
928            }
929            _ => {
930                push_plugin_diagnostic(
931                    &mut diagnostics,
932                    &mut diagnostics_limited,
933                    path,
934                    "Manifest contract is invalid: unsupported plugin manifest schema".to_string(),
935                );
936                continue;
937            }
938        };
939        if manifest.id != expected_id {
940            push_plugin_diagnostic(
941                &mut diagnostics,
942                &mut diagnostics_limited,
943                path,
944                "Manifest UUID does not match its filename".to_string(),
945            );
946            continue;
947        }
948        let contract_result = match action_contribution.as_ref() {
949            Some(contribution) => validate_action_contributor(&manifest, contribution),
950            None => manifest.validate(),
951        };
952        if let Err(error) = contract_result {
953            push_plugin_diagnostic(
954                &mut diagnostics,
955                &mut diagnostics_limited,
956                path,
957                format!("Manifest contract is invalid: {error}"),
958            );
959            continue;
960        }
961        if let Err(error) = open_verified_file(&manifest.executable, true, false) {
962            push_plugin_diagnostic(
963                &mut diagnostics,
964                &mut diagnostics_limited,
965                path,
966                format!(
967                    "Plugin executable was quarantined: {error}. Restore trusted ownership, remove group/world or special permission bits, and ensure an execute bit is set"
968                ),
969            );
970            continue;
971        }
972        if let Some(declaration) = action_contribution {
973            action_contributions.push(DiscoveredActionContribution {
974                plugin_id: manifest.id,
975                declaration,
976            });
977        }
978        manifests.push(manifest);
979    }
980    manifests.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
981    manifests.dedup_by_key(|manifest| manifest.id);
982    action_contributions.sort_by_key(|contribution| contribution.plugin_id);
983    action_contributions.dedup_by_key(|contribution| contribution.plugin_id);
984    Ok(PluginDiscoveryReport {
985        plugins: manifests,
986        action_contributions,
987        diagnostics,
988        diagnostics_limited,
989        scan_truncated,
990    })
991}
992
993fn require_complete_discovery(report: PluginDiscoveryReport) -> io::Result<Vec<PluginManifest>> {
994    if report.scan_truncated {
995        return Err(invalid_data(
996            "plugin discovery was incomplete; review diagnostics before retrying",
997        ));
998    }
999    Ok(report.plugins)
1000}
1001
1002fn push_plugin_diagnostic(
1003    diagnostics: &mut Vec<PluginDiagnostic>,
1004    diagnostics_limited: &mut bool,
1005    path: PathBuf,
1006    message: String,
1007) {
1008    if diagnostics.len() < PLUGIN_DIAGNOSTIC_LIMIT {
1009        diagnostics.push(PluginDiagnostic { path, message });
1010    } else {
1011        *diagnostics_limited = true;
1012    }
1013}
1014
1015pub fn install_manifest(manifest: &PluginManifest) -> io::Result<PathBuf> {
1016    install_manifest_in(manifest, &plugin_data_dir())
1017}
1018
1019pub fn install_manifest_in(manifest: &PluginManifest, directory: &Path) -> io::Result<PathBuf> {
1020    if manifest.schema != MANIFEST_SCHEMA {
1021        return Err(invalid_data(
1022            "use install_action_manifest for schema-2 GUI action manifests",
1023        ));
1024    }
1025    manifest.validate()?;
1026    let bytes = serde_json::to_vec_pretty(manifest).map_err(invalid_json)?;
1027    install_manifest_bytes(manifest.id, &manifest.executable, &bytes, directory)
1028}
1029
1030pub fn install_action_manifest(manifest: &PluginActionManifest) -> io::Result<PathBuf> {
1031    install_action_manifest_in(manifest, &plugin_data_dir())
1032}
1033
1034pub fn install_action_manifest_in(
1035    manifest: &PluginActionManifest,
1036    directory: &Path,
1037) -> io::Result<PathBuf> {
1038    manifest.validate()?;
1039    let bytes = serde_json::to_vec_pretty(manifest).map_err(invalid_json)?;
1040    install_manifest_bytes(manifest.id, &manifest.executable, &bytes, directory)
1041}
1042
1043fn install_manifest_bytes(
1044    id: Uuid,
1045    executable: &Path,
1046    bytes: &[u8],
1047    directory: &Path,
1048) -> io::Result<PathBuf> {
1049    if bytes.len() as u64 > MANIFEST_BYTES_LIMIT {
1050        return Err(invalid_data(format!(
1051            "manifest exceeds the {MANIFEST_BYTES_LIMIT}-byte limit"
1052        )));
1053    }
1054    open_verified_file(executable, true, false)?;
1055    if let Some(parent) = directory.parent() {
1056        create_private_dir(parent)?;
1057    }
1058    let directory_file = create_private_dir(directory)?;
1059    rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
1060        .map_err(errno_error)?;
1061    let destination = directory.join(format!("{id}.json"));
1062    let destination_name = format!("{id}.json");
1063    let temporary_name = format!(".{id}.{}.tmp", Uuid::now_v7());
1064    if !destination.exists() && manifest_count(directory)? >= PLUGIN_LIMIT {
1065        return Err(invalid_data("plugin limit reached"));
1066    }
1067    let result = (|| {
1068        let owned_fd = rustix::fs::openat(
1069            &directory_file,
1070            temporary_name.as_str(),
1071            rustix::fs::OFlags::WRONLY
1072                | rustix::fs::OFlags::CREATE
1073                | rustix::fs::OFlags::EXCL
1074                | rustix::fs::OFlags::NOFOLLOW
1075                | rustix::fs::OFlags::CLOEXEC,
1076            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
1077        )
1078        .map_err(errno_error)?;
1079        let mut file = fs::File::from(owned_fd);
1080        file.write_all(bytes)?;
1081        file.write_all(b"\n")?;
1082        file.sync_all()?;
1083        drop(file);
1084        rustix::fs::renameat(
1085            &directory_file,
1086            temporary_name.as_str(),
1087            &directory_file,
1088            destination_name.as_str(),
1089        )
1090        .map_err(errno_error)?;
1091        directory_file.sync_all()?;
1092        Ok(destination.clone())
1093    })();
1094    if result.is_err() {
1095        let _ = rustix::fs::unlinkat(
1096            &directory_file,
1097            temporary_name.as_str(),
1098            rustix::fs::AtFlags::empty(),
1099        );
1100    }
1101    result
1102}
1103
1104pub fn run_plugin(
1105    manifest: &PluginManifest,
1106    arguments: impl IntoIterator<Item = OsString>,
1107) -> io::Result<ExitStatus> {
1108    manifest.validate()?;
1109    let executable = open_verified_file(&manifest.executable, true, false)?;
1110    rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty()).map_err(errno_error)?;
1111    let executable_path = format!("/proc/self/fd/{}", executable.as_raw_fd());
1112    let mut command = Command::new(executable_path);
1113    command
1114        .args(arguments)
1115        .stdin(Stdio::inherit())
1116        .stdout(Stdio::inherit())
1117        .stderr(Stdio::inherit());
1118    spawn_plugin_command(&mut command)?.wait()
1119}
1120
1121#[derive(Debug)]
1122pub struct PluginOutput {
1123    pub status: ExitStatus,
1124    pub stdout: Vec<u8>,
1125    pub stderr: Vec<u8>,
1126}
1127
1128pub struct PluginProcess {
1129    child: Option<Child>,
1130    process_group: rustix::process::Pid,
1131    stdin_writer: Option<Receiver<io::Result<()>>>,
1132    stdout_reader: Option<Receiver<io::Result<Vec<u8>>>>,
1133    stderr_reader: Option<Receiver<io::Result<Vec<u8>>>>,
1134    cancellation_requested_at: Option<Instant>,
1135}
1136
1137impl PluginProcess {
1138    pub fn try_wait(&mut self) -> io::Result<Option<PluginOutput>> {
1139        if self
1140            .cancellation_requested_at
1141            .is_some_and(|started| started.elapsed() >= PLUGIN_SHUTDOWN_TIMEOUT)
1142        {
1143            let _ = rustix::process::kill_process_group(
1144                self.process_group,
1145                rustix::process::Signal::KILL,
1146            );
1147        }
1148        let Some(child) = self.child.as_mut() else {
1149            return Err(io::Error::other("plugin process was already collected"));
1150        };
1151        let Some(status) = child.try_wait()? else {
1152            return Ok(None);
1153        };
1154        self.child.take();
1155        let _ =
1156            rustix::process::kill_process_group(self.process_group, rustix::process::Signal::KILL);
1157        collect_input(&mut self.stdin_writer)?;
1158        let stdout = collect_output(&mut self.stdout_reader)?;
1159        let stderr = collect_output(&mut self.stderr_reader)?;
1160        Ok(Some(PluginOutput {
1161            status,
1162            stdout,
1163            stderr,
1164        }))
1165    }
1166
1167    pub fn cancel(&mut self) -> io::Result<()> {
1168        if self.cancellation_requested_at.is_some() {
1169            return Ok(());
1170        }
1171        match rustix::process::kill_process_group(self.process_group, rustix::process::Signal::TERM)
1172        {
1173            Ok(()) | Err(rustix::io::Errno::SRCH) => {}
1174            Err(error) => return Err(errno_error(error)),
1175        }
1176        self.cancellation_requested_at = Some(Instant::now());
1177        Ok(())
1178    }
1179
1180    pub fn cancellation_requested(&self) -> bool {
1181        self.cancellation_requested_at.is_some()
1182    }
1183}
1184
1185impl Drop for PluginProcess {
1186    fn drop(&mut self) {
1187        let Some(child) = self.child.as_mut() else {
1188            return;
1189        };
1190        let _ =
1191            rustix::process::kill_process_group(self.process_group, rustix::process::Signal::TERM);
1192        let started = Instant::now();
1193        while started.elapsed() < PLUGIN_SHUTDOWN_TIMEOUT {
1194            match child.try_wait() {
1195                Ok(Some(_)) => break,
1196                Ok(None) => thread::sleep(PLUGIN_SHUTDOWN_POLL_INTERVAL),
1197                Err(_) => break,
1198            }
1199        }
1200        let _ =
1201            rustix::process::kill_process_group(self.process_group, rustix::process::Signal::KILL);
1202        if child.try_wait().ok().flatten().is_none() {
1203            let _ = child.kill();
1204            let _ = child.wait();
1205        }
1206        self.child.take();
1207        self.stdin_writer.take();
1208        self.stdout_reader.take();
1209        self.stderr_reader.take();
1210    }
1211}
1212
1213pub fn spawn_plugin(
1214    manifest: &PluginManifest,
1215    arguments: impl IntoIterator<Item = OsString>,
1216) -> io::Result<PluginProcess> {
1217    spawn_plugin_process(manifest, arguments, None, RuntimeManifestKind::Command)
1218}
1219
1220#[derive(Clone, Copy)]
1221enum RuntimeManifestKind {
1222    Command,
1223    GuiAction,
1224}
1225
1226fn spawn_plugin_process(
1227    manifest: &PluginManifest,
1228    arguments: impl IntoIterator<Item = OsString>,
1229    input: Option<Vec<u8>>,
1230    kind: RuntimeManifestKind,
1231) -> io::Result<PluginProcess> {
1232    validate_runtime_manifest(manifest, kind)?;
1233    let executable = open_verified_file(&manifest.executable, true, false)?;
1234    rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty()).map_err(errno_error)?;
1235    let executable_path = format!("/proc/self/fd/{}", executable.as_raw_fd());
1236    let mut command = Command::new(executable_path);
1237    command.args(arguments);
1238    command.stdin(if input.is_some() {
1239        Stdio::piped()
1240    } else {
1241        Stdio::null()
1242    });
1243    command.stdout(Stdio::piped()).stderr(Stdio::piped());
1244    #[cfg(unix)]
1245    {
1246        use std::os::unix::process::CommandExt;
1247        command.process_group(0);
1248    }
1249    let mut child = spawn_plugin_command(&mut command)?;
1250    let process_group = match i32::try_from(child.id())
1251        .ok()
1252        .and_then(rustix::process::Pid::from_raw)
1253    {
1254        Some(process_group) => process_group,
1255        None => {
1256            let _ = child.kill();
1257            let _ = child.wait();
1258            return Err(io::Error::other("plugin process ID is out of range"));
1259        }
1260    };
1261    let stdin_writer = if let Some(input) = input {
1262        let Some(stdin) = child.stdin.take() else {
1263            terminate_failed_spawn(&mut child, process_group);
1264            return Err(io::Error::other("plugin stdin was not captured"));
1265        };
1266        match spawn_input_writer(stdin, input) {
1267            Ok(writer) => Some(writer),
1268            Err(error) => {
1269                terminate_failed_spawn(&mut child, process_group);
1270                return Err(error);
1271            }
1272        }
1273    } else {
1274        None
1275    };
1276    let Some(stdout) = child.stdout.take() else {
1277        terminate_failed_spawn(&mut child, process_group);
1278        return Err(io::Error::other("plugin stdout was not captured"));
1279    };
1280    let Some(stderr) = child.stderr.take() else {
1281        terminate_failed_spawn(&mut child, process_group);
1282        return Err(io::Error::other("plugin stderr was not captured"));
1283    };
1284    let stdout_reader = match spawn_output_reader("guth-plugin-stdout", stdout) {
1285        Ok(reader) => reader,
1286        Err(error) => {
1287            terminate_failed_spawn(&mut child, process_group);
1288            return Err(error);
1289        }
1290    };
1291    let stderr_reader = match spawn_output_reader("guth-plugin-stderr", stderr) {
1292        Ok(reader) => reader,
1293        Err(error) => {
1294            terminate_failed_spawn(&mut child, process_group);
1295            drop(stdout_reader);
1296            return Err(error);
1297        }
1298    };
1299    Ok(PluginProcess {
1300        child: Some(child),
1301        process_group,
1302        stdin_writer,
1303        stdout_reader: Some(stdout_reader),
1304        stderr_reader: Some(stderr_reader),
1305        cancellation_requested_at: None,
1306    })
1307}
1308
1309/// Starts a bounded schema-2 action probe. The executable receives exactly
1310/// `--guth-action-protocol 1 probe`; its request is a single JSON object on
1311/// stdin and its response must be a single versioned JSON object on stdout.
1312pub fn spawn_plugin_action_probe(
1313    manifest: &PluginManifest,
1314    contribution: &PluginActionContribution,
1315    context: &PluginActionContext,
1316) -> io::Result<PluginProcess> {
1317    validate_action_contributor(manifest, contribution)?;
1318    context.validate()?;
1319    let request = PluginActionProbeRequest {
1320        protocol: ACTION_PROTOCOL_VERSION,
1321        plugin_id: manifest.id,
1322        operation: PluginActionOperation::Probe,
1323        context: context.clone(),
1324    };
1325    spawn_plugin_protocol(manifest, PluginActionOperation::Probe, &request)
1326}
1327
1328/// Parses and validates a completed action probe without trusting plugin IDs,
1329/// action identifiers, menu placement, or selection matchers from stdout.
1330pub fn parse_plugin_action_probe(
1331    manifest: &PluginManifest,
1332    contribution: &PluginActionContribution,
1333    output: &PluginOutput,
1334) -> io::Result<Vec<PluginActionMetadata>> {
1335    validate_action_contributor(manifest, contribution)?;
1336    let response: PluginActionProbeResponse = parse_plugin_protocol_output(output, "probe")?;
1337    response.validate(manifest.id)?;
1338    Ok(response.actions)
1339}
1340
1341/// Blocking probe convenience for worker threads and headless callers. GUI
1342/// code should run this away from the render thread or use the spawn/parse pair.
1343pub fn probe_plugin_actions(
1344    manifest: &PluginManifest,
1345    contribution: &PluginActionContribution,
1346    context: &PluginActionContext,
1347) -> io::Result<Vec<PluginActionMetadata>> {
1348    let mut process = spawn_plugin_action_probe(manifest, contribution, context)?;
1349    let started = Instant::now();
1350    loop {
1351        if let Some(output) = process.try_wait()? {
1352            return parse_plugin_action_probe(manifest, contribution, &output);
1353        }
1354        if started.elapsed() >= PLUGIN_ACTION_PROBE_TIMEOUT {
1355            process.cancel()?;
1356            return Err(io::Error::new(
1357                io::ErrorKind::TimedOut,
1358                "plugin action probe exceeded the 2-second limit",
1359            ));
1360        }
1361        thread::sleep(Duration::from_millis(5));
1362    }
1363}
1364
1365/// Starts an action previously returned by a validated probe. Invocation is
1366/// rejected when the action no longer matches the supplied context.
1367pub fn spawn_plugin_action(
1368    manifest: &PluginManifest,
1369    contribution: &PluginActionContribution,
1370    action: &PluginActionMetadata,
1371    context: &PluginActionContext,
1372) -> io::Result<PluginProcess> {
1373    validate_action_contributor(manifest, contribution)?;
1374    action.validate()?;
1375    context.validate()?;
1376    if !action.matches_context(context) {
1377        return Err(invalid_data(
1378            "plugin action does not match the current file context",
1379        ));
1380    }
1381    let request = PluginActionInvokeRequest {
1382        protocol: ACTION_PROTOCOL_VERSION,
1383        plugin_id: manifest.id,
1384        operation: PluginActionOperation::Invoke,
1385        action_id: action.id.clone(),
1386        context: context.clone(),
1387    };
1388    spawn_plugin_protocol(manifest, PluginActionOperation::Invoke, &request)
1389}
1390
1391/// Runs a validated action to completion for worker-thread and headless use.
1392/// The process is cancelled after five minutes and is always reaped by the
1393/// bounded [`PluginProcess`] shutdown path.
1394pub fn invoke_plugin_action(
1395    manifest: &PluginManifest,
1396    contribution: &PluginActionContribution,
1397    action: &PluginActionMetadata,
1398    context: &PluginActionContext,
1399) -> io::Result<PluginActionResult> {
1400    let mut process = spawn_plugin_action(manifest, contribution, action, context)?;
1401    let started = Instant::now();
1402    loop {
1403        if let Some(output) = process.try_wait()? {
1404            return parse_plugin_action_result(manifest, contribution, action, &output);
1405        }
1406        if started.elapsed() >= PLUGIN_ACTION_INVOKE_TIMEOUT {
1407            process.cancel()?;
1408            return Err(io::Error::new(
1409                io::ErrorKind::TimedOut,
1410                "plugin action exceeded the 5-minute limit",
1411            ));
1412        }
1413        thread::sleep(Duration::from_millis(10));
1414    }
1415}
1416
1417pub fn parse_plugin_action_result(
1418    manifest: &PluginManifest,
1419    contribution: &PluginActionContribution,
1420    action: &PluginActionMetadata,
1421    output: &PluginOutput,
1422) -> io::Result<PluginActionResult> {
1423    validate_action_contributor(manifest, contribution)?;
1424    action.validate()?;
1425    let result: PluginActionResult = parse_plugin_protocol_output(output, "invocation")?;
1426    result.validate(manifest.id, &action.id)?;
1427    Ok(result)
1428}
1429
1430fn spawn_plugin_protocol(
1431    manifest: &PluginManifest,
1432    operation: PluginActionOperation,
1433    request: &impl Serialize,
1434) -> io::Result<PluginProcess> {
1435    let input = serialize_plugin_protocol_request(request)?;
1436    spawn_plugin_process(
1437        manifest,
1438        [
1439            OsString::from(ACTION_PROTOCOL_ARGUMENT),
1440            OsString::from(ACTION_PROTOCOL_VERSION.to_string()),
1441            OsString::from(operation.argument()),
1442        ],
1443        Some(input),
1444        RuntimeManifestKind::GuiAction,
1445    )
1446}
1447
1448fn validate_action_contributor(
1449    manifest: &PluginManifest,
1450    contribution: &PluginActionContribution,
1451) -> io::Result<()> {
1452    contribution.validate()?;
1453    if manifest.schema != ACTION_MANIFEST_SCHEMA
1454        || !manifest.supports(ACTION_CONTRIBUTION_CAPABILITY)
1455    {
1456        return Err(invalid_data(
1457            "plugin did not opt into the schema-2 GUI action contract",
1458        ));
1459    }
1460    manifest.validate_common()
1461}
1462
1463fn validate_runtime_manifest(
1464    manifest: &PluginManifest,
1465    kind: RuntimeManifestKind,
1466) -> io::Result<()> {
1467    match kind {
1468        RuntimeManifestKind::Command => manifest.validate(),
1469        RuntimeManifestKind::GuiAction
1470            if manifest.schema == ACTION_MANIFEST_SCHEMA
1471                && manifest.supports(ACTION_CONTRIBUTION_CAPABILITY) =>
1472        {
1473            manifest.validate_common()
1474        }
1475        RuntimeManifestKind::GuiAction => Err(invalid_data(
1476            "GUI action runtime plugins must use schema 2 and declare gui-actions",
1477        )),
1478    }
1479}
1480
1481fn serialize_plugin_protocol_request(request: &impl Serialize) -> io::Result<Vec<u8>> {
1482    let mut input = serde_json::to_vec(request)
1483        .map_err(|error| invalid_data(format!("invalid plugin protocol request: {error}")))?;
1484    if input.len().saturating_add(1) > PLUGIN_ACTION_IO_BYTES_LIMIT {
1485        return Err(invalid_data(format!(
1486            "plugin protocol request exceeds the {PLUGIN_ACTION_IO_BYTES_LIMIT}-byte limit"
1487        )));
1488    }
1489    input.push(b'\n');
1490    Ok(input)
1491}
1492
1493fn parse_plugin_protocol_request<T: DeserializeOwned>(
1494    input: impl Read,
1495    operation: &str,
1496) -> io::Result<T> {
1497    let mut bytes = Vec::new();
1498    input
1499        .take(PLUGIN_ACTION_IO_BYTES_LIMIT as u64 + 1)
1500        .read_to_end(&mut bytes)?;
1501    if bytes.len() > PLUGIN_ACTION_IO_BYTES_LIMIT {
1502        return Err(invalid_data(format!(
1503            "plugin action {operation} request exceeds the {PLUGIN_ACTION_IO_BYTES_LIMIT}-byte limit"
1504        )));
1505    }
1506    serde_json::from_slice(&bytes).map_err(|error| {
1507        invalid_data(format!(
1508            "invalid plugin action {operation} request: {error}"
1509        ))
1510    })
1511}
1512
1513fn validate_allowed_action_ids(action_ids: &[&str]) -> io::Result<()> {
1514    if action_ids.is_empty() || action_ids.len() > PLUGIN_ACTION_LIMIT {
1515        return Err(invalid_data(format!(
1516            "allowed plugin action IDs must contain 1 to {PLUGIN_ACTION_LIMIT} entries"
1517        )));
1518    }
1519    let mut unique = BTreeSet::new();
1520    for action_id in action_ids {
1521        validate_token(action_id, "allowed plugin action ID")?;
1522        if !unique.insert(*action_id) {
1523            return Err(invalid_data("allowed plugin action IDs must be unique"));
1524        }
1525    }
1526    Ok(())
1527}
1528
1529fn parse_plugin_protocol_output<T: DeserializeOwned>(
1530    output: &PluginOutput,
1531    operation: &str,
1532) -> io::Result<T> {
1533    if !output.status.success() {
1534        let stderr = bounded_protocol_message(&output.stderr);
1535        let detail = if stderr.is_empty() {
1536            String::new()
1537        } else {
1538            format!(": {stderr}")
1539        };
1540        return Err(io::Error::other(format!(
1541            "plugin action {operation} exited unsuccessfully{detail}"
1542        )));
1543    }
1544    if output.stdout.len() > PLUGIN_ACTION_IO_BYTES_LIMIT {
1545        return Err(invalid_data(format!(
1546            "plugin action {operation} response exceeds the {PLUGIN_ACTION_IO_BYTES_LIMIT}-byte limit"
1547        )));
1548    }
1549    serde_json::from_slice(&output.stdout).map_err(|error| {
1550        invalid_data(format!(
1551            "invalid plugin action {operation} response: {error}"
1552        ))
1553    })
1554}
1555
1556fn bounded_protocol_message(bytes: &[u8]) -> String {
1557    let normalized = String::from_utf8_lossy(bytes)
1558        .split_whitespace()
1559        .collect::<Vec<_>>()
1560        .join(" ");
1561    normalized.chars().take(512).collect()
1562}
1563
1564fn spawn_plugin_command(command: &mut Command) -> io::Result<Child> {
1565    for attempt in 0..=PLUGIN_SPAWN_BUSY_RETRIES {
1566        match command.spawn() {
1567            Ok(child) => return Ok(child),
1568            Err(error)
1569                if error.raw_os_error() == Some(rustix::io::Errno::TXTBSY.raw_os_error())
1570                    && attempt < PLUGIN_SPAWN_BUSY_RETRIES =>
1571            {
1572                thread::sleep(PLUGIN_SPAWN_BUSY_RETRY_DELAY);
1573            }
1574            Err(error) => return Err(error),
1575        }
1576    }
1577    unreachable!("bounded plugin spawn loop always returns")
1578}
1579
1580fn spawn_output_reader(
1581    name: &str,
1582    reader: impl Read + Send + 'static,
1583) -> io::Result<Receiver<io::Result<Vec<u8>>>> {
1584    let (sender, receiver) = mpsc::sync_channel(1);
1585    thread::Builder::new()
1586        .name(name.to_string())
1587        .spawn(move || {
1588            let _ = sender.send(read_bounded(reader));
1589        })?;
1590    Ok(receiver)
1591}
1592
1593fn spawn_input_writer(
1594    mut writer: impl Write + Send + 'static,
1595    input: Vec<u8>,
1596) -> io::Result<Receiver<io::Result<()>>> {
1597    let (sender, receiver) = mpsc::sync_channel(1);
1598    thread::Builder::new()
1599        .name("guth-plugin-stdin".to_string())
1600        .spawn(move || {
1601            let result = writer.write_all(&input).and_then(|()| writer.flush());
1602            let _ = sender.send(result);
1603        })?;
1604    Ok(receiver)
1605}
1606
1607fn terminate_failed_spawn(child: &mut Child, process_group: rustix::process::Pid) {
1608    let _ = rustix::process::kill_process_group(process_group, rustix::process::Signal::KILL);
1609    let _ = child.kill();
1610    let _ = child.wait();
1611}
1612
1613fn read_bounded(mut reader: impl Read) -> io::Result<Vec<u8>> {
1614    let mut output = Vec::new();
1615    let mut buffer = [0_u8; 8 * 1024];
1616    loop {
1617        let count = reader.read(&mut buffer)?;
1618        if count == 0 {
1619            return Ok(output);
1620        }
1621        if count >= PLUGIN_OUTPUT_BYTES_LIMIT {
1622            output.clear();
1623            output.extend_from_slice(&buffer[count - PLUGIN_OUTPUT_BYTES_LIMIT..count]);
1624            continue;
1625        }
1626        let overflow = output
1627            .len()
1628            .saturating_add(count)
1629            .saturating_sub(PLUGIN_OUTPUT_BYTES_LIMIT);
1630        if overflow > 0 {
1631            output.drain(..overflow);
1632        }
1633        output.extend_from_slice(&buffer[..count]);
1634    }
1635}
1636
1637fn collect_output(reader: &mut Option<Receiver<io::Result<Vec<u8>>>>) -> io::Result<Vec<u8>> {
1638    reader
1639        .take()
1640        .ok_or_else(|| io::Error::other("plugin output was already collected"))?
1641        .recv_timeout(PLUGIN_OUTPUT_DRAIN_TIMEOUT)
1642        .map_err(|error| io::Error::other(format!("plugin output reader failed: {error}")))?
1643}
1644
1645fn collect_input(writer: &mut Option<Receiver<io::Result<()>>>) -> io::Result<()> {
1646    let Some(writer) = writer.take() else {
1647        return Ok(());
1648    };
1649    writer
1650        .recv_timeout(PLUGIN_OUTPUT_DRAIN_TIMEOUT)
1651        .map_err(|error| io::Error::other(format!("plugin input writer failed: {error}")))?
1652}
1653
1654fn validate_text(value: &str, label: &str, max_len: usize) -> io::Result<()> {
1655    if value.is_empty()
1656        || value.trim().is_empty()
1657        || value.len() > max_len
1658        || value.chars().any(is_unsafe_plugin_ui_character)
1659    {
1660        return Err(invalid_data(format!("invalid {label}")));
1661    }
1662    Ok(())
1663}
1664
1665fn is_unsafe_plugin_ui_character(character: char) -> bool {
1666    character.is_control()
1667        || matches!(
1668            character,
1669            // Invisible formatting characters that can conceal an otherwise
1670            // empty label or alter adjacent host-owned interface text.
1671            '\u{00ad}'
1672                | '\u{061c}'
1673                | '\u{180e}'
1674                | '\u{200b}'
1675                | '\u{200e}'
1676                | '\u{200f}'
1677                | '\u{2028}'
1678                | '\u{2029}'
1679                | '\u{202a}'..='\u{202e}'
1680                | '\u{2060}'
1681                | '\u{2066}'..='\u{206f}'
1682                | '\u{feff}'
1683                | '\u{fff9}'..='\u{fffb}'
1684        )
1685}
1686
1687fn validate_token(value: &str, label: &str) -> io::Result<()> {
1688    if value.is_empty()
1689        || value.len() > 40
1690        || !value
1691            .bytes()
1692            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1693    {
1694        return Err(invalid_data(format!("invalid {label}")));
1695    }
1696    Ok(())
1697}
1698
1699fn validate_extension(value: &str) -> io::Result<()> {
1700    if value.is_empty()
1701        || value.len() > 32
1702        || !value
1703            .bytes()
1704            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'_' | b'.'))
1705    {
1706        return Err(invalid_data("invalid plugin action extension"));
1707    }
1708    Ok(())
1709}
1710
1711fn validate_protocol_path(path: &Path) -> io::Result<()> {
1712    let bytes = path.as_os_str().as_bytes();
1713    if !path.is_absolute()
1714        || bytes.is_empty()
1715        || bytes.len() > PLUGIN_PATH_BYTES_LIMIT
1716        || bytes.contains(&0)
1717    {
1718        return Err(invalid_data(
1719            "plugin protocol paths must be bounded absolute Linux paths",
1720        ));
1721    }
1722    Ok(())
1723}
1724
1725fn validate_protocol_identity(
1726    protocol: u32,
1727    plugin_id: Uuid,
1728    expected_plugin_id: Uuid,
1729) -> io::Result<()> {
1730    if protocol != ACTION_PROTOCOL_VERSION {
1731        return Err(invalid_data(
1732            "plugin action response protocol version does not match Guth",
1733        ));
1734    }
1735    validate_plugin_id(expected_plugin_id)?;
1736    validate_plugin_id(plugin_id)?;
1737    if plugin_id != expected_plugin_id {
1738        return Err(invalid_data(
1739            "plugin action response does not match the requested plugin",
1740        ));
1741    }
1742    Ok(())
1743}
1744
1745fn all_unique<T: Ord>(values: &[T]) -> bool {
1746    values.iter().collect::<BTreeSet<_>>().len() == values.len()
1747}
1748
1749fn encode_hex(bytes: &[u8]) -> String {
1750    const HEX: &[u8; 16] = b"0123456789abcdef";
1751    let mut encoded = String::with_capacity(bytes.len() * 2);
1752    for byte in bytes {
1753        encoded.push(char::from(HEX[usize::from(byte >> 4)]));
1754        encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
1755    }
1756    encoded
1757}
1758
1759fn decode_hex(value: &str) -> io::Result<Vec<u8>> {
1760    if !value.len().is_multiple_of(2) || value.len() > PLUGIN_PATH_BYTES_LIMIT * 2 {
1761        return Err(invalid_data("invalid plugin protocol path encoding"));
1762    }
1763    value
1764        .as_bytes()
1765        .chunks_exact(2)
1766        .map(|pair| {
1767            let high = decode_hex_digit(pair[0])?;
1768            let low = decode_hex_digit(pair[1])?;
1769            Ok((high << 4) | low)
1770        })
1771        .collect()
1772}
1773
1774fn decode_hex_digit(byte: u8) -> io::Result<u8> {
1775    match byte {
1776        b'0'..=b'9' => Ok(byte - b'0'),
1777        b'a'..=b'f' => Ok(byte - b'a' + 10),
1778        _ => Err(invalid_data(
1779            "plugin protocol path encoding must use lowercase hexadecimal",
1780        )),
1781    }
1782}
1783
1784fn parse_plugin_id(value: &str) -> io::Result<Uuid> {
1785    let id = Uuid::parse_str(value).map_err(|_| invalid_data("invalid plugin UUID"))?;
1786    if id.to_string() != value {
1787        return Err(invalid_data(
1788            "plugin UUID must use canonical lowercase text",
1789        ));
1790    }
1791    validate_plugin_id(id)?;
1792    Ok(id)
1793}
1794
1795fn validate_plugin_id(id: Uuid) -> io::Result<()> {
1796    if id.get_version() != Some(Version::SortRand) || id.get_variant() != Variant::RFC4122 {
1797        return Err(invalid_data("plugin ID must be a UUIDv7"));
1798    }
1799    Ok(())
1800}
1801
1802fn open_verified_file(path: &Path, executable: bool, private: bool) -> io::Result<fs::File> {
1803    let owned_fd = rustix::fs::open(
1804        path,
1805        rustix::fs::OFlags::RDONLY
1806            | rustix::fs::OFlags::CLOEXEC
1807            | rustix::fs::OFlags::NOFOLLOW
1808            | rustix::fs::OFlags::NONBLOCK,
1809        rustix::fs::Mode::empty(),
1810    )
1811    .map_err(errno_error)?;
1812    let file = fs::File::from(owned_fd);
1813    let metadata = file.metadata()?;
1814    if !metadata.is_file() {
1815        return Err(invalid_data("plugin path must be a regular file"));
1816    }
1817    #[cfg(unix)]
1818    {
1819        use std::os::unix::fs::MetadataExt;
1820        let current_uid = rustix::process::geteuid().as_raw();
1821        if metadata.uid() != current_uid && metadata.uid() != 0 {
1822            return Err(io::Error::new(
1823                io::ErrorKind::PermissionDenied,
1824                "plugin file has an untrusted owner",
1825            ));
1826        }
1827        // Set-user/group-ID and sticky bits have no place on plugin manifests or
1828        // executables. Rejecting them also keeps root-owned executables from
1829        // unexpectedly crossing a privilege boundary when dispatched.
1830        let unsafe_permissions = if private { 0o7077 } else { 0o7022 };
1831        if metadata.mode() & unsafe_permissions != 0 {
1832            return Err(io::Error::new(
1833                io::ErrorKind::PermissionDenied,
1834                "plugin file has unsafe permissions",
1835            ));
1836        }
1837        if executable && metadata.mode() & 0o111 == 0 {
1838            return Err(io::Error::new(
1839                io::ErrorKind::PermissionDenied,
1840                "plugin executable is not executable",
1841            ));
1842        }
1843    }
1844    Ok(file)
1845}
1846
1847fn create_private_dir(path: &Path) -> io::Result<fs::File> {
1848    if !path.is_absolute() {
1849        return Err(invalid_data("plugin directory must be absolute"));
1850    }
1851    fs::create_dir_all(path)?;
1852    let owned_fd = rustix::fs::open(
1853        path,
1854        rustix::fs::OFlags::RDONLY
1855            | rustix::fs::OFlags::DIRECTORY
1856            | rustix::fs::OFlags::NOFOLLOW
1857            | rustix::fs::OFlags::CLOEXEC,
1858        rustix::fs::Mode::empty(),
1859    )
1860    .map_err(errno_error)?;
1861    let directory = fs::File::from(owned_fd);
1862    #[cfg(unix)]
1863    {
1864        use std::os::unix::fs::MetadataExt;
1865        let metadata = directory.metadata()?;
1866        if !metadata.is_dir() {
1867            return Err(io::Error::new(
1868                io::ErrorKind::PermissionDenied,
1869                "plugin directory must be a real directory",
1870            ));
1871        }
1872        if metadata.uid() != rustix::process::geteuid().as_raw() {
1873            return Err(io::Error::new(
1874                io::ErrorKind::PermissionDenied,
1875                "plugin directory has an unexpected owner",
1876            ));
1877        }
1878        rustix::fs::fchmod(&directory, rustix::fs::Mode::RWXU).map_err(errno_error)?;
1879    }
1880    Ok(directory)
1881}
1882
1883fn manifest_count(directory: &Path) -> io::Result<usize> {
1884    let mut count = 0;
1885    for (index, entry) in fs::read_dir(directory)?.enumerate() {
1886        if index >= PLUGIN_DIRECTORY_ENTRY_LIMIT {
1887            return Err(invalid_data(format!(
1888                "plugin directory contains more than {PLUGIN_DIRECTORY_ENTRY_LIMIT} entries"
1889            )));
1890        }
1891        let Ok(entry) = entry else {
1892            continue;
1893        };
1894        let path = entry.path();
1895        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1896            continue;
1897        };
1898        if path
1899            .extension()
1900            .is_some_and(|extension| extension == "json")
1901            && Uuid::parse_str(stem)
1902                .is_ok_and(|id| validate_plugin_id(id).is_ok() && id.to_string() == stem)
1903        {
1904            count += 1;
1905        }
1906    }
1907    Ok(count)
1908}
1909
1910#[cfg(test)]
1911fn write_enabled_plugins_to(ids: &BTreeSet<Uuid>, path: &Path) -> io::Result<()> {
1912    let directory_file = lock_enabled_plugin_directory(path)?;
1913    write_enabled_plugins_locked(ids, path, &directory_file)
1914}
1915
1916fn lock_enabled_plugin_directory(path: &Path) -> io::Result<fs::File> {
1917    let directory = path
1918        .parent()
1919        .ok_or_else(|| invalid_data("enabled plugin path has no parent"))?;
1920    let directory_file = create_private_dir(directory)?;
1921    rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
1922        .map_err(errno_error)?;
1923    Ok(directory_file)
1924}
1925
1926fn write_enabled_plugins_locked(
1927    ids: &BTreeSet<Uuid>,
1928    path: &Path,
1929    directory_file: &fs::File,
1930) -> io::Result<()> {
1931    let file_name = path
1932        .file_name()
1933        .ok_or_else(|| invalid_data("enabled plugin path has no file name"))?;
1934    let temporary_name = format!(".enabled-plugins.{}.tmp", Uuid::now_v7());
1935    let result = (|| {
1936        let owned_fd = rustix::fs::openat(
1937            directory_file,
1938            temporary_name.as_str(),
1939            rustix::fs::OFlags::WRONLY
1940                | rustix::fs::OFlags::CREATE
1941                | rustix::fs::OFlags::EXCL
1942                | rustix::fs::OFlags::NOFOLLOW
1943                | rustix::fs::OFlags::CLOEXEC,
1944            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
1945        )
1946        .map_err(errno_error)?;
1947        let mut file = fs::File::from(owned_fd);
1948        for id in ids {
1949            writeln!(file, "{id}")?;
1950        }
1951        file.sync_all()?;
1952        drop(file);
1953        rustix::fs::renameat(
1954            directory_file,
1955            temporary_name.as_str(),
1956            directory_file,
1957            file_name,
1958        )
1959        .map_err(errno_error)?;
1960        directory_file.sync_all()
1961    })();
1962    if result.is_err() {
1963        let _ = rustix::fs::unlinkat(
1964            directory_file,
1965            temporary_name.as_str(),
1966            rustix::fs::AtFlags::empty(),
1967        );
1968    }
1969    result
1970}
1971
1972fn home_dir() -> PathBuf {
1973    std::env::var_os("HOME")
1974        .map(PathBuf::from)
1975        .unwrap_or_else(|| PathBuf::from("/"))
1976}
1977
1978fn invalid_data(message: impl Into<String>) -> io::Error {
1979    io::Error::new(io::ErrorKind::InvalidData, message.into())
1980}
1981
1982fn invalid_json(error: impl std::fmt::Display) -> io::Error {
1983    invalid_data(format!("invalid plugin manifest: {error}"))
1984}
1985
1986fn errno_error(error: rustix::io::Errno) -> io::Error {
1987    io::Error::from_raw_os_error(error.raw_os_error())
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992    use super::*;
1993    use std::time::{SystemTime, UNIX_EPOCH};
1994
1995    struct TestDir(PathBuf);
1996
1997    impl TestDir {
1998        fn new(label: &str) -> Self {
1999            let nonce = SystemTime::now()
2000                .duration_since(UNIX_EPOCH)
2001                .unwrap()
2002                .as_nanos();
2003            let path = std::env::temp_dir()
2004                .join(format!("guth-cli-{label}-{}-{nonce}", std::process::id()));
2005            fs::create_dir(&path).unwrap();
2006            Self(path)
2007        }
2008    }
2009
2010    impl Drop for TestDir {
2011        fn drop(&mut self) {
2012            let _ = fs::remove_dir_all(&self.0);
2013        }
2014    }
2015
2016    fn test_manifest(executable: PathBuf) -> PluginManifest {
2017        PluginManifest {
2018            schema: MANIFEST_SCHEMA,
2019            id: Uuid::now_v7(),
2020            name: "Test Sync".to_string(),
2021            version: "0.1.0".to_string(),
2022            executable,
2023            description: "A test plugin".to_string(),
2024            capabilities: vec!["sync".to_string()],
2025        }
2026    }
2027
2028    fn test_action_manifest(executable: PathBuf) -> PluginActionManifest {
2029        PluginActionManifest {
2030            schema: ACTION_MANIFEST_SCHEMA,
2031            id: Uuid::now_v7(),
2032            name: "Test Actions".to_string(),
2033            version: "0.1.0".to_string(),
2034            executable,
2035            description: "A test GUI action plugin".to_string(),
2036            capabilities: vec![ACTION_CONTRIBUTION_CAPABILITY.to_string()],
2037            contributions: PluginContributions {
2038                actions: PluginActionContribution {
2039                    protocol: ACTION_PROTOCOL_VERSION,
2040                },
2041            },
2042        }
2043    }
2044
2045    fn test_action() -> PluginActionMetadata {
2046        PluginActionMetadata {
2047            id: "convert-text".to_string(),
2048            label: "Convert text".to_string(),
2049            description: "Converts selected text files".to_string(),
2050            category: PluginActionCategory::Convert,
2051            icon: PluginActionIcon::Convert,
2052            placements: vec![
2053                PluginActionPlacement::ContextMenu,
2054                PluginActionPlacement::CommandPalette,
2055            ],
2056            matcher: PluginActionMatcher {
2057                min_selection: 1,
2058                max_selection: 4,
2059                kinds: vec![PluginActionTargetKind::File],
2060                extensions: vec!["txt".to_string()],
2061            },
2062            destructive: false,
2063            confirmation: None,
2064        }
2065    }
2066
2067    fn test_action_context(extension: &str) -> PluginActionContext {
2068        PluginActionContext::new(
2069            Path::new("/workspace"),
2070            vec![PluginActionTarget::from_path(
2071                Path::new(&format!("/workspace/document.{extension}")),
2072                PluginActionTargetKind::File,
2073            )
2074            .unwrap()],
2075        )
2076        .unwrap()
2077    }
2078
2079    #[test]
2080    fn manifests_require_uuid_v7_and_absolute_executables() {
2081        let mut manifest = test_manifest(PathBuf::from("relative"));
2082        assert!(manifest.validate().is_err());
2083        manifest.executable = PathBuf::from("/bin/true");
2084        manifest.id = Uuid::nil();
2085        assert!(manifest.validate().is_err());
2086
2087        let mut non_rfc_bytes = *Uuid::now_v7().as_bytes();
2088        non_rfc_bytes[8] &= 0x3f;
2089        manifest.id = Uuid::from_bytes(non_rfc_bytes);
2090        assert_eq!(manifest.id.get_version(), Some(Version::SortRand));
2091        assert_eq!(manifest.id.get_variant(), Variant::NCS);
2092        assert!(manifest.validate().is_err());
2093    }
2094
2095    #[test]
2096    fn schema_one_manifest_wire_contract_remains_unchanged() {
2097        let manifest = test_manifest(PathBuf::from("/bin/true"));
2098        let value = serde_json::to_value(&manifest).unwrap();
2099
2100        assert_eq!(value["schema"], MANIFEST_SCHEMA);
2101        assert!(value.get("contributions").is_none());
2102        assert_eq!(
2103            serde_json::from_value::<PluginManifest>(value).unwrap(),
2104            manifest
2105        );
2106    }
2107
2108    #[test]
2109    fn action_manifests_require_schema_capability_and_protocol() {
2110        let mut manifest = test_action_manifest(PathBuf::from("/bin/true"));
2111        assert!(manifest.validate().is_ok());
2112
2113        let projection = manifest.plugin();
2114        assert!(projection.validate().is_err());
2115        assert!(validate_runtime_manifest(&projection, RuntimeManifestKind::GuiAction).is_ok());
2116        assert!(validate_runtime_manifest(&projection, RuntimeManifestKind::Command).is_err());
2117        assert!(spawn_plugin(&projection, []).is_err());
2118        assert!(run_plugin(&projection, []).is_err());
2119
2120        manifest.schema = MANIFEST_SCHEMA;
2121        assert!(manifest.validate().is_err());
2122        manifest.schema = ACTION_MANIFEST_SCHEMA;
2123        manifest.capabilities.clear();
2124        assert!(manifest.validate().is_err());
2125        manifest
2126            .capabilities
2127            .push(ACTION_CONTRIBUTION_CAPABILITY.to_string());
2128        manifest.contributions.actions.protocol = ACTION_PROTOCOL_VERSION + 1;
2129        assert!(manifest.validate().is_err());
2130    }
2131
2132    #[cfg(unix)]
2133    #[test]
2134    fn schema_two_action_manifests_are_discovered_without_hiding_schema_one_plugins() {
2135        use std::os::unix::fs::PermissionsExt;
2136
2137        let root = TestDir::new("action-discovery");
2138        let legacy_executable = root.0.join("legacy-plugin");
2139        let action_executable = root.0.join("action-plugin");
2140        for executable in [&legacy_executable, &action_executable] {
2141            fs::write(executable, b"#!/bin/sh\nexit 0\n").unwrap();
2142            fs::set_permissions(executable, fs::Permissions::from_mode(0o700)).unwrap();
2143        }
2144        let legacy = test_manifest(legacy_executable);
2145        let action = test_action_manifest(action_executable);
2146        let directory = root.0.join("manifests");
2147        install_manifest_in(&legacy, &directory).unwrap();
2148        install_action_manifest_in(&action, &directory).unwrap();
2149
2150        let report = discover_plugins_in_with_diagnostics(&directory).unwrap();
2151
2152        assert_eq!(report.plugins.len(), 2);
2153        assert!(report.plugins.contains(&legacy));
2154        assert!(report.plugins.contains(&action.plugin()));
2155        assert_eq!(
2156            report.action_contributions,
2157            vec![DiscoveredActionContribution {
2158                plugin_id: action.id,
2159                declaration: action.contributions.actions,
2160            }]
2161        );
2162        assert!(report.diagnostics.is_empty());
2163        assert!(!report.scan_truncated);
2164    }
2165
2166    #[test]
2167    fn action_matching_is_bounded_and_checks_every_selected_target() {
2168        let action = test_action();
2169        let text = test_action_context("txt");
2170        let image = test_action_context("png");
2171        let empty = PluginActionContext::new(Path::new("/workspace"), Vec::new()).unwrap();
2172
2173        assert!(action.matches_context(&text));
2174        assert!(!action.matches_context(&image));
2175        assert!(!action.matches_context(&empty));
2176
2177        let mut invalid = action.clone();
2178        invalid.matcher.max_selection = (PLUGIN_ACTION_SELECTION_LIMIT + 1) as u16;
2179        assert!(invalid.validate().is_err());
2180        invalid = action;
2181        invalid.matcher.extensions.push("txt".to_string());
2182        assert!(invalid.validate().is_err());
2183    }
2184
2185    #[test]
2186    fn action_metadata_rejects_ambiguous_or_unconfirmed_destructive_actions() {
2187        let mut action = test_action();
2188        action.destructive = true;
2189        assert!(action.validate().is_err());
2190        action.confirmation = Some("Replace the selected files?".to_string());
2191        assert!(action.validate().is_ok());
2192
2193        let mut response = PluginActionProbeResponse {
2194            protocol: ACTION_PROTOCOL_VERSION,
2195            plugin_id: Uuid::now_v7(),
2196            actions: vec![action.clone(), action],
2197        };
2198        assert!(response.validate(response.plugin_id).is_err());
2199        response.actions.pop();
2200        assert!(response.validate(Uuid::now_v7()).is_err());
2201    }
2202
2203    #[test]
2204    fn plugin_ui_text_rejects_blank_and_unsafe_formatting() {
2205        let mut action = test_action();
2206        action.label = "   ".to_string();
2207        assert!(action.validate().is_err());
2208
2209        action = test_action();
2210        action.description = "Misleading\u{202e}text".to_string();
2211        assert!(action.validate().is_err());
2212
2213        action = test_action();
2214        action.description = "First\u{2028}second".to_string();
2215        assert!(action.validate().is_err());
2216
2217        action = test_action();
2218        action.destructive = true;
2219        action.confirmation = Some("\u{200b}".to_string());
2220        assert!(action.validate().is_err());
2221
2222        let result = PluginActionResult {
2223            protocol: ACTION_PROTOCOL_VERSION,
2224            plugin_id: Uuid::now_v7(),
2225            action_id: "convert-text".to_string(),
2226            outcome: PluginActionOutcome::Failed,
2227            message: Some("   ".to_string()),
2228            refresh: false,
2229        };
2230        assert!(result.validate(result.plugin_id, "convert-text").is_err());
2231
2232        assert!(validate_text("أداة صور", "localized label", 80).is_ok());
2233    }
2234
2235    #[test]
2236    fn plugin_author_probe_parser_rejects_malformed_oversized_and_confused_requests() {
2237        let plugin_id = Uuid::now_v7();
2238        let mut request = PluginActionProbeRequest {
2239            protocol: ACTION_PROTOCOL_VERSION,
2240            plugin_id,
2241            operation: PluginActionOperation::Probe,
2242            context: test_action_context("txt"),
2243        };
2244        let valid = serde_json::to_vec(&request).unwrap();
2245
2246        assert_eq!(
2247            parse_plugin_action_probe_request(valid.as_slice(), plugin_id).unwrap(),
2248            request
2249        );
2250        assert!(parse_plugin_action_probe_request(&b"{"[..], plugin_id).is_err());
2251        assert!(parse_plugin_action_probe_request(
2252            vec![b' '; PLUGIN_ACTION_IO_BYTES_LIMIT + 1].as_slice(),
2253            plugin_id,
2254        )
2255        .is_err());
2256        assert!(parse_plugin_action_probe_request(valid.as_slice(), Uuid::now_v7()).is_err());
2257
2258        request.operation = PluginActionOperation::Invoke;
2259        assert!(parse_plugin_action_probe_request(
2260            serde_json::to_vec(&request).unwrap().as_slice(),
2261            plugin_id,
2262        )
2263        .is_err());
2264        request.operation = PluginActionOperation::Probe;
2265        request.context.current_directory.display = "/different".to_string();
2266        assert!(parse_plugin_action_probe_request(
2267            serde_json::to_vec(&request).unwrap().as_slice(),
2268            plugin_id,
2269        )
2270        .is_err());
2271    }
2272
2273    #[test]
2274    fn plugin_author_invoke_parser_requires_declared_action_identity() {
2275        let plugin_id = Uuid::now_v7();
2276        let mut request = PluginActionInvokeRequest {
2277            protocol: ACTION_PROTOCOL_VERSION,
2278            plugin_id,
2279            operation: PluginActionOperation::Invoke,
2280            action_id: "convert-text".to_string(),
2281            context: test_action_context("txt"),
2282        };
2283        let valid = serde_json::to_vec(&request).unwrap();
2284
2285        assert_eq!(
2286            parse_plugin_action_invoke_request(valid.as_slice(), plugin_id, &["convert-text"])
2287                .unwrap(),
2288            request
2289        );
2290        assert!(
2291            parse_plugin_action_invoke_request(valid.as_slice(), plugin_id, &["other-action"])
2292                .is_err()
2293        );
2294        assert!(parse_plugin_action_invoke_request(valid.as_slice(), plugin_id, &[]).is_err());
2295        assert!(parse_plugin_action_invoke_request(
2296            valid.as_slice(),
2297            plugin_id,
2298            &["convert-text", "convert-text"],
2299        )
2300        .is_err());
2301
2302        request.action_id = "INVALID ACTION".to_string();
2303        assert!(parse_plugin_action_invoke_request(
2304            serde_json::to_vec(&request).unwrap().as_slice(),
2305            plugin_id,
2306            &["convert-text"],
2307        )
2308        .is_err());
2309        request.action_id = "convert-text".to_string();
2310        request.operation = PluginActionOperation::Probe;
2311        assert!(parse_plugin_action_invoke_request(
2312            serde_json::to_vec(&request).unwrap().as_slice(),
2313            plugin_id,
2314            &["convert-text"],
2315        )
2316        .is_err());
2317    }
2318
2319    #[cfg(unix)]
2320    #[test]
2321    fn protocol_paths_round_trip_non_utf8_linux_names() {
2322        let path = PathBuf::from(OsString::from_vec(b"/workspace/non-utf8-\xff.txt".to_vec()));
2323        let wire = PluginProtocolPath::from_path(&path).unwrap();
2324
2325        assert_eq!(wire.to_path_buf().unwrap(), path);
2326        assert!(wire.display.contains('\u{fffd}'));
2327
2328        let mut tampered = wire;
2329        tampered.display = "/workspace/different.txt".to_string();
2330        assert!(tampered.to_path_buf().is_err());
2331    }
2332
2333    #[cfg(unix)]
2334    #[test]
2335    fn installed_manifests_are_private_and_discoverable() {
2336        use std::os::unix::fs::{MetadataExt, PermissionsExt};
2337
2338        let root = TestDir::new("discovery");
2339        let executable = root.0.join("plugin");
2340        fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
2341        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
2342        let manifest = test_manifest(executable);
2343        let directory = root.0.join("manifests");
2344
2345        let installed = install_manifest_in(&manifest, &directory).unwrap();
2346        assert_eq!(fs::metadata(&directory).unwrap().mode() & 0o777, 0o700);
2347        assert_eq!(fs::metadata(&installed).unwrap().mode() & 0o777, 0o600);
2348        assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
2349    }
2350
2351    #[cfg(unix)]
2352    #[test]
2353    fn writable_executables_are_rejected() {
2354        use std::os::unix::fs::PermissionsExt;
2355
2356        let root = TestDir::new("permissions");
2357        let executable = root.0.join("plugin");
2358        fs::write(&executable, b"plugin").unwrap();
2359        fs::set_permissions(&executable, fs::Permissions::from_mode(0o722)).unwrap();
2360        let error = open_verified_file(&executable, true, false).unwrap_err();
2361        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
2362    }
2363
2364    #[cfg(unix)]
2365    #[test]
2366    fn special_permission_bits_quarantine_plugin_executables() {
2367        use std::os::unix::fs::PermissionsExt;
2368
2369        let root = TestDir::new("special-permissions");
2370        let executable = root.0.join("plugin");
2371        fs::write(&executable, b"plugin").unwrap();
2372        fs::set_permissions(&executable, fs::Permissions::from_mode(0o4700)).unwrap();
2373
2374        let error = open_verified_file(&executable, true, false).unwrap_err();
2375
2376        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
2377    }
2378
2379    #[cfg(unix)]
2380    #[test]
2381    fn malformed_neighbors_do_not_hide_valid_plugins() {
2382        use std::os::unix::fs::PermissionsExt;
2383
2384        let root = TestDir::new("malformed-neighbor");
2385        let executable = root.0.join("plugin");
2386        fs::write(&executable, b"plugin").unwrap();
2387        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
2388        let manifest = test_manifest(executable);
2389        let directory = root.0.join("manifests");
2390        install_manifest_in(&manifest, &directory).unwrap();
2391        let malformed = directory.join(format!("{}.json", Uuid::now_v7()));
2392        fs::write(&malformed, b"not json").unwrap();
2393        fs::set_permissions(&malformed, fs::Permissions::from_mode(0o600)).unwrap();
2394
2395        let report = discover_plugins_in_with_diagnostics(&directory).unwrap();
2396        assert_eq!(report.plugins, vec![manifest]);
2397        assert_eq!(report.diagnostics.len(), 1);
2398        assert_eq!(report.diagnostics[0].path, malformed);
2399        assert!(report.diagnostics[0].message.contains("JSON is invalid"));
2400        assert!(!report.diagnostics_limited);
2401    }
2402
2403    #[cfg(unix)]
2404    #[test]
2405    fn malformed_manifest_filenames_are_actionable_without_hiding_valid_plugins() {
2406        use std::os::unix::fs::PermissionsExt;
2407
2408        let root = TestDir::new("malformed-filename");
2409        let executable = root.0.join("plugin");
2410        fs::write(&executable, b"plugin").unwrap();
2411        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
2412        let manifest = test_manifest(executable);
2413        let directory = root.0.join("manifests");
2414        install_manifest_in(&manifest, &directory).unwrap();
2415        let malformed = directory.join("plugin.json");
2416        fs::write(&malformed, b"{}").unwrap();
2417        fs::set_permissions(&malformed, fs::Permissions::from_mode(0o600)).unwrap();
2418
2419        let report = discover_plugins_in_with_diagnostics(&directory).unwrap();
2420
2421        assert_eq!(report.plugins, vec![manifest]);
2422        assert_eq!(report.diagnostics.len(), 1);
2423        assert_eq!(report.diagnostics[0].path, malformed);
2424        assert!(report.diagnostics[0].message.contains("UUIDv7"));
2425    }
2426
2427    #[cfg(unix)]
2428    #[test]
2429    fn discovery_diagnostics_are_bounded() {
2430        use std::os::unix::fs::PermissionsExt;
2431
2432        let root = TestDir::new("bounded-diagnostics");
2433        for _ in 0..=PLUGIN_LIMIT {
2434            let path = root.0.join(format!("{}.json", Uuid::now_v7()));
2435            fs::write(&path, b"not json").unwrap();
2436            fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
2437        }
2438
2439        let report = discover_plugins_in_with_diagnostics(&root.0).unwrap();
2440        assert!(report.plugins.is_empty());
2441        assert_eq!(report.diagnostics.len(), PLUGIN_DIAGNOSTIC_LIMIT);
2442        assert!(report.diagnostics_limited);
2443        assert!(report.scan_truncated);
2444        assert!(discover_plugins_in(&root.0).is_err());
2445    }
2446
2447    #[test]
2448    fn directory_entry_limit_is_diagnostic_but_strict_discovery_fails_closed() {
2449        let root = TestDir::new("entry-limit");
2450        for index in 0..=PLUGIN_DIRECTORY_ENTRY_LIMIT {
2451            fs::write(root.0.join(format!("neighbor-{index}")), b"").unwrap();
2452        }
2453
2454        let report = discover_plugins_in_with_diagnostics(&root.0).unwrap();
2455
2456        assert!(report.plugins.is_empty());
2457        assert!(report.scan_truncated);
2458        assert!(report
2459            .diagnostics
2460            .iter()
2461            .any(|diagnostic| diagnostic.message.contains("scan stopped")));
2462        assert!(discover_plugins_in(&root.0).is_err());
2463    }
2464
2465    #[cfg(unix)]
2466    #[test]
2467    fn special_file_candidates_do_not_block_discovery() {
2468        let root = TestDir::new("special-file");
2469        let fifo = root.0.join(format!("{}.json", Uuid::now_v7()));
2470        rustix::fs::mkfifoat(
2471            rustix::fs::CWD,
2472            &fifo,
2473            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
2474        )
2475        .unwrap();
2476
2477        assert!(discover_plugins_in(&root.0).unwrap().is_empty());
2478    }
2479
2480    #[test]
2481    fn enabled_plugin_state_round_trips_uuid_v7_ids() {
2482        let root = TestDir::new("enabled-state");
2483        let path = root.0.join("guth/enabled-plugins.conf");
2484        let ids = BTreeSet::from([Uuid::now_v7(), Uuid::now_v7()]);
2485
2486        write_enabled_plugins_to(&ids, &path).unwrap();
2487        assert_eq!(load_enabled_plugins_from(&path).unwrap(), ids);
2488    }
2489
2490    #[test]
2491    fn loading_enabled_plugins_does_not_rewrite_or_prune_state() {
2492        let root = TestDir::new("enabled-state-nondestructive");
2493        let path = root.0.join("guth/enabled-plugins.conf");
2494        let ids = BTreeSet::from([Uuid::now_v7(), Uuid::now_v7()]);
2495        write_enabled_plugins_to(&ids, &path).unwrap();
2496        let before = fs::read(&path).unwrap();
2497
2498        assert_eq!(load_enabled_plugins_from(&path).unwrap(), ids);
2499        assert_eq!(fs::read(path).unwrap(), before);
2500    }
2501
2502    #[cfg(unix)]
2503    #[test]
2504    fn versioned_action_probe_and_invocation_use_fixed_argv_and_json_stdio() {
2505        use std::os::unix::fs::PermissionsExt;
2506
2507        let root = TestDir::new("action-protocol");
2508        let executable = root.0.join("plugin");
2509        let captured = root.0.join("request.json");
2510        let manifest = test_action_manifest(executable.clone());
2511        let plugin = manifest.plugin();
2512        let action = test_action();
2513        let action_json = serde_json::to_string(&action).unwrap();
2514        let script = format!(
2515            "#!/bin/sh\n\
2516             [ \"$1\" = \"{ACTION_PROTOCOL_ARGUMENT}\" ] || exit 20\n\
2517             [ \"$2\" = \"{ACTION_PROTOCOL_VERSION}\" ] || exit 21\n\
2518             case \"$3\" in probe|invoke) ;; *) exit 22 ;; esac\n\
2519             IFS= read -r request || exit 23\n\
2520             printf '%s' \"$request\" > '{}'\n\
2521             if [ \"$3\" = probe ]; then\n\
2522               printf '%s\\n' '{{\"protocol\":{ACTION_PROTOCOL_VERSION},\"plugin_id\":\"{}\",\"actions\":[{}]}}'\n\
2523             else\n\
2524               printf '%s\\n' '{{\"protocol\":{ACTION_PROTOCOL_VERSION},\"plugin_id\":\"{}\",\"action_id\":\"{}\",\"outcome\":\"completed\",\"message\":\"Converted\",\"refresh\":true}}'\n\
2525             fi\n",
2526            captured.display(),
2527            manifest.id,
2528            action_json,
2529            manifest.id,
2530            action.id,
2531        );
2532        fs::write(&executable, script).unwrap();
2533        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
2534        let contribution = manifest.contributions.actions;
2535        let context = test_action_context("txt");
2536
2537        let actions = probe_plugin_actions(&plugin, &contribution, &context).unwrap();
2538        assert_eq!(actions, vec![action.clone()]);
2539        let probe_request =
2540            parse_plugin_action_probe_request(fs::read(&captured).unwrap().as_slice(), manifest.id)
2541                .unwrap();
2542        assert_eq!(probe_request.protocol, ACTION_PROTOCOL_VERSION);
2543        assert_eq!(probe_request.plugin_id, manifest.id);
2544        assert_eq!(probe_request.operation, PluginActionOperation::Probe);
2545        assert_eq!(probe_request.context, context);
2546
2547        let result = invoke_plugin_action(&plugin, &contribution, &action, &context).unwrap();
2548        assert_eq!(result.outcome, PluginActionOutcome::Completed);
2549        assert!(result.refresh);
2550        let invoke_request = parse_plugin_action_invoke_request(
2551            fs::read(captured).unwrap().as_slice(),
2552            manifest.id,
2553            &[action.id.as_str()],
2554        )
2555        .unwrap();
2556        assert_eq!(invoke_request.operation, PluginActionOperation::Invoke);
2557        assert_eq!(invoke_request.action_id, action.id);
2558        assert_eq!(invoke_request.context, context);
2559    }
2560
2561    #[test]
2562    fn action_invocation_rejects_legacy_plugins_and_stale_contexts() {
2563        let legacy = test_manifest(PathBuf::from("/bin/true"));
2564        let action_manifest = test_action_manifest(PathBuf::from("/bin/true"));
2565        let contribution = action_manifest.contributions.actions.clone();
2566        let action = test_action();
2567
2568        assert!(
2569            spawn_plugin_action(&legacy, &contribution, &action, &test_action_context("txt"),)
2570                .is_err()
2571        );
2572        assert!(spawn_plugin_action(
2573            &action_manifest.plugin(),
2574            &contribution,
2575            &action,
2576            &test_action_context("png"),
2577        )
2578        .is_err());
2579    }
2580
2581    #[cfg(unix)]
2582    #[test]
2583    fn typed_action_results_require_a_successful_process_exit() {
2584        use std::os::unix::process::ExitStatusExt;
2585
2586        let action_manifest = test_action_manifest(PathBuf::from("/bin/true"));
2587        let contribution = action_manifest.contributions.actions.clone();
2588        let action = test_action();
2589        let result = PluginActionResult {
2590            protocol: ACTION_PROTOCOL_VERSION,
2591            plugin_id: action_manifest.id,
2592            action_id: action.id.clone(),
2593            outcome: PluginActionOutcome::Failed,
2594            message: Some("Conversion failed".to_string()),
2595            refresh: false,
2596        };
2597        let output = PluginOutput {
2598            status: ExitStatus::from_raw(1 << 8),
2599            stdout: serde_json::to_vec(&result).unwrap(),
2600            stderr: b"plugin failed".to_vec(),
2601        };
2602
2603        let error =
2604            parse_plugin_action_result(&action_manifest.plugin(), &contribution, &action, &output)
2605                .unwrap_err();
2606
2607        assert!(error.to_string().contains("exited unsuccessfully"));
2608        assert!(error.to_string().contains("plugin failed"));
2609    }
2610
2611    #[cfg(unix)]
2612    #[test]
2613    fn spawned_plugins_capture_output_and_exit_status() {
2614        use std::os::unix::fs::PermissionsExt;
2615
2616        let root = TestDir::new("spawn-output");
2617        let executable = root.0.join("plugin");
2618        fs::write(
2619            &executable,
2620            b"#!/bin/sh\nprintf 'converted:%s' \"$1\"\nprintf 'diagnostic' >&2\nexit 7\n",
2621        )
2622        .unwrap();
2623        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
2624        let mut process =
2625            spawn_plugin(&test_manifest(executable), [OsString::from("track.wav")]).unwrap();
2626        let output = loop {
2627            if let Some(output) = process.try_wait().unwrap() {
2628                break output;
2629            }
2630            thread::sleep(Duration::from_millis(5));
2631        };
2632
2633        assert_eq!(output.status.code(), Some(7));
2634        assert_eq!(output.stdout, b"converted:track.wav");
2635        assert_eq!(output.stderr, b"diagnostic");
2636    }
2637
2638    #[cfg(unix)]
2639    #[test]
2640    fn plugin_spawn_retries_a_transient_executable_busy_error() {
2641        use std::os::unix::fs::PermissionsExt;
2642
2643        let root = TestDir::new("spawn-busy-retry");
2644        let executable = root.0.join("plugin");
2645        fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
2646        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
2647        let writer = fs::OpenOptions::new()
2648            .write(true)
2649            .open(&executable)
2650            .unwrap();
2651        let release_writer = thread::spawn(move || {
2652            thread::sleep(Duration::from_millis(25));
2653            drop(writer);
2654        });
2655        let mut command = Command::new(&executable);
2656        command
2657            .stdin(Stdio::null())
2658            .stdout(Stdio::null())
2659            .stderr(Stdio::null());
2660
2661        let status = spawn_plugin_command(&mut command).unwrap().wait().unwrap();
2662        release_writer.join().unwrap();
2663
2664        assert!(status.success());
2665    }
2666
2667    #[cfg(unix)]
2668    #[test]
2669    fn spawned_plugins_can_be_cancelled_and_reaped() {
2670        use std::os::unix::fs::PermissionsExt;
2671
2672        let root = TestDir::new("spawn-cancel");
2673        let executable = root.0.join("plugin");
2674        fs::write(&executable, b"#!/bin/sh\nwhile :; do sleep 1; done\n").unwrap();
2675        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
2676        let mut process = spawn_plugin(&test_manifest(executable), []).unwrap();
2677        thread::sleep(Duration::from_millis(50));
2678        process.cancel().unwrap();
2679        assert!(process.cancellation_requested());
2680        let output = loop {
2681            if let Some(output) = process.try_wait().unwrap() {
2682                break output;
2683            }
2684            thread::sleep(Duration::from_millis(5));
2685        };
2686
2687        assert!(!output.status.success());
2688    }
2689
2690    #[cfg(unix)]
2691    #[test]
2692    fn cancellation_escalates_when_term_is_ignored() {
2693        use std::os::unix::fs::PermissionsExt;
2694
2695        let root = TestDir::new("spawn-cancel-escalation");
2696        let executable = root.0.join("plugin");
2697        fs::write(
2698            &executable,
2699            b"#!/bin/sh\ntrap '' TERM\nwhile :; do sleep 1; done\n",
2700        )
2701        .unwrap();
2702        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
2703        let mut process = spawn_plugin(&test_manifest(executable), []).unwrap();
2704        thread::sleep(Duration::from_millis(50));
2705        process.cancel().unwrap();
2706        let started = Instant::now();
2707        let output = loop {
2708            if let Some(output) = process.try_wait().unwrap() {
2709                break output;
2710            }
2711            assert!(started.elapsed() < Duration::from_secs(3));
2712            thread::sleep(Duration::from_millis(10));
2713        };
2714
2715        assert!(!output.status.success());
2716    }
2717
2718    #[cfg(unix)]
2719    #[test]
2720    fn dropping_plugin_with_a_descendant_is_bounded() {
2721        use std::os::unix::fs::PermissionsExt;
2722
2723        let root = TestDir::new("spawn-drop-descendant");
2724        let executable = root.0.join("plugin");
2725        fs::write(&executable, b"#!/bin/sh\nsleep 30 &\nexit 0\n").unwrap();
2726        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
2727        let process = spawn_plugin(&test_manifest(executable), []).unwrap();
2728        let started = Instant::now();
2729        drop(process);
2730
2731        assert!(started.elapsed() < Duration::from_secs(3));
2732    }
2733}