Skip to main content

guth_cli/
lib.rs

1//! Shared external-plugin discovery and dispatch for Guth.
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeSet;
5use std::ffi::OsString;
6use std::fs;
7use std::io::{self, Read, Write};
8use std::os::fd::AsRawFd;
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command, ExitStatus, Stdio};
11use std::sync::mpsc::{self, Receiver};
12use std::thread;
13use std::time::{Duration, Instant};
14use uuid::{Uuid, Version};
15
16pub const MANIFEST_SCHEMA: u32 = 1;
17pub const MANIFEST_BYTES_LIMIT: u64 = 64 * 1024;
18pub const PLUGIN_LIMIT: usize = 64;
19pub const ENABLED_PLUGIN_BYTES_LIMIT: u64 = 8 * 1024;
20const PLUGIN_DIRECTORY_ENTRY_LIMIT: usize = 4096;
21const PLUGIN_OUTPUT_BYTES_LIMIT: usize = 64 * 1024;
22const PLUGIN_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
23const PLUGIN_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(25);
24const PLUGIN_OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
25
26#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
27#[serde(deny_unknown_fields)]
28pub struct PluginManifest {
29    pub schema: u32,
30    pub id: Uuid,
31    pub name: String,
32    pub version: String,
33    pub executable: PathBuf,
34    pub description: String,
35    pub capabilities: Vec<String>,
36}
37
38impl PluginManifest {
39    pub fn validate(&self) -> io::Result<()> {
40        if self.schema != MANIFEST_SCHEMA {
41            return Err(invalid_data("unsupported plugin manifest schema"));
42        }
43        if self.id.get_version() != Some(Version::SortRand) {
44            return Err(invalid_data("plugin ID must be a UUIDv7"));
45        }
46        validate_text(&self.name, "plugin name", 80)?;
47        validate_text(&self.version, "plugin version", 32)?;
48        validate_text(&self.description, "plugin description", 280)?;
49        if !self.executable.is_absolute() {
50            return Err(invalid_data("plugin executable must be an absolute path"));
51        }
52        if self.capabilities.is_empty() || self.capabilities.len() > 16 {
53            return Err(invalid_data("plugin must declare 1 to 16 capabilities"));
54        }
55        let mut seen = BTreeSet::new();
56        for capability in &self.capabilities {
57            validate_token(capability, "plugin capability")?;
58            if !seen.insert(capability) {
59                return Err(invalid_data("plugin capabilities must be unique"));
60            }
61        }
62        Ok(())
63    }
64
65    pub fn supports(&self, capability: &str) -> bool {
66        self.capabilities
67            .iter()
68            .any(|candidate| candidate == capability)
69    }
70}
71
72pub fn plugin_data_dir() -> PathBuf {
73    std::env::var_os("XDG_DATA_HOME")
74        .map(PathBuf::from)
75        .unwrap_or_else(|| home_dir().join(".local/share"))
76        .join("guth/plugins")
77}
78
79pub fn enabled_plugins_path() -> PathBuf {
80    std::env::var_os("XDG_CONFIG_HOME")
81        .map(PathBuf::from)
82        .unwrap_or_else(|| home_dir().join(".config"))
83        .join("guth/enabled-plugins.conf")
84}
85
86pub fn load_enabled_plugins() -> io::Result<BTreeSet<Uuid>> {
87    load_enabled_plugins_from(&enabled_plugins_path())
88}
89
90fn load_enabled_plugins_from(path: &Path) -> io::Result<BTreeSet<Uuid>> {
91    let file = match open_verified_file(path, false, true) {
92        Ok(file) => file,
93        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
94        Err(error) => return Err(error),
95    };
96    let metadata = file.metadata()?;
97    if metadata.len() > ENABLED_PLUGIN_BYTES_LIMIT {
98        return Err(invalid_data("enabled plugin file is too large"));
99    }
100    let mut bytes = Vec::with_capacity(metadata.len() as usize);
101    file.take(ENABLED_PLUGIN_BYTES_LIMIT + 1)
102        .read_to_end(&mut bytes)?;
103    let text = std::str::from_utf8(&bytes)
104        .map_err(|_| invalid_data("enabled plugin file must be UTF-8"))?;
105    let mut enabled = BTreeSet::new();
106    for line in text.lines() {
107        if line.is_empty() {
108            continue;
109        }
110        let id = parse_plugin_id(line)?;
111        if !enabled.insert(id) || enabled.len() > PLUGIN_LIMIT {
112            return Err(invalid_data("enabled plugin file contains invalid entries"));
113        }
114    }
115    Ok(enabled)
116}
117
118pub fn set_plugin_enabled(id: Uuid, enabled: bool) -> io::Result<()> {
119    validate_plugin_id(id)?;
120    let path = enabled_plugins_path();
121    let directory_file = lock_enabled_plugin_directory(&path)?;
122    let mut ids = load_enabled_plugins_from(&path)?;
123    if enabled {
124        if ids.len() >= PLUGIN_LIMIT && !ids.contains(&id) {
125            return Err(invalid_data("enabled plugin limit reached"));
126        }
127        ids.insert(id);
128    } else {
129        ids.remove(&id);
130    }
131    write_enabled_plugins_locked(&ids, &path, &directory_file)
132}
133
134pub fn prune_uninstalled_enabled_plugins() -> io::Result<BTreeSet<Uuid>> {
135    let plugin_directory = plugin_data_dir();
136    if let Some(parent) = plugin_directory.parent() {
137        create_private_dir(parent)?;
138    }
139    let plugin_directory_file = create_private_dir(&plugin_directory)?;
140    rustix::fs::flock(
141        &plugin_directory_file,
142        rustix::fs::FlockOperation::LockExclusive,
143    )
144    .map_err(errno_error)?;
145    let installed = discover_plugins_in(&plugin_directory)?
146        .into_iter()
147        .map(|plugin| plugin.id)
148        .collect::<BTreeSet<_>>();
149    let path = enabled_plugins_path();
150    let directory_file = lock_enabled_plugin_directory(&path)?;
151    let mut ids = load_enabled_plugins_from(&path)?;
152    let original_len = ids.len();
153    ids.retain(|id| installed.contains(id));
154    if ids.len() != original_len {
155        write_enabled_plugins_locked(&ids, &path, &directory_file)?;
156    }
157    Ok(ids)
158}
159
160pub fn discover_plugins() -> io::Result<Vec<PluginManifest>> {
161    discover_plugins_in(&plugin_data_dir())
162}
163
164pub fn discover_plugins_in(directory: &Path) -> io::Result<Vec<PluginManifest>> {
165    let mut manifests = Vec::new();
166    let entries = match fs::read_dir(directory) {
167        Ok(entries) => entries,
168        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(manifests),
169        Err(error) => return Err(error),
170    };
171    let mut paths = Vec::new();
172    for (index, entry) in entries.enumerate() {
173        if index >= PLUGIN_DIRECTORY_ENTRY_LIMIT {
174            return Err(invalid_data("plugin directory entry limit exceeded"));
175        }
176        let Ok(entry) = entry else {
177            continue;
178        };
179        let path = entry.path();
180        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
181            continue;
182        };
183        let Ok(id) = Uuid::parse_str(stem) else {
184            continue;
185        };
186        if id.get_version() == Some(Version::SortRand)
187            && id.to_string() == stem
188            && path
189                .extension()
190                .is_some_and(|extension| extension == "json")
191        {
192            paths.push((id, path));
193        }
194    }
195    paths.sort();
196    for (expected_id, path) in paths.into_iter().take(PLUGIN_LIMIT) {
197        let Ok(file) = open_verified_file(&path, false, true) else {
198            continue;
199        };
200        let Ok(metadata) = file.metadata() else {
201            continue;
202        };
203        if metadata.len() > MANIFEST_BYTES_LIMIT {
204            continue;
205        }
206        let mut bytes = Vec::with_capacity(metadata.len() as usize);
207        if file
208            .take(MANIFEST_BYTES_LIMIT + 1)
209            .read_to_end(&mut bytes)
210            .is_err()
211        {
212            continue;
213        }
214        let Ok(manifest) = serde_json::from_slice::<PluginManifest>(&bytes) else {
215            continue;
216        };
217        if manifest.id != expected_id
218            || manifest.validate().is_err()
219            || open_verified_file(&manifest.executable, true, false).is_err()
220        {
221            continue;
222        }
223        manifests.push(manifest);
224    }
225    manifests.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
226    manifests.dedup_by_key(|manifest| manifest.id);
227    Ok(manifests)
228}
229
230pub fn install_manifest(manifest: &PluginManifest) -> io::Result<PathBuf> {
231    install_manifest_in(manifest, &plugin_data_dir())
232}
233
234pub fn install_manifest_in(manifest: &PluginManifest, directory: &Path) -> io::Result<PathBuf> {
235    manifest.validate()?;
236    open_verified_file(&manifest.executable, true, false)?;
237    if let Some(parent) = directory.parent() {
238        create_private_dir(parent)?;
239    }
240    let directory_file = create_private_dir(directory)?;
241    rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
242        .map_err(errno_error)?;
243    let destination = directory.join(format!("{}.json", manifest.id));
244    let destination_name = format!("{}.json", manifest.id);
245    let temporary_name = format!(".{}.{}.tmp", manifest.id, Uuid::now_v7());
246    if !destination.exists() && manifest_count(directory)? >= PLUGIN_LIMIT {
247        return Err(invalid_data("plugin limit reached"));
248    }
249    let bytes = serde_json::to_vec_pretty(manifest).map_err(invalid_json)?;
250    let result = (|| {
251        let owned_fd = rustix::fs::openat(
252            &directory_file,
253            temporary_name.as_str(),
254            rustix::fs::OFlags::WRONLY
255                | rustix::fs::OFlags::CREATE
256                | rustix::fs::OFlags::EXCL
257                | rustix::fs::OFlags::NOFOLLOW
258                | rustix::fs::OFlags::CLOEXEC,
259            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
260        )
261        .map_err(errno_error)?;
262        let mut file = fs::File::from(owned_fd);
263        file.write_all(&bytes)?;
264        file.write_all(b"\n")?;
265        file.sync_all()?;
266        drop(file);
267        rustix::fs::renameat(
268            &directory_file,
269            temporary_name.as_str(),
270            &directory_file,
271            destination_name.as_str(),
272        )
273        .map_err(errno_error)?;
274        directory_file.sync_all()?;
275        Ok(destination.clone())
276    })();
277    if result.is_err() {
278        let _ = rustix::fs::unlinkat(
279            &directory_file,
280            temporary_name.as_str(),
281            rustix::fs::AtFlags::empty(),
282        );
283    }
284    result
285}
286
287pub fn run_plugin(
288    manifest: &PluginManifest,
289    arguments: impl IntoIterator<Item = OsString>,
290) -> io::Result<ExitStatus> {
291    manifest.validate()?;
292    let executable = open_verified_file(&manifest.executable, true, false)?;
293    rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty()).map_err(errno_error)?;
294    let executable_path = format!("/proc/self/fd/{}", executable.as_raw_fd());
295    Command::new(executable_path)
296        .args(arguments)
297        .stdin(Stdio::inherit())
298        .stdout(Stdio::inherit())
299        .stderr(Stdio::inherit())
300        .status()
301}
302
303#[derive(Debug)]
304pub struct PluginOutput {
305    pub status: ExitStatus,
306    pub stdout: Vec<u8>,
307    pub stderr: Vec<u8>,
308}
309
310pub struct PluginProcess {
311    child: Option<Child>,
312    process_group: rustix::process::Pid,
313    stdout_reader: Option<Receiver<io::Result<Vec<u8>>>>,
314    stderr_reader: Option<Receiver<io::Result<Vec<u8>>>>,
315    cancellation_requested_at: Option<Instant>,
316}
317
318impl PluginProcess {
319    pub fn try_wait(&mut self) -> io::Result<Option<PluginOutput>> {
320        if self
321            .cancellation_requested_at
322            .is_some_and(|started| started.elapsed() >= PLUGIN_SHUTDOWN_TIMEOUT)
323        {
324            let _ = rustix::process::kill_process_group(
325                self.process_group,
326                rustix::process::Signal::KILL,
327            );
328        }
329        let Some(child) = self.child.as_mut() else {
330            return Err(io::Error::other("plugin process was already collected"));
331        };
332        let Some(status) = child.try_wait()? else {
333            return Ok(None);
334        };
335        self.child.take();
336        let _ =
337            rustix::process::kill_process_group(self.process_group, rustix::process::Signal::KILL);
338        let stdout = collect_output(&mut self.stdout_reader)?;
339        let stderr = collect_output(&mut self.stderr_reader)?;
340        Ok(Some(PluginOutput {
341            status,
342            stdout,
343            stderr,
344        }))
345    }
346
347    pub fn cancel(&mut self) -> io::Result<()> {
348        if self.cancellation_requested_at.is_some() {
349            return Ok(());
350        }
351        rustix::process::kill_process_group(self.process_group, rustix::process::Signal::TERM)
352            .map_err(errno_error)?;
353        self.cancellation_requested_at = Some(Instant::now());
354        Ok(())
355    }
356
357    pub fn cancellation_requested(&self) -> bool {
358        self.cancellation_requested_at.is_some()
359    }
360}
361
362impl Drop for PluginProcess {
363    fn drop(&mut self) {
364        let Some(child) = self.child.as_mut() else {
365            return;
366        };
367        let _ =
368            rustix::process::kill_process_group(self.process_group, rustix::process::Signal::TERM);
369        let started = Instant::now();
370        while started.elapsed() < PLUGIN_SHUTDOWN_TIMEOUT {
371            match child.try_wait() {
372                Ok(Some(_)) => break,
373                Ok(None) => thread::sleep(PLUGIN_SHUTDOWN_POLL_INTERVAL),
374                Err(_) => break,
375            }
376        }
377        let _ =
378            rustix::process::kill_process_group(self.process_group, rustix::process::Signal::KILL);
379        if child.try_wait().ok().flatten().is_none() {
380            let _ = child.kill();
381            let _ = child.wait();
382        }
383        self.child.take();
384        self.stdout_reader.take();
385        self.stderr_reader.take();
386    }
387}
388
389pub fn spawn_plugin(
390    manifest: &PluginManifest,
391    arguments: impl IntoIterator<Item = OsString>,
392) -> io::Result<PluginProcess> {
393    manifest.validate()?;
394    let executable = open_verified_file(&manifest.executable, true, false)?;
395    rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty()).map_err(errno_error)?;
396    let executable_path = format!("/proc/self/fd/{}", executable.as_raw_fd());
397    let mut command = Command::new(executable_path);
398    command
399        .args(arguments)
400        .stdin(Stdio::null())
401        .stdout(Stdio::piped())
402        .stderr(Stdio::piped());
403    #[cfg(unix)]
404    {
405        use std::os::unix::process::CommandExt;
406        command.process_group(0);
407    }
408    let mut child = command.spawn()?;
409    let process_group = match i32::try_from(child.id())
410        .ok()
411        .and_then(rustix::process::Pid::from_raw)
412    {
413        Some(process_group) => process_group,
414        None => {
415            let _ = child.kill();
416            let _ = child.wait();
417            return Err(io::Error::other("plugin process ID is out of range"));
418        }
419    };
420    let Some(stdout) = child.stdout.take() else {
421        terminate_failed_spawn(&mut child, process_group);
422        return Err(io::Error::other("plugin stdout was not captured"));
423    };
424    let Some(stderr) = child.stderr.take() else {
425        terminate_failed_spawn(&mut child, process_group);
426        return Err(io::Error::other("plugin stderr was not captured"));
427    };
428    let stdout_reader = match spawn_output_reader("guth-plugin-stdout", stdout) {
429        Ok(reader) => reader,
430        Err(error) => {
431            terminate_failed_spawn(&mut child, process_group);
432            return Err(error);
433        }
434    };
435    let stderr_reader = match spawn_output_reader("guth-plugin-stderr", stderr) {
436        Ok(reader) => reader,
437        Err(error) => {
438            terminate_failed_spawn(&mut child, process_group);
439            drop(stdout_reader);
440            return Err(error);
441        }
442    };
443    Ok(PluginProcess {
444        child: Some(child),
445        process_group,
446        stdout_reader: Some(stdout_reader),
447        stderr_reader: Some(stderr_reader),
448        cancellation_requested_at: None,
449    })
450}
451
452fn spawn_output_reader(
453    name: &str,
454    reader: impl Read + Send + 'static,
455) -> io::Result<Receiver<io::Result<Vec<u8>>>> {
456    let (sender, receiver) = mpsc::sync_channel(1);
457    thread::Builder::new()
458        .name(name.to_string())
459        .spawn(move || {
460            let _ = sender.send(read_bounded(reader));
461        })?;
462    Ok(receiver)
463}
464
465fn terminate_failed_spawn(child: &mut Child, process_group: rustix::process::Pid) {
466    let _ = rustix::process::kill_process_group(process_group, rustix::process::Signal::KILL);
467    let _ = child.kill();
468    let _ = child.wait();
469}
470
471fn read_bounded(mut reader: impl Read) -> io::Result<Vec<u8>> {
472    let mut output = Vec::new();
473    let mut buffer = [0_u8; 8 * 1024];
474    loop {
475        let count = reader.read(&mut buffer)?;
476        if count == 0 {
477            return Ok(output);
478        }
479        if count >= PLUGIN_OUTPUT_BYTES_LIMIT {
480            output.clear();
481            output.extend_from_slice(&buffer[count - PLUGIN_OUTPUT_BYTES_LIMIT..count]);
482            continue;
483        }
484        let overflow = output
485            .len()
486            .saturating_add(count)
487            .saturating_sub(PLUGIN_OUTPUT_BYTES_LIMIT);
488        if overflow > 0 {
489            output.drain(..overflow);
490        }
491        output.extend_from_slice(&buffer[..count]);
492    }
493}
494
495fn collect_output(reader: &mut Option<Receiver<io::Result<Vec<u8>>>>) -> io::Result<Vec<u8>> {
496    reader
497        .take()
498        .ok_or_else(|| io::Error::other("plugin output was already collected"))?
499        .recv_timeout(PLUGIN_OUTPUT_DRAIN_TIMEOUT)
500        .map_err(|error| io::Error::other(format!("plugin output reader failed: {error}")))?
501}
502
503fn validate_text(value: &str, label: &str, max_len: usize) -> io::Result<()> {
504    if value.is_empty()
505        || value.len() > max_len
506        || value.chars().any(|character| character.is_control())
507    {
508        return Err(invalid_data(format!("invalid {label}")));
509    }
510    Ok(())
511}
512
513fn validate_token(value: &str, label: &str) -> io::Result<()> {
514    if value.is_empty()
515        || value.len() > 40
516        || !value
517            .bytes()
518            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
519    {
520        return Err(invalid_data(format!("invalid {label}")));
521    }
522    Ok(())
523}
524
525fn parse_plugin_id(value: &str) -> io::Result<Uuid> {
526    let id = Uuid::parse_str(value).map_err(|_| invalid_data("invalid plugin UUID"))?;
527    if id.to_string() != value {
528        return Err(invalid_data(
529            "plugin UUID must use canonical lowercase text",
530        ));
531    }
532    validate_plugin_id(id)?;
533    Ok(id)
534}
535
536fn validate_plugin_id(id: Uuid) -> io::Result<()> {
537    if id.get_version() != Some(Version::SortRand) {
538        return Err(invalid_data("plugin ID must be a UUIDv7"));
539    }
540    Ok(())
541}
542
543fn open_verified_file(path: &Path, executable: bool, private: bool) -> io::Result<fs::File> {
544    let owned_fd = rustix::fs::open(
545        path,
546        rustix::fs::OFlags::RDONLY
547            | rustix::fs::OFlags::CLOEXEC
548            | rustix::fs::OFlags::NOFOLLOW
549            | rustix::fs::OFlags::NONBLOCK,
550        rustix::fs::Mode::empty(),
551    )
552    .map_err(errno_error)?;
553    let file = fs::File::from(owned_fd);
554    let metadata = file.metadata()?;
555    if !metadata.is_file() {
556        return Err(invalid_data("plugin path must be a regular file"));
557    }
558    #[cfg(unix)]
559    {
560        use std::os::unix::fs::MetadataExt;
561        let current_uid = rustix::process::geteuid().as_raw();
562        if metadata.uid() != current_uid && metadata.uid() != 0 {
563            return Err(io::Error::new(
564                io::ErrorKind::PermissionDenied,
565                "plugin file has an untrusted owner",
566            ));
567        }
568        let unsafe_permissions = if private { 0o077 } else { 0o022 };
569        if metadata.mode() & unsafe_permissions != 0 {
570            return Err(io::Error::new(
571                io::ErrorKind::PermissionDenied,
572                "plugin file has unsafe permissions",
573            ));
574        }
575        if executable && metadata.mode() & 0o111 == 0 {
576            return Err(io::Error::new(
577                io::ErrorKind::PermissionDenied,
578                "plugin executable is not executable",
579            ));
580        }
581    }
582    Ok(file)
583}
584
585fn create_private_dir(path: &Path) -> io::Result<fs::File> {
586    if !path.is_absolute() {
587        return Err(invalid_data("plugin directory must be absolute"));
588    }
589    fs::create_dir_all(path)?;
590    let owned_fd = rustix::fs::open(
591        path,
592        rustix::fs::OFlags::RDONLY
593            | rustix::fs::OFlags::DIRECTORY
594            | rustix::fs::OFlags::NOFOLLOW
595            | rustix::fs::OFlags::CLOEXEC,
596        rustix::fs::Mode::empty(),
597    )
598    .map_err(errno_error)?;
599    let directory = fs::File::from(owned_fd);
600    #[cfg(unix)]
601    {
602        use std::os::unix::fs::MetadataExt;
603        let metadata = directory.metadata()?;
604        if !metadata.is_dir() {
605            return Err(io::Error::new(
606                io::ErrorKind::PermissionDenied,
607                "plugin directory must be a real directory",
608            ));
609        }
610        if metadata.uid() != rustix::process::geteuid().as_raw() {
611            return Err(io::Error::new(
612                io::ErrorKind::PermissionDenied,
613                "plugin directory has an unexpected owner",
614            ));
615        }
616        rustix::fs::fchmod(&directory, rustix::fs::Mode::RWXU).map_err(errno_error)?;
617    }
618    Ok(directory)
619}
620
621fn manifest_count(directory: &Path) -> io::Result<usize> {
622    let mut count = 0;
623    for entry in fs::read_dir(directory)?.take(PLUGIN_DIRECTORY_ENTRY_LIMIT) {
624        let Ok(entry) = entry else {
625            continue;
626        };
627        let path = entry.path();
628        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
629            continue;
630        };
631        if path
632            .extension()
633            .is_some_and(|extension| extension == "json")
634            && Uuid::parse_str(stem).is_ok_and(|id| {
635                id.get_version() == Some(Version::SortRand) && id.to_string() == stem
636            })
637        {
638            count += 1;
639        }
640    }
641    Ok(count)
642}
643
644#[cfg(test)]
645fn write_enabled_plugins_to(ids: &BTreeSet<Uuid>, path: &Path) -> io::Result<()> {
646    let directory_file = lock_enabled_plugin_directory(path)?;
647    write_enabled_plugins_locked(ids, path, &directory_file)
648}
649
650fn lock_enabled_plugin_directory(path: &Path) -> io::Result<fs::File> {
651    let directory = path
652        .parent()
653        .ok_or_else(|| invalid_data("enabled plugin path has no parent"))?;
654    let directory_file = create_private_dir(directory)?;
655    rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
656        .map_err(errno_error)?;
657    Ok(directory_file)
658}
659
660fn write_enabled_plugins_locked(
661    ids: &BTreeSet<Uuid>,
662    path: &Path,
663    directory_file: &fs::File,
664) -> io::Result<()> {
665    let file_name = path
666        .file_name()
667        .ok_or_else(|| invalid_data("enabled plugin path has no file name"))?;
668    let temporary_name = format!(".enabled-plugins.{}.tmp", Uuid::now_v7());
669    let result = (|| {
670        let owned_fd = rustix::fs::openat(
671            directory_file,
672            temporary_name.as_str(),
673            rustix::fs::OFlags::WRONLY
674                | rustix::fs::OFlags::CREATE
675                | rustix::fs::OFlags::EXCL
676                | rustix::fs::OFlags::NOFOLLOW
677                | rustix::fs::OFlags::CLOEXEC,
678            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
679        )
680        .map_err(errno_error)?;
681        let mut file = fs::File::from(owned_fd);
682        for id in ids {
683            writeln!(file, "{id}")?;
684        }
685        file.sync_all()?;
686        drop(file);
687        rustix::fs::renameat(
688            directory_file,
689            temporary_name.as_str(),
690            directory_file,
691            file_name,
692        )
693        .map_err(errno_error)?;
694        directory_file.sync_all()
695    })();
696    if result.is_err() {
697        let _ = rustix::fs::unlinkat(
698            directory_file,
699            temporary_name.as_str(),
700            rustix::fs::AtFlags::empty(),
701        );
702    }
703    result
704}
705
706fn home_dir() -> PathBuf {
707    std::env::var_os("HOME")
708        .map(PathBuf::from)
709        .unwrap_or_else(|| PathBuf::from("/"))
710}
711
712fn invalid_data(message: impl Into<String>) -> io::Error {
713    io::Error::new(io::ErrorKind::InvalidData, message.into())
714}
715
716fn invalid_json(error: impl std::fmt::Display) -> io::Error {
717    invalid_data(format!("invalid plugin manifest: {error}"))
718}
719
720fn errno_error(error: rustix::io::Errno) -> io::Error {
721    io::Error::from_raw_os_error(error.raw_os_error())
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use std::time::{SystemTime, UNIX_EPOCH};
728
729    struct TestDir(PathBuf);
730
731    impl TestDir {
732        fn new(label: &str) -> Self {
733            let nonce = SystemTime::now()
734                .duration_since(UNIX_EPOCH)
735                .unwrap()
736                .as_nanos();
737            let path = std::env::temp_dir()
738                .join(format!("guth-cli-{label}-{}-{nonce}", std::process::id()));
739            fs::create_dir(&path).unwrap();
740            Self(path)
741        }
742    }
743
744    impl Drop for TestDir {
745        fn drop(&mut self) {
746            let _ = fs::remove_dir_all(&self.0);
747        }
748    }
749
750    fn test_manifest(executable: PathBuf) -> PluginManifest {
751        PluginManifest {
752            schema: MANIFEST_SCHEMA,
753            id: Uuid::now_v7(),
754            name: "Test Sync".to_string(),
755            version: "0.1.0".to_string(),
756            executable,
757            description: "A test plugin".to_string(),
758            capabilities: vec!["sync".to_string()],
759        }
760    }
761
762    #[test]
763    fn manifests_require_uuid_v7_and_absolute_executables() {
764        let mut manifest = test_manifest(PathBuf::from("relative"));
765        assert!(manifest.validate().is_err());
766        manifest.executable = PathBuf::from("/bin/true");
767        manifest.id = Uuid::nil();
768        assert!(manifest.validate().is_err());
769    }
770
771    #[cfg(unix)]
772    #[test]
773    fn installed_manifests_are_private_and_discoverable() {
774        use std::os::unix::fs::{MetadataExt, PermissionsExt};
775
776        let root = TestDir::new("discovery");
777        let executable = root.0.join("plugin");
778        fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
779        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
780        let manifest = test_manifest(executable);
781        let directory = root.0.join("manifests");
782
783        let installed = install_manifest_in(&manifest, &directory).unwrap();
784        assert_eq!(fs::metadata(&directory).unwrap().mode() & 0o777, 0o700);
785        assert_eq!(fs::metadata(&installed).unwrap().mode() & 0o777, 0o600);
786        assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
787    }
788
789    #[cfg(unix)]
790    #[test]
791    fn writable_executables_are_rejected() {
792        use std::os::unix::fs::PermissionsExt;
793
794        let root = TestDir::new("permissions");
795        let executable = root.0.join("plugin");
796        fs::write(&executable, b"plugin").unwrap();
797        fs::set_permissions(&executable, fs::Permissions::from_mode(0o722)).unwrap();
798        let error = open_verified_file(&executable, true, false).unwrap_err();
799        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
800    }
801
802    #[cfg(unix)]
803    #[test]
804    fn malformed_neighbors_do_not_hide_valid_plugins() {
805        use std::os::unix::fs::PermissionsExt;
806
807        let root = TestDir::new("malformed-neighbor");
808        let executable = root.0.join("plugin");
809        fs::write(&executable, b"plugin").unwrap();
810        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
811        let manifest = test_manifest(executable);
812        let directory = root.0.join("manifests");
813        install_manifest_in(&manifest, &directory).unwrap();
814        let malformed = directory.join(format!("{}.json", Uuid::now_v7()));
815        fs::write(&malformed, b"not json").unwrap();
816        fs::set_permissions(&malformed, fs::Permissions::from_mode(0o600)).unwrap();
817
818        assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
819    }
820
821    #[cfg(unix)]
822    #[test]
823    fn special_file_candidates_do_not_block_discovery() {
824        let root = TestDir::new("special-file");
825        let fifo = root.0.join(format!("{}.json", Uuid::now_v7()));
826        rustix::fs::mkfifoat(
827            rustix::fs::CWD,
828            &fifo,
829            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
830        )
831        .unwrap();
832
833        assert!(discover_plugins_in(&root.0).unwrap().is_empty());
834    }
835
836    #[test]
837    fn enabled_plugin_state_round_trips_uuid_v7_ids() {
838        let root = TestDir::new("enabled-state");
839        let path = root.0.join("guth/enabled-plugins.conf");
840        let ids = BTreeSet::from([Uuid::now_v7(), Uuid::now_v7()]);
841
842        write_enabled_plugins_to(&ids, &path).unwrap();
843        assert_eq!(load_enabled_plugins_from(&path).unwrap(), ids);
844    }
845
846    #[cfg(unix)]
847    #[test]
848    fn spawned_plugins_capture_output_and_exit_status() {
849        use std::os::unix::fs::PermissionsExt;
850
851        let root = TestDir::new("spawn-output");
852        let executable = root.0.join("plugin");
853        fs::write(
854            &executable,
855            b"#!/bin/sh\nprintf 'converted:%s' \"$1\"\nprintf 'diagnostic' >&2\nexit 7\n",
856        )
857        .unwrap();
858        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
859        let mut process =
860            spawn_plugin(&test_manifest(executable), [OsString::from("track.wav")]).unwrap();
861        let output = loop {
862            if let Some(output) = process.try_wait().unwrap() {
863                break output;
864            }
865            thread::sleep(Duration::from_millis(5));
866        };
867
868        assert_eq!(output.status.code(), Some(7));
869        assert_eq!(output.stdout, b"converted:track.wav");
870        assert_eq!(output.stderr, b"diagnostic");
871    }
872
873    #[cfg(unix)]
874    #[test]
875    fn spawned_plugins_can_be_cancelled_and_reaped() {
876        use std::os::unix::fs::PermissionsExt;
877
878        let root = TestDir::new("spawn-cancel");
879        let executable = root.0.join("plugin");
880        fs::write(&executable, b"#!/bin/sh\nwhile :; do sleep 1; done\n").unwrap();
881        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
882        let mut process = spawn_plugin(&test_manifest(executable), []).unwrap();
883        thread::sleep(Duration::from_millis(50));
884        process.cancel().unwrap();
885        assert!(process.cancellation_requested());
886        let output = loop {
887            if let Some(output) = process.try_wait().unwrap() {
888                break output;
889            }
890            thread::sleep(Duration::from_millis(5));
891        };
892
893        assert!(!output.status.success());
894    }
895
896    #[cfg(unix)]
897    #[test]
898    fn cancellation_escalates_when_term_is_ignored() {
899        use std::os::unix::fs::PermissionsExt;
900
901        let root = TestDir::new("spawn-cancel-escalation");
902        let executable = root.0.join("plugin");
903        fs::write(
904            &executable,
905            b"#!/bin/sh\ntrap '' TERM\nwhile :; do sleep 1; done\n",
906        )
907        .unwrap();
908        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
909        let mut process = spawn_plugin(&test_manifest(executable), []).unwrap();
910        thread::sleep(Duration::from_millis(50));
911        process.cancel().unwrap();
912        let started = Instant::now();
913        let output = loop {
914            if let Some(output) = process.try_wait().unwrap() {
915                break output;
916            }
917            assert!(started.elapsed() < Duration::from_secs(3));
918            thread::sleep(Duration::from_millis(10));
919        };
920
921        assert!(!output.status.success());
922    }
923
924    #[cfg(unix)]
925    #[test]
926    fn dropping_plugin_with_a_descendant_is_bounded() {
927        use std::os::unix::fs::PermissionsExt;
928
929        let root = TestDir::new("spawn-drop-descendant");
930        let executable = root.0.join("plugin");
931        fs::write(&executable, b"#!/bin/sh\nsleep 30 &\nexit 0\n").unwrap();
932        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
933        let process = spawn_plugin(&test_manifest(executable), []).unwrap();
934        let started = Instant::now();
935        drop(process);
936
937        assert!(started.elapsed() < Duration::from_secs(3));
938    }
939}