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;
16const PLUGIN_DIRECTORY_ENTRY_LIMIT: usize = 4096;
17
18#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
19#[serde(deny_unknown_fields)]
20pub struct PluginManifest {
21    pub schema: u32,
22    pub id: Uuid,
23    pub name: String,
24    pub version: String,
25    pub executable: PathBuf,
26    pub description: String,
27    pub capabilities: Vec<String>,
28}
29
30impl PluginManifest {
31    pub fn validate(&self) -> io::Result<()> {
32        if self.schema != MANIFEST_SCHEMA {
33            return Err(invalid_data("unsupported plugin manifest schema"));
34        }
35        if self.id.get_version() != Some(Version::SortRand) {
36            return Err(invalid_data("plugin ID must be a UUIDv7"));
37        }
38        validate_text(&self.name, "plugin name", 80)?;
39        validate_text(&self.version, "plugin version", 32)?;
40        validate_text(&self.description, "plugin description", 280)?;
41        if !self.executable.is_absolute() {
42            return Err(invalid_data("plugin executable must be an absolute path"));
43        }
44        if self.capabilities.is_empty() || self.capabilities.len() > 16 {
45            return Err(invalid_data("plugin must declare 1 to 16 capabilities"));
46        }
47        let mut seen = BTreeSet::new();
48        for capability in &self.capabilities {
49            validate_token(capability, "plugin capability")?;
50            if !seen.insert(capability) {
51                return Err(invalid_data("plugin capabilities must be unique"));
52            }
53        }
54        Ok(())
55    }
56
57    pub fn supports(&self, capability: &str) -> bool {
58        self.capabilities
59            .iter()
60            .any(|candidate| candidate == capability)
61    }
62}
63
64pub fn plugin_data_dir() -> PathBuf {
65    std::env::var_os("XDG_DATA_HOME")
66        .map(PathBuf::from)
67        .unwrap_or_else(|| home_dir().join(".local/share"))
68        .join("guth/plugins")
69}
70
71pub fn discover_plugins() -> io::Result<Vec<PluginManifest>> {
72    discover_plugins_in(&plugin_data_dir())
73}
74
75pub fn discover_plugins_in(directory: &Path) -> io::Result<Vec<PluginManifest>> {
76    let mut manifests = Vec::new();
77    let entries = match fs::read_dir(directory) {
78        Ok(entries) => entries,
79        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(manifests),
80        Err(error) => return Err(error),
81    };
82    let mut paths = Vec::new();
83    for (index, entry) in entries.enumerate() {
84        if index >= PLUGIN_DIRECTORY_ENTRY_LIMIT {
85            return Err(invalid_data("plugin directory entry limit exceeded"));
86        }
87        let Ok(entry) = entry else {
88            continue;
89        };
90        let path = entry.path();
91        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
92            continue;
93        };
94        let Ok(id) = Uuid::parse_str(stem) else {
95            continue;
96        };
97        if id.get_version() == Some(Version::SortRand)
98            && id.to_string() == stem
99            && path
100                .extension()
101                .is_some_and(|extension| extension == "json")
102        {
103            paths.push((id, path));
104        }
105    }
106    paths.sort();
107    for (expected_id, path) in paths.into_iter().take(PLUGIN_LIMIT) {
108        let Ok(file) = open_verified_file(&path, false, true) else {
109            continue;
110        };
111        let Ok(metadata) = file.metadata() else {
112            continue;
113        };
114        if metadata.len() > MANIFEST_BYTES_LIMIT {
115            continue;
116        }
117        let mut bytes = Vec::with_capacity(metadata.len() as usize);
118        if file
119            .take(MANIFEST_BYTES_LIMIT + 1)
120            .read_to_end(&mut bytes)
121            .is_err()
122        {
123            continue;
124        }
125        let Ok(manifest) = serde_json::from_slice::<PluginManifest>(&bytes) else {
126            continue;
127        };
128        if manifest.id != expected_id
129            || manifest.validate().is_err()
130            || open_verified_file(&manifest.executable, true, false).is_err()
131        {
132            continue;
133        }
134        manifests.push(manifest);
135    }
136    manifests.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
137    manifests.dedup_by_key(|manifest| manifest.id);
138    Ok(manifests)
139}
140
141pub fn install_manifest(manifest: &PluginManifest) -> io::Result<PathBuf> {
142    install_manifest_in(manifest, &plugin_data_dir())
143}
144
145pub fn install_manifest_in(manifest: &PluginManifest, directory: &Path) -> io::Result<PathBuf> {
146    manifest.validate()?;
147    open_verified_file(&manifest.executable, true, false)?;
148    if let Some(parent) = directory.parent() {
149        create_private_dir(parent)?;
150    }
151    let directory_file = create_private_dir(directory)?;
152    rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
153        .map_err(errno_error)?;
154    let destination = directory.join(format!("{}.json", manifest.id));
155    let destination_name = format!("{}.json", manifest.id);
156    let temporary_name = format!(".{}.{}.tmp", manifest.id, Uuid::now_v7());
157    if !destination.exists() && manifest_count(directory)? >= PLUGIN_LIMIT {
158        return Err(invalid_data("plugin limit reached"));
159    }
160    let bytes = serde_json::to_vec_pretty(manifest).map_err(invalid_json)?;
161    let result = (|| {
162        let owned_fd = rustix::fs::openat(
163            &directory_file,
164            temporary_name.as_str(),
165            rustix::fs::OFlags::WRONLY
166                | rustix::fs::OFlags::CREATE
167                | rustix::fs::OFlags::EXCL
168                | rustix::fs::OFlags::NOFOLLOW
169                | rustix::fs::OFlags::CLOEXEC,
170            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
171        )
172        .map_err(errno_error)?;
173        let mut file = fs::File::from(owned_fd);
174        file.write_all(&bytes)?;
175        file.write_all(b"\n")?;
176        file.sync_all()?;
177        drop(file);
178        rustix::fs::renameat(
179            &directory_file,
180            temporary_name.as_str(),
181            &directory_file,
182            destination_name.as_str(),
183        )
184        .map_err(errno_error)?;
185        directory_file.sync_all()?;
186        Ok(destination.clone())
187    })();
188    if result.is_err() {
189        let _ = rustix::fs::unlinkat(
190            &directory_file,
191            temporary_name.as_str(),
192            rustix::fs::AtFlags::empty(),
193        );
194    }
195    result
196}
197
198pub fn run_plugin(
199    manifest: &PluginManifest,
200    arguments: impl IntoIterator<Item = OsString>,
201) -> io::Result<ExitStatus> {
202    manifest.validate()?;
203    let executable = open_verified_file(&manifest.executable, true, false)?;
204    rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty()).map_err(errno_error)?;
205    let executable_path = format!("/proc/self/fd/{}", executable.as_raw_fd());
206    Command::new(executable_path)
207        .args(arguments)
208        .stdin(Stdio::inherit())
209        .stdout(Stdio::inherit())
210        .stderr(Stdio::inherit())
211        .status()
212}
213
214fn validate_text(value: &str, label: &str, max_len: usize) -> io::Result<()> {
215    if value.is_empty()
216        || value.len() > max_len
217        || value.chars().any(|character| character.is_control())
218    {
219        return Err(invalid_data(format!("invalid {label}")));
220    }
221    Ok(())
222}
223
224fn validate_token(value: &str, label: &str) -> io::Result<()> {
225    if value.is_empty()
226        || value.len() > 40
227        || !value
228            .bytes()
229            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
230    {
231        return Err(invalid_data(format!("invalid {label}")));
232    }
233    Ok(())
234}
235
236fn open_verified_file(path: &Path, executable: bool, private: bool) -> io::Result<fs::File> {
237    let owned_fd = rustix::fs::open(
238        path,
239        rustix::fs::OFlags::RDONLY
240            | rustix::fs::OFlags::CLOEXEC
241            | rustix::fs::OFlags::NOFOLLOW
242            | rustix::fs::OFlags::NONBLOCK,
243        rustix::fs::Mode::empty(),
244    )
245    .map_err(errno_error)?;
246    let file = fs::File::from(owned_fd);
247    let metadata = file.metadata()?;
248    if !metadata.is_file() {
249        return Err(invalid_data("plugin path must be a regular file"));
250    }
251    #[cfg(unix)]
252    {
253        use std::os::unix::fs::MetadataExt;
254        let current_uid = rustix::process::geteuid().as_raw();
255        if metadata.uid() != current_uid && metadata.uid() != 0 {
256            return Err(io::Error::new(
257                io::ErrorKind::PermissionDenied,
258                "plugin file has an untrusted owner",
259            ));
260        }
261        let unsafe_permissions = if private { 0o077 } else { 0o022 };
262        if metadata.mode() & unsafe_permissions != 0 {
263            return Err(io::Error::new(
264                io::ErrorKind::PermissionDenied,
265                "plugin file has unsafe permissions",
266            ));
267        }
268        if executable && metadata.mode() & 0o111 == 0 {
269            return Err(io::Error::new(
270                io::ErrorKind::PermissionDenied,
271                "plugin executable is not executable",
272            ));
273        }
274    }
275    Ok(file)
276}
277
278fn create_private_dir(path: &Path) -> io::Result<fs::File> {
279    if !path.is_absolute() {
280        return Err(invalid_data("plugin directory must be absolute"));
281    }
282    fs::create_dir_all(path)?;
283    let owned_fd = rustix::fs::open(
284        path,
285        rustix::fs::OFlags::RDONLY
286            | rustix::fs::OFlags::DIRECTORY
287            | rustix::fs::OFlags::NOFOLLOW
288            | rustix::fs::OFlags::CLOEXEC,
289        rustix::fs::Mode::empty(),
290    )
291    .map_err(errno_error)?;
292    let directory = fs::File::from(owned_fd);
293    #[cfg(unix)]
294    {
295        use std::os::unix::fs::MetadataExt;
296        let metadata = directory.metadata()?;
297        if !metadata.is_dir() {
298            return Err(io::Error::new(
299                io::ErrorKind::PermissionDenied,
300                "plugin directory must be a real directory",
301            ));
302        }
303        if metadata.uid() != rustix::process::geteuid().as_raw() {
304            return Err(io::Error::new(
305                io::ErrorKind::PermissionDenied,
306                "plugin directory has an unexpected owner",
307            ));
308        }
309        rustix::fs::fchmod(&directory, rustix::fs::Mode::RWXU).map_err(errno_error)?;
310    }
311    Ok(directory)
312}
313
314fn manifest_count(directory: &Path) -> io::Result<usize> {
315    let mut count = 0;
316    for entry in fs::read_dir(directory)?.take(PLUGIN_DIRECTORY_ENTRY_LIMIT) {
317        let Ok(entry) = entry else {
318            continue;
319        };
320        let path = entry.path();
321        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
322            continue;
323        };
324        if path
325            .extension()
326            .is_some_and(|extension| extension == "json")
327            && Uuid::parse_str(stem).is_ok_and(|id| {
328                id.get_version() == Some(Version::SortRand) && id.to_string() == stem
329            })
330        {
331            count += 1;
332        }
333    }
334    Ok(count)
335}
336
337fn home_dir() -> PathBuf {
338    std::env::var_os("HOME")
339        .map(PathBuf::from)
340        .unwrap_or_else(|| PathBuf::from("/"))
341}
342
343fn invalid_data(message: impl Into<String>) -> io::Error {
344    io::Error::new(io::ErrorKind::InvalidData, message.into())
345}
346
347fn invalid_json(error: impl std::fmt::Display) -> io::Error {
348    invalid_data(format!("invalid plugin manifest: {error}"))
349}
350
351fn errno_error(error: rustix::io::Errno) -> io::Error {
352    io::Error::from_raw_os_error(error.raw_os_error())
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use std::time::{SystemTime, UNIX_EPOCH};
359
360    struct TestDir(PathBuf);
361
362    impl TestDir {
363        fn new(label: &str) -> Self {
364            let nonce = SystemTime::now()
365                .duration_since(UNIX_EPOCH)
366                .unwrap()
367                .as_nanos();
368            let path = std::env::temp_dir()
369                .join(format!("guth-cli-{label}-{}-{nonce}", std::process::id()));
370            fs::create_dir(&path).unwrap();
371            Self(path)
372        }
373    }
374
375    impl Drop for TestDir {
376        fn drop(&mut self) {
377            let _ = fs::remove_dir_all(&self.0);
378        }
379    }
380
381    fn test_manifest(executable: PathBuf) -> PluginManifest {
382        PluginManifest {
383            schema: MANIFEST_SCHEMA,
384            id: Uuid::now_v7(),
385            name: "Test Sync".to_string(),
386            version: "0.1.0".to_string(),
387            executable,
388            description: "A test plugin".to_string(),
389            capabilities: vec!["sync".to_string()],
390        }
391    }
392
393    #[test]
394    fn manifests_require_uuid_v7_and_absolute_executables() {
395        let mut manifest = test_manifest(PathBuf::from("relative"));
396        assert!(manifest.validate().is_err());
397        manifest.executable = PathBuf::from("/bin/true");
398        manifest.id = Uuid::nil();
399        assert!(manifest.validate().is_err());
400    }
401
402    #[cfg(unix)]
403    #[test]
404    fn installed_manifests_are_private_and_discoverable() {
405        use std::os::unix::fs::{MetadataExt, PermissionsExt};
406
407        let root = TestDir::new("discovery");
408        let executable = root.0.join("plugin");
409        fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
410        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
411        let manifest = test_manifest(executable);
412        let directory = root.0.join("manifests");
413
414        let installed = install_manifest_in(&manifest, &directory).unwrap();
415        assert_eq!(fs::metadata(&directory).unwrap().mode() & 0o777, 0o700);
416        assert_eq!(fs::metadata(&installed).unwrap().mode() & 0o777, 0o600);
417        assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
418    }
419
420    #[cfg(unix)]
421    #[test]
422    fn writable_executables_are_rejected() {
423        use std::os::unix::fs::PermissionsExt;
424
425        let root = TestDir::new("permissions");
426        let executable = root.0.join("plugin");
427        fs::write(&executable, b"plugin").unwrap();
428        fs::set_permissions(&executable, fs::Permissions::from_mode(0o722)).unwrap();
429        let error = open_verified_file(&executable, true, false).unwrap_err();
430        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
431    }
432
433    #[cfg(unix)]
434    #[test]
435    fn malformed_neighbors_do_not_hide_valid_plugins() {
436        use std::os::unix::fs::PermissionsExt;
437
438        let root = TestDir::new("malformed-neighbor");
439        let executable = root.0.join("plugin");
440        fs::write(&executable, b"plugin").unwrap();
441        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
442        let manifest = test_manifest(executable);
443        let directory = root.0.join("manifests");
444        install_manifest_in(&manifest, &directory).unwrap();
445        let malformed = directory.join(format!("{}.json", Uuid::now_v7()));
446        fs::write(&malformed, b"not json").unwrap();
447        fs::set_permissions(&malformed, fs::Permissions::from_mode(0o600)).unwrap();
448
449        assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
450    }
451
452    #[cfg(unix)]
453    #[test]
454    fn special_file_candidates_do_not_block_discovery() {
455        let root = TestDir::new("special-file");
456        let fifo = root.0.join(format!("{}.json", Uuid::now_v7()));
457        rustix::fs::mkfifoat(
458            rustix::fs::CWD,
459            &fifo,
460            rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
461        )
462        .unwrap();
463
464        assert!(discover_plugins_in(&root.0).unwrap().is_empty());
465    }
466}