1use 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}