1use 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 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#[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#[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 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
474pub 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 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
551pub 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 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
638pub 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
1309pub 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
1328pub 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
1341pub 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
1365pub 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
1391pub 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 '\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 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}