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::{Command, ExitStatus, Stdio};
11use uuid::{Uuid, Version};
12
13pub const MANIFEST_SCHEMA: u32 = 1;
14pub const MANIFEST_BYTES_LIMIT: u64 = 64 * 1024;
15pub const PLUGIN_LIMIT: usize = 64;
16pub const ENABLED_PLUGIN_BYTES_LIMIT: u64 = 8 * 1024;
17const PLUGIN_DIRECTORY_ENTRY_LIMIT: usize = 4096;
18
19#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
20#[serde(deny_unknown_fields)]
21pub struct PluginManifest {
22    pub schema: u32,
23    pub id: Uuid,
24    pub name: String,
25    pub version: String,
26    pub executable: PathBuf,
27    pub description: String,
28    pub capabilities: Vec<String>,
29}
30
31impl PluginManifest {
32    pub fn validate(&self) -> io::Result<()> {
33        if self.schema != MANIFEST_SCHEMA {
34            return Err(invalid_data("unsupported plugin manifest schema"));
35        }
36        if self.id.get_version() != Some(Version::SortRand) {
37            return Err(invalid_data("plugin ID must be a UUIDv7"));
38        }
39        validate_text(&self.name, "plugin name", 80)?;
40        validate_text(&self.version, "plugin version", 32)?;
41        validate_text(&self.description, "plugin description", 280)?;
42        if !self.executable.is_absolute() {
43            return Err(invalid_data("plugin executable must be an absolute path"));
44        }
45        if self.capabilities.is_empty() || self.capabilities.len() > 16 {
46            return Err(invalid_data("plugin must declare 1 to 16 capabilities"));
47        }
48        let mut seen = BTreeSet::new();
49        for capability in &self.capabilities {
50            validate_token(capability, "plugin capability")?;
51            if !seen.insert(capability) {
52                return Err(invalid_data("plugin capabilities must be unique"));
53            }
54        }
55        Ok(())
56    }
57
58    pub fn supports(&self, capability: &str) -> bool {
59        self.capabilities
60            .iter()
61            .any(|candidate| candidate == capability)
62    }
63}
64
65pub fn plugin_data_dir() -> PathBuf {
66    std::env::var_os("XDG_DATA_HOME")
67        .map(PathBuf::from)
68        .unwrap_or_else(|| home_dir().join(".local/share"))
69        .join("guth/plugins")
70}
71
72pub fn enabled_plugins_path() -> PathBuf {
73    std::env::var_os("XDG_CONFIG_HOME")
74        .map(PathBuf::from)
75        .unwrap_or_else(|| home_dir().join(".config"))
76        .join("guth/enabled-plugins.conf")
77}
78
79pub fn load_enabled_plugins() -> io::Result<BTreeSet<Uuid>> {
80    load_enabled_plugins_from(&enabled_plugins_path())
81}
82
83fn load_enabled_plugins_from(path: &Path) -> io::Result<BTreeSet<Uuid>> {
84    let file = match open_verified_file(path, false, true) {
85        Ok(file) => file,
86        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
87        Err(error) => return Err(error),
88    };
89    let metadata = file.metadata()?;
90    if metadata.len() > ENABLED_PLUGIN_BYTES_LIMIT {
91        return Err(invalid_data("enabled plugin file is too large"));
92    }
93    let mut bytes = Vec::with_capacity(metadata.len() as usize);
94    file.take(ENABLED_PLUGIN_BYTES_LIMIT + 1)
95        .read_to_end(&mut bytes)?;
96    let text = std::str::from_utf8(&bytes)
97        .map_err(|_| invalid_data("enabled plugin file must be UTF-8"))?;
98    let mut enabled = BTreeSet::new();
99    for line in text.lines() {
100        if line.is_empty() {
101            continue;
102        }
103        let id = parse_plugin_id(line)?;
104        if !enabled.insert(id) || enabled.len() > PLUGIN_LIMIT {
105            return Err(invalid_data("enabled plugin file contains invalid entries"));
106        }
107    }
108    Ok(enabled)
109}
110
111pub fn set_plugin_enabled(id: Uuid, enabled: bool) -> io::Result<()> {
112    validate_plugin_id(id)?;
113    let path = enabled_plugins_path();
114    let directory_file = lock_enabled_plugin_directory(&path)?;
115    let mut ids = load_enabled_plugins_from(&path)?;
116    if enabled {
117        if ids.len() >= PLUGIN_LIMIT && !ids.contains(&id) {
118            return Err(invalid_data("enabled plugin limit reached"));
119        }
120        ids.insert(id);
121    } else {
122        ids.remove(&id);
123    }
124    write_enabled_plugins_locked(&ids, &path, &directory_file)
125}
126
127pub fn prune_uninstalled_enabled_plugins() -> io::Result<BTreeSet<Uuid>> {
128    let plugin_directory = plugin_data_dir();
129    if let Some(parent) = plugin_directory.parent() {
130        create_private_dir(parent)?;
131    }
132    let plugin_directory_file = create_private_dir(&plugin_directory)?;
133    rustix::fs::flock(
134        &plugin_directory_file,
135        rustix::fs::FlockOperation::LockExclusive,
136    )
137    .map_err(errno_error)?;
138    let installed = discover_plugins_in(&plugin_directory)?
139        .into_iter()
140        .map(|plugin| plugin.id)
141        .collect::<BTreeSet<_>>();
142    let path = enabled_plugins_path();
143    let directory_file = lock_enabled_plugin_directory(&path)?;
144    let mut ids = load_enabled_plugins_from(&path)?;
145    let original_len = ids.len();
146    ids.retain(|id| installed.contains(id));
147    if ids.len() != original_len {
148        write_enabled_plugins_locked(&ids, &path, &directory_file)?;
149    }
150    Ok(ids)
151}
152
153pub fn discover_plugins() -> io::Result<Vec<PluginManifest>> {
154    discover_plugins_in(&plugin_data_dir())
155}
156
157pub fn discover_plugins_in(directory: &Path) -> io::Result<Vec<PluginManifest>> {
158    let mut manifests = Vec::new();
159    let entries = match fs::read_dir(directory) {
160        Ok(entries) => entries,
161        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(manifests),
162        Err(error) => return Err(error),
163    };
164    let mut paths = Vec::new();
165    for (index, entry) in entries.enumerate() {
166        if index >= PLUGIN_DIRECTORY_ENTRY_LIMIT {
167            return Err(invalid_data("plugin directory entry limit exceeded"));
168        }
169        let Ok(entry) = entry else {
170            continue;
171        };
172        let path = entry.path();
173        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
174            continue;
175        };
176        let Ok(id) = Uuid::parse_str(stem) else {
177            continue;
178        };
179        if id.get_version() == Some(Version::SortRand)
180            && id.to_string() == stem
181            && path
182                .extension()
183                .is_some_and(|extension| extension == "json")
184        {
185            paths.push((id, path));
186        }
187    }
188    paths.sort();
189    for (expected_id, path) in paths.into_iter().take(PLUGIN_LIMIT) {
190        let Ok(file) = open_verified_file(&path, false, true) else {
191            continue;
192        };
193        let Ok(metadata) = file.metadata() else {
194            continue;
195        };
196        if metadata.len() > MANIFEST_BYTES_LIMIT {
197            continue;
198        }
199        let mut bytes = Vec::with_capacity(metadata.len() as usize);
200        if file
201            .take(MANIFEST_BYTES_LIMIT + 1)
202            .read_to_end(&mut bytes)
203            .is_err()
204        {
205            continue;
206        }
207        let Ok(manifest) = serde_json::from_slice::<PluginManifest>(&bytes) else {
208            continue;
209        };
210        if manifest.id != expected_id
211            || manifest.validate().is_err()
212            || open_verified_file(&manifest.executable, true, false).is_err()
213        {
214            continue;
215        }
216        manifests.push(manifest);
217    }
218    manifests.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
219    manifests.dedup_by_key(|manifest| manifest.id);
220    Ok(manifests)
221}
222
223pub fn install_manifest(manifest: &PluginManifest) -> io::Result<PathBuf> {
224    install_manifest_in(manifest, &plugin_data_dir())
225}
226
227pub fn install_manifest_in(manifest: &PluginManifest, directory: &Path) -> io::Result<PathBuf> {
228    manifest.validate()?;
229    open_verified_file(&manifest.executable, true, false)?;
230    if let Some(parent) = directory.parent() {
231        create_private_dir(parent)?;
232    }
233    let directory_file = create_private_dir(directory)?;
234    rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
235        .map_err(errno_error)?;
236    let destination = directory.join(format!("{}.json", manifest.id));
237    let destination_name = format!("{}.json", manifest.id);
238    let temporary_name = format!(".{}.{}.tmp", manifest.id, Uuid::now_v7());
239    if !destination.exists() && manifest_count(directory)? >= PLUGIN_LIMIT {
240        return Err(invalid_data("plugin limit reached"));
241    }
242    let bytes = serde_json::to_vec_pretty(manifest).map_err(invalid_json)?;
243    let result = (|| {
244        let owned_fd = rustix::fs::openat(
245            &directory_file,
246            temporary_name.as_str(),
247            rustix::fs::OFlags::WRONLY
248                | rustix::fs::OFlags::CREATE
249                | rustix::fs::OFlags::EXCL
250                | rustix::fs::OFlags::NOFOLLOW
251                | rustix::fs::OFlags::CLOEXEC,
252            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
253        )
254        .map_err(errno_error)?;
255        let mut file = fs::File::from(owned_fd);
256        file.write_all(&bytes)?;
257        file.write_all(b"\n")?;
258        file.sync_all()?;
259        drop(file);
260        rustix::fs::renameat(
261            &directory_file,
262            temporary_name.as_str(),
263            &directory_file,
264            destination_name.as_str(),
265        )
266        .map_err(errno_error)?;
267        directory_file.sync_all()?;
268        Ok(destination.clone())
269    })();
270    if result.is_err() {
271        let _ = rustix::fs::unlinkat(
272            &directory_file,
273            temporary_name.as_str(),
274            rustix::fs::AtFlags::empty(),
275        );
276    }
277    result
278}
279
280pub fn run_plugin(
281    manifest: &PluginManifest,
282    arguments: impl IntoIterator<Item = OsString>,
283) -> io::Result<ExitStatus> {
284    manifest.validate()?;
285    let executable = open_verified_file(&manifest.executable, true, false)?;
286    rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty()).map_err(errno_error)?;
287    let executable_path = format!("/proc/self/fd/{}", executable.as_raw_fd());
288    Command::new(executable_path)
289        .args(arguments)
290        .stdin(Stdio::inherit())
291        .stdout(Stdio::inherit())
292        .stderr(Stdio::inherit())
293        .status()
294}
295
296fn validate_text(value: &str, label: &str, max_len: usize) -> io::Result<()> {
297    if value.is_empty()
298        || value.len() > max_len
299        || value.chars().any(|character| character.is_control())
300    {
301        return Err(invalid_data(format!("invalid {label}")));
302    }
303    Ok(())
304}
305
306fn validate_token(value: &str, label: &str) -> io::Result<()> {
307    if value.is_empty()
308        || value.len() > 40
309        || !value
310            .bytes()
311            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
312    {
313        return Err(invalid_data(format!("invalid {label}")));
314    }
315    Ok(())
316}
317
318fn parse_plugin_id(value: &str) -> io::Result<Uuid> {
319    let id = Uuid::parse_str(value).map_err(|_| invalid_data("invalid plugin UUID"))?;
320    if id.to_string() != value {
321        return Err(invalid_data(
322            "plugin UUID must use canonical lowercase text",
323        ));
324    }
325    validate_plugin_id(id)?;
326    Ok(id)
327}
328
329fn validate_plugin_id(id: Uuid) -> io::Result<()> {
330    if id.get_version() != Some(Version::SortRand) {
331        return Err(invalid_data("plugin ID must be a UUIDv7"));
332    }
333    Ok(())
334}
335
336fn open_verified_file(path: &Path, executable: bool, private: bool) -> io::Result<fs::File> {
337    let owned_fd = rustix::fs::open(
338        path,
339        rustix::fs::OFlags::RDONLY
340            | rustix::fs::OFlags::CLOEXEC
341            | rustix::fs::OFlags::NOFOLLOW
342            | rustix::fs::OFlags::NONBLOCK,
343        rustix::fs::Mode::empty(),
344    )
345    .map_err(errno_error)?;
346    let file = fs::File::from(owned_fd);
347    let metadata = file.metadata()?;
348    if !metadata.is_file() {
349        return Err(invalid_data("plugin path must be a regular file"));
350    }
351    #[cfg(unix)]
352    {
353        use std::os::unix::fs::MetadataExt;
354        let current_uid = rustix::process::geteuid().as_raw();
355        if metadata.uid() != current_uid && metadata.uid() != 0 {
356            return Err(io::Error::new(
357                io::ErrorKind::PermissionDenied,
358                "plugin file has an untrusted owner",
359            ));
360        }
361        let unsafe_permissions = if private { 0o077 } else { 0o022 };
362        if metadata.mode() & unsafe_permissions != 0 {
363            return Err(io::Error::new(
364                io::ErrorKind::PermissionDenied,
365                "plugin file has unsafe permissions",
366            ));
367        }
368        if executable && metadata.mode() & 0o111 == 0 {
369            return Err(io::Error::new(
370                io::ErrorKind::PermissionDenied,
371                "plugin executable is not executable",
372            ));
373        }
374    }
375    Ok(file)
376}
377
378fn create_private_dir(path: &Path) -> io::Result<fs::File> {
379    if !path.is_absolute() {
380        return Err(invalid_data("plugin directory must be absolute"));
381    }
382    fs::create_dir_all(path)?;
383    let owned_fd = rustix::fs::open(
384        path,
385        rustix::fs::OFlags::RDONLY
386            | rustix::fs::OFlags::DIRECTORY
387            | rustix::fs::OFlags::NOFOLLOW
388            | rustix::fs::OFlags::CLOEXEC,
389        rustix::fs::Mode::empty(),
390    )
391    .map_err(errno_error)?;
392    let directory = fs::File::from(owned_fd);
393    #[cfg(unix)]
394    {
395        use std::os::unix::fs::MetadataExt;
396        let metadata = directory.metadata()?;
397        if !metadata.is_dir() {
398            return Err(io::Error::new(
399                io::ErrorKind::PermissionDenied,
400                "plugin directory must be a real directory",
401            ));
402        }
403        if metadata.uid() != rustix::process::geteuid().as_raw() {
404            return Err(io::Error::new(
405                io::ErrorKind::PermissionDenied,
406                "plugin directory has an unexpected owner",
407            ));
408        }
409        rustix::fs::fchmod(&directory, rustix::fs::Mode::RWXU).map_err(errno_error)?;
410    }
411    Ok(directory)
412}
413
414fn manifest_count(directory: &Path) -> io::Result<usize> {
415    let mut count = 0;
416    for entry in fs::read_dir(directory)?.take(PLUGIN_DIRECTORY_ENTRY_LIMIT) {
417        let Ok(entry) = entry else {
418            continue;
419        };
420        let path = entry.path();
421        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
422            continue;
423        };
424        if path
425            .extension()
426            .is_some_and(|extension| extension == "json")
427            && Uuid::parse_str(stem).is_ok_and(|id| {
428                id.get_version() == Some(Version::SortRand) && id.to_string() == stem
429            })
430        {
431            count += 1;
432        }
433    }
434    Ok(count)
435}
436
437#[cfg(test)]
438fn write_enabled_plugins_to(ids: &BTreeSet<Uuid>, path: &Path) -> io::Result<()> {
439    let directory_file = lock_enabled_plugin_directory(path)?;
440    write_enabled_plugins_locked(ids, path, &directory_file)
441}
442
443fn lock_enabled_plugin_directory(path: &Path) -> io::Result<fs::File> {
444    let directory = path
445        .parent()
446        .ok_or_else(|| invalid_data("enabled plugin path has no parent"))?;
447    let directory_file = create_private_dir(directory)?;
448    rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
449        .map_err(errno_error)?;
450    Ok(directory_file)
451}
452
453fn write_enabled_plugins_locked(
454    ids: &BTreeSet<Uuid>,
455    path: &Path,
456    directory_file: &fs::File,
457) -> io::Result<()> {
458    let file_name = path
459        .file_name()
460        .ok_or_else(|| invalid_data("enabled plugin path has no file name"))?;
461    let temporary_name = format!(".enabled-plugins.{}.tmp", Uuid::now_v7());
462    let result = (|| {
463        let owned_fd = rustix::fs::openat(
464            directory_file,
465            temporary_name.as_str(),
466            rustix::fs::OFlags::WRONLY
467                | rustix::fs::OFlags::CREATE
468                | rustix::fs::OFlags::EXCL
469                | rustix::fs::OFlags::NOFOLLOW
470                | rustix::fs::OFlags::CLOEXEC,
471            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
472        )
473        .map_err(errno_error)?;
474        let mut file = fs::File::from(owned_fd);
475        for id in ids {
476            writeln!(file, "{id}")?;
477        }
478        file.sync_all()?;
479        drop(file);
480        rustix::fs::renameat(
481            directory_file,
482            temporary_name.as_str(),
483            directory_file,
484            file_name,
485        )
486        .map_err(errno_error)?;
487        directory_file.sync_all()
488    })();
489    if result.is_err() {
490        let _ = rustix::fs::unlinkat(
491            directory_file,
492            temporary_name.as_str(),
493            rustix::fs::AtFlags::empty(),
494        );
495    }
496    result
497}
498
499fn home_dir() -> PathBuf {
500    std::env::var_os("HOME")
501        .map(PathBuf::from)
502        .unwrap_or_else(|| PathBuf::from("/"))
503}
504
505fn invalid_data(message: impl Into<String>) -> io::Error {
506    io::Error::new(io::ErrorKind::InvalidData, message.into())
507}
508
509fn invalid_json(error: impl std::fmt::Display) -> io::Error {
510    invalid_data(format!("invalid plugin manifest: {error}"))
511}
512
513fn errno_error(error: rustix::io::Errno) -> io::Error {
514    io::Error::from_raw_os_error(error.raw_os_error())
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use std::time::{SystemTime, UNIX_EPOCH};
521
522    struct TestDir(PathBuf);
523
524    impl TestDir {
525        fn new(label: &str) -> Self {
526            let nonce = SystemTime::now()
527                .duration_since(UNIX_EPOCH)
528                .unwrap()
529                .as_nanos();
530            let path = std::env::temp_dir()
531                .join(format!("guth-cli-{label}-{}-{nonce}", std::process::id()));
532            fs::create_dir(&path).unwrap();
533            Self(path)
534        }
535    }
536
537    impl Drop for TestDir {
538        fn drop(&mut self) {
539            let _ = fs::remove_dir_all(&self.0);
540        }
541    }
542
543    fn test_manifest(executable: PathBuf) -> PluginManifest {
544        PluginManifest {
545            schema: MANIFEST_SCHEMA,
546            id: Uuid::now_v7(),
547            name: "Test Sync".to_string(),
548            version: "0.1.0".to_string(),
549            executable,
550            description: "A test plugin".to_string(),
551            capabilities: vec!["sync".to_string()],
552        }
553    }
554
555    #[test]
556    fn manifests_require_uuid_v7_and_absolute_executables() {
557        let mut manifest = test_manifest(PathBuf::from("relative"));
558        assert!(manifest.validate().is_err());
559        manifest.executable = PathBuf::from("/bin/true");
560        manifest.id = Uuid::nil();
561        assert!(manifest.validate().is_err());
562    }
563
564    #[cfg(unix)]
565    #[test]
566    fn installed_manifests_are_private_and_discoverable() {
567        use std::os::unix::fs::{MetadataExt, PermissionsExt};
568
569        let root = TestDir::new("discovery");
570        let executable = root.0.join("plugin");
571        fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
572        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
573        let manifest = test_manifest(executable);
574        let directory = root.0.join("manifests");
575
576        let installed = install_manifest_in(&manifest, &directory).unwrap();
577        assert_eq!(fs::metadata(&directory).unwrap().mode() & 0o777, 0o700);
578        assert_eq!(fs::metadata(&installed).unwrap().mode() & 0o777, 0o600);
579        assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
580    }
581
582    #[cfg(unix)]
583    #[test]
584    fn writable_executables_are_rejected() {
585        use std::os::unix::fs::PermissionsExt;
586
587        let root = TestDir::new("permissions");
588        let executable = root.0.join("plugin");
589        fs::write(&executable, b"plugin").unwrap();
590        fs::set_permissions(&executable, fs::Permissions::from_mode(0o722)).unwrap();
591        let error = open_verified_file(&executable, true, false).unwrap_err();
592        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
593    }
594
595    #[cfg(unix)]
596    #[test]
597    fn malformed_neighbors_do_not_hide_valid_plugins() {
598        use std::os::unix::fs::PermissionsExt;
599
600        let root = TestDir::new("malformed-neighbor");
601        let executable = root.0.join("plugin");
602        fs::write(&executable, b"plugin").unwrap();
603        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
604        let manifest = test_manifest(executable);
605        let directory = root.0.join("manifests");
606        install_manifest_in(&manifest, &directory).unwrap();
607        let malformed = directory.join(format!("{}.json", Uuid::now_v7()));
608        fs::write(&malformed, b"not json").unwrap();
609        fs::set_permissions(&malformed, fs::Permissions::from_mode(0o600)).unwrap();
610
611        assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
612    }
613
614    #[cfg(unix)]
615    #[test]
616    fn special_file_candidates_do_not_block_discovery() {
617        let root = TestDir::new("special-file");
618        let fifo = root.0.join(format!("{}.json", Uuid::now_v7()));
619        rustix::fs::mkfifoat(
620            rustix::fs::CWD,
621            &fifo,
622            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
623        )
624        .unwrap();
625
626        assert!(discover_plugins_in(&root.0).unwrap().is_empty());
627    }
628
629    #[test]
630    fn enabled_plugin_state_round_trips_uuid_v7_ids() {
631        let root = TestDir::new("enabled-state");
632        let path = root.0.join("guth/enabled-plugins.conf");
633        let ids = BTreeSet::from([Uuid::now_v7(), Uuid::now_v7()]);
634
635        write_enabled_plugins_to(&ids, &path).unwrap();
636        assert_eq!(load_enabled_plugins_from(&path).unwrap(), ids);
637    }
638}