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::{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}