use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::ffi::OsString;
use std::fs;
use std::io::{self, Read, Write};
use std::os::fd::AsRawFd;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{Duration, Instant};
use uuid::{Uuid, Version};
pub const MANIFEST_SCHEMA: u32 = 1;
pub const MANIFEST_BYTES_LIMIT: u64 = 64 * 1024;
pub const PLUGIN_LIMIT: usize = 64;
pub const ENABLED_PLUGIN_BYTES_LIMIT: u64 = 8 * 1024;
const PLUGIN_DIRECTORY_ENTRY_LIMIT: usize = 4096;
const PLUGIN_OUTPUT_BYTES_LIMIT: usize = 64 * 1024;
const PLUGIN_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
const PLUGIN_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(25);
const PLUGIN_OUTPUT_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PluginManifest {
pub schema: u32,
pub id: Uuid,
pub name: String,
pub version: String,
pub executable: PathBuf,
pub description: String,
pub capabilities: Vec<String>,
}
impl PluginManifest {
pub fn validate(&self) -> io::Result<()> {
if self.schema != MANIFEST_SCHEMA {
return Err(invalid_data("unsupported plugin manifest schema"));
}
if self.id.get_version() != Some(Version::SortRand) {
return Err(invalid_data("plugin ID must be a UUIDv7"));
}
validate_text(&self.name, "plugin name", 80)?;
validate_text(&self.version, "plugin version", 32)?;
validate_text(&self.description, "plugin description", 280)?;
if !self.executable.is_absolute() {
return Err(invalid_data("plugin executable must be an absolute path"));
}
if self.capabilities.is_empty() || self.capabilities.len() > 16 {
return Err(invalid_data("plugin must declare 1 to 16 capabilities"));
}
let mut seen = BTreeSet::new();
for capability in &self.capabilities {
validate_token(capability, "plugin capability")?;
if !seen.insert(capability) {
return Err(invalid_data("plugin capabilities must be unique"));
}
}
Ok(())
}
pub fn supports(&self, capability: &str) -> bool {
self.capabilities
.iter()
.any(|candidate| candidate == capability)
}
}
pub fn plugin_data_dir() -> PathBuf {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".local/share"))
.join("guth/plugins")
}
pub fn enabled_plugins_path() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".config"))
.join("guth/enabled-plugins.conf")
}
pub fn load_enabled_plugins() -> io::Result<BTreeSet<Uuid>> {
load_enabled_plugins_from(&enabled_plugins_path())
}
fn load_enabled_plugins_from(path: &Path) -> io::Result<BTreeSet<Uuid>> {
let file = match open_verified_file(path, false, true) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(BTreeSet::new()),
Err(error) => return Err(error),
};
let metadata = file.metadata()?;
if metadata.len() > ENABLED_PLUGIN_BYTES_LIMIT {
return Err(invalid_data("enabled plugin file is too large"));
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take(ENABLED_PLUGIN_BYTES_LIMIT + 1)
.read_to_end(&mut bytes)?;
let text = std::str::from_utf8(&bytes)
.map_err(|_| invalid_data("enabled plugin file must be UTF-8"))?;
let mut enabled = BTreeSet::new();
for line in text.lines() {
if line.is_empty() {
continue;
}
let id = parse_plugin_id(line)?;
if !enabled.insert(id) || enabled.len() > PLUGIN_LIMIT {
return Err(invalid_data("enabled plugin file contains invalid entries"));
}
}
Ok(enabled)
}
pub fn set_plugin_enabled(id: Uuid, enabled: bool) -> io::Result<()> {
validate_plugin_id(id)?;
let path = enabled_plugins_path();
let directory_file = lock_enabled_plugin_directory(&path)?;
let mut ids = load_enabled_plugins_from(&path)?;
if enabled {
if ids.len() >= PLUGIN_LIMIT && !ids.contains(&id) {
return Err(invalid_data("enabled plugin limit reached"));
}
ids.insert(id);
} else {
ids.remove(&id);
}
write_enabled_plugins_locked(&ids, &path, &directory_file)
}
pub fn prune_uninstalled_enabled_plugins() -> io::Result<BTreeSet<Uuid>> {
let plugin_directory = plugin_data_dir();
if let Some(parent) = plugin_directory.parent() {
create_private_dir(parent)?;
}
let plugin_directory_file = create_private_dir(&plugin_directory)?;
rustix::fs::flock(
&plugin_directory_file,
rustix::fs::FlockOperation::LockExclusive,
)
.map_err(errno_error)?;
let installed = discover_plugins_in(&plugin_directory)?
.into_iter()
.map(|plugin| plugin.id)
.collect::<BTreeSet<_>>();
let path = enabled_plugins_path();
let directory_file = lock_enabled_plugin_directory(&path)?;
let mut ids = load_enabled_plugins_from(&path)?;
let original_len = ids.len();
ids.retain(|id| installed.contains(id));
if ids.len() != original_len {
write_enabled_plugins_locked(&ids, &path, &directory_file)?;
}
Ok(ids)
}
pub fn discover_plugins() -> io::Result<Vec<PluginManifest>> {
discover_plugins_in(&plugin_data_dir())
}
pub fn discover_plugins_in(directory: &Path) -> io::Result<Vec<PluginManifest>> {
let mut manifests = Vec::new();
let entries = match fs::read_dir(directory) {
Ok(entries) => entries,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(manifests),
Err(error) => return Err(error),
};
let mut paths = Vec::new();
for (index, entry) in entries.enumerate() {
if index >= PLUGIN_DIRECTORY_ENTRY_LIMIT {
return Err(invalid_data("plugin directory entry limit exceeded"));
}
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let Ok(id) = Uuid::parse_str(stem) else {
continue;
};
if id.get_version() == Some(Version::SortRand)
&& id.to_string() == stem
&& path
.extension()
.is_some_and(|extension| extension == "json")
{
paths.push((id, path));
}
}
paths.sort();
for (expected_id, path) in paths.into_iter().take(PLUGIN_LIMIT) {
let Ok(file) = open_verified_file(&path, false, true) else {
continue;
};
let Ok(metadata) = file.metadata() else {
continue;
};
if metadata.len() > MANIFEST_BYTES_LIMIT {
continue;
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
if file
.take(MANIFEST_BYTES_LIMIT + 1)
.read_to_end(&mut bytes)
.is_err()
{
continue;
}
let Ok(manifest) = serde_json::from_slice::<PluginManifest>(&bytes) else {
continue;
};
if manifest.id != expected_id
|| manifest.validate().is_err()
|| open_verified_file(&manifest.executable, true, false).is_err()
{
continue;
}
manifests.push(manifest);
}
manifests.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
manifests.dedup_by_key(|manifest| manifest.id);
Ok(manifests)
}
pub fn install_manifest(manifest: &PluginManifest) -> io::Result<PathBuf> {
install_manifest_in(manifest, &plugin_data_dir())
}
pub fn install_manifest_in(manifest: &PluginManifest, directory: &Path) -> io::Result<PathBuf> {
manifest.validate()?;
open_verified_file(&manifest.executable, true, false)?;
if let Some(parent) = directory.parent() {
create_private_dir(parent)?;
}
let directory_file = create_private_dir(directory)?;
rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
.map_err(errno_error)?;
let destination = directory.join(format!("{}.json", manifest.id));
let destination_name = format!("{}.json", manifest.id);
let temporary_name = format!(".{}.{}.tmp", manifest.id, Uuid::now_v7());
if !destination.exists() && manifest_count(directory)? >= PLUGIN_LIMIT {
return Err(invalid_data("plugin limit reached"));
}
let bytes = serde_json::to_vec_pretty(manifest).map_err(invalid_json)?;
let result = (|| {
let owned_fd = rustix::fs::openat(
&directory_file,
temporary_name.as_str(),
rustix::fs::OFlags::WRONLY
| rustix::fs::OFlags::CREATE
| rustix::fs::OFlags::EXCL
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
)
.map_err(errno_error)?;
let mut file = fs::File::from(owned_fd);
file.write_all(&bytes)?;
file.write_all(b"\n")?;
file.sync_all()?;
drop(file);
rustix::fs::renameat(
&directory_file,
temporary_name.as_str(),
&directory_file,
destination_name.as_str(),
)
.map_err(errno_error)?;
directory_file.sync_all()?;
Ok(destination.clone())
})();
if result.is_err() {
let _ = rustix::fs::unlinkat(
&directory_file,
temporary_name.as_str(),
rustix::fs::AtFlags::empty(),
);
}
result
}
pub fn run_plugin(
manifest: &PluginManifest,
arguments: impl IntoIterator<Item = OsString>,
) -> io::Result<ExitStatus> {
manifest.validate()?;
let executable = open_verified_file(&manifest.executable, true, false)?;
rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty()).map_err(errno_error)?;
let executable_path = format!("/proc/self/fd/{}", executable.as_raw_fd());
Command::new(executable_path)
.args(arguments)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
}
#[derive(Debug)]
pub struct PluginOutput {
pub status: ExitStatus,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
pub struct PluginProcess {
child: Option<Child>,
process_group: rustix::process::Pid,
stdout_reader: Option<Receiver<io::Result<Vec<u8>>>>,
stderr_reader: Option<Receiver<io::Result<Vec<u8>>>>,
cancellation_requested_at: Option<Instant>,
}
impl PluginProcess {
pub fn try_wait(&mut self) -> io::Result<Option<PluginOutput>> {
if self
.cancellation_requested_at
.is_some_and(|started| started.elapsed() >= PLUGIN_SHUTDOWN_TIMEOUT)
{
let _ = rustix::process::kill_process_group(
self.process_group,
rustix::process::Signal::KILL,
);
}
let Some(child) = self.child.as_mut() else {
return Err(io::Error::other("plugin process was already collected"));
};
let Some(status) = child.try_wait()? else {
return Ok(None);
};
self.child.take();
let _ =
rustix::process::kill_process_group(self.process_group, rustix::process::Signal::KILL);
let stdout = collect_output(&mut self.stdout_reader)?;
let stderr = collect_output(&mut self.stderr_reader)?;
Ok(Some(PluginOutput {
status,
stdout,
stderr,
}))
}
pub fn cancel(&mut self) -> io::Result<()> {
if self.cancellation_requested_at.is_some() {
return Ok(());
}
rustix::process::kill_process_group(self.process_group, rustix::process::Signal::TERM)
.map_err(errno_error)?;
self.cancellation_requested_at = Some(Instant::now());
Ok(())
}
pub fn cancellation_requested(&self) -> bool {
self.cancellation_requested_at.is_some()
}
}
impl Drop for PluginProcess {
fn drop(&mut self) {
let Some(child) = self.child.as_mut() else {
return;
};
let _ =
rustix::process::kill_process_group(self.process_group, rustix::process::Signal::TERM);
let started = Instant::now();
while started.elapsed() < PLUGIN_SHUTDOWN_TIMEOUT {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => thread::sleep(PLUGIN_SHUTDOWN_POLL_INTERVAL),
Err(_) => break,
}
}
let _ =
rustix::process::kill_process_group(self.process_group, rustix::process::Signal::KILL);
if child.try_wait().ok().flatten().is_none() {
let _ = child.kill();
let _ = child.wait();
}
self.child.take();
self.stdout_reader.take();
self.stderr_reader.take();
}
}
pub fn spawn_plugin(
manifest: &PluginManifest,
arguments: impl IntoIterator<Item = OsString>,
) -> io::Result<PluginProcess> {
manifest.validate()?;
let executable = open_verified_file(&manifest.executable, true, false)?;
rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty()).map_err(errno_error)?;
let executable_path = format!("/proc/self/fd/{}", executable.as_raw_fd());
let mut command = Command::new(executable_path);
command
.args(arguments)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let mut child = command.spawn()?;
let process_group = match i32::try_from(child.id())
.ok()
.and_then(rustix::process::Pid::from_raw)
{
Some(process_group) => process_group,
None => {
let _ = child.kill();
let _ = child.wait();
return Err(io::Error::other("plugin process ID is out of range"));
}
};
let Some(stdout) = child.stdout.take() else {
terminate_failed_spawn(&mut child, process_group);
return Err(io::Error::other("plugin stdout was not captured"));
};
let Some(stderr) = child.stderr.take() else {
terminate_failed_spawn(&mut child, process_group);
return Err(io::Error::other("plugin stderr was not captured"));
};
let stdout_reader = match spawn_output_reader("guth-plugin-stdout", stdout) {
Ok(reader) => reader,
Err(error) => {
terminate_failed_spawn(&mut child, process_group);
return Err(error);
}
};
let stderr_reader = match spawn_output_reader("guth-plugin-stderr", stderr) {
Ok(reader) => reader,
Err(error) => {
terminate_failed_spawn(&mut child, process_group);
drop(stdout_reader);
return Err(error);
}
};
Ok(PluginProcess {
child: Some(child),
process_group,
stdout_reader: Some(stdout_reader),
stderr_reader: Some(stderr_reader),
cancellation_requested_at: None,
})
}
fn spawn_output_reader(
name: &str,
reader: impl Read + Send + 'static,
) -> io::Result<Receiver<io::Result<Vec<u8>>>> {
let (sender, receiver) = mpsc::sync_channel(1);
thread::Builder::new()
.name(name.to_string())
.spawn(move || {
let _ = sender.send(read_bounded(reader));
})?;
Ok(receiver)
}
fn terminate_failed_spawn(child: &mut Child, process_group: rustix::process::Pid) {
let _ = rustix::process::kill_process_group(process_group, rustix::process::Signal::KILL);
let _ = child.kill();
let _ = child.wait();
}
fn read_bounded(mut reader: impl Read) -> io::Result<Vec<u8>> {
let mut output = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
return Ok(output);
}
if count >= PLUGIN_OUTPUT_BYTES_LIMIT {
output.clear();
output.extend_from_slice(&buffer[count - PLUGIN_OUTPUT_BYTES_LIMIT..count]);
continue;
}
let overflow = output
.len()
.saturating_add(count)
.saturating_sub(PLUGIN_OUTPUT_BYTES_LIMIT);
if overflow > 0 {
output.drain(..overflow);
}
output.extend_from_slice(&buffer[..count]);
}
}
fn collect_output(reader: &mut Option<Receiver<io::Result<Vec<u8>>>>) -> io::Result<Vec<u8>> {
reader
.take()
.ok_or_else(|| io::Error::other("plugin output was already collected"))?
.recv_timeout(PLUGIN_OUTPUT_DRAIN_TIMEOUT)
.map_err(|error| io::Error::other(format!("plugin output reader failed: {error}")))?
}
fn validate_text(value: &str, label: &str, max_len: usize) -> io::Result<()> {
if value.is_empty()
|| value.len() > max_len
|| value.chars().any(|character| character.is_control())
{
return Err(invalid_data(format!("invalid {label}")));
}
Ok(())
}
fn validate_token(value: &str, label: &str) -> io::Result<()> {
if value.is_empty()
|| value.len() > 40
|| !value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err(invalid_data(format!("invalid {label}")));
}
Ok(())
}
fn parse_plugin_id(value: &str) -> io::Result<Uuid> {
let id = Uuid::parse_str(value).map_err(|_| invalid_data("invalid plugin UUID"))?;
if id.to_string() != value {
return Err(invalid_data(
"plugin UUID must use canonical lowercase text",
));
}
validate_plugin_id(id)?;
Ok(id)
}
fn validate_plugin_id(id: Uuid) -> io::Result<()> {
if id.get_version() != Some(Version::SortRand) {
return Err(invalid_data("plugin ID must be a UUIDv7"));
}
Ok(())
}
fn open_verified_file(path: &Path, executable: bool, private: bool) -> io::Result<fs::File> {
let owned_fd = rustix::fs::open(
path,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::CLOEXEC
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::NONBLOCK,
rustix::fs::Mode::empty(),
)
.map_err(errno_error)?;
let file = fs::File::from(owned_fd);
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(invalid_data("plugin path must be a regular file"));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let current_uid = rustix::process::geteuid().as_raw();
if metadata.uid() != current_uid && metadata.uid() != 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"plugin file has an untrusted owner",
));
}
let unsafe_permissions = if private { 0o077 } else { 0o022 };
if metadata.mode() & unsafe_permissions != 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"plugin file has unsafe permissions",
));
}
if executable && metadata.mode() & 0o111 == 0 {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"plugin executable is not executable",
));
}
}
Ok(file)
}
fn create_private_dir(path: &Path) -> io::Result<fs::File> {
if !path.is_absolute() {
return Err(invalid_data("plugin directory must be absolute"));
}
fs::create_dir_all(path)?;
let owned_fd = rustix::fs::open(
path,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::DIRECTORY
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)
.map_err(errno_error)?;
let directory = fs::File::from(owned_fd);
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let metadata = directory.metadata()?;
if !metadata.is_dir() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"plugin directory must be a real directory",
));
}
if metadata.uid() != rustix::process::geteuid().as_raw() {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"plugin directory has an unexpected owner",
));
}
rustix::fs::fchmod(&directory, rustix::fs::Mode::RWXU).map_err(errno_error)?;
}
Ok(directory)
}
fn manifest_count(directory: &Path) -> io::Result<usize> {
let mut count = 0;
for entry in fs::read_dir(directory)?.take(PLUGIN_DIRECTORY_ENTRY_LIMIT) {
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
if path
.extension()
.is_some_and(|extension| extension == "json")
&& Uuid::parse_str(stem).is_ok_and(|id| {
id.get_version() == Some(Version::SortRand) && id.to_string() == stem
})
{
count += 1;
}
}
Ok(count)
}
#[cfg(test)]
fn write_enabled_plugins_to(ids: &BTreeSet<Uuid>, path: &Path) -> io::Result<()> {
let directory_file = lock_enabled_plugin_directory(path)?;
write_enabled_plugins_locked(ids, path, &directory_file)
}
fn lock_enabled_plugin_directory(path: &Path) -> io::Result<fs::File> {
let directory = path
.parent()
.ok_or_else(|| invalid_data("enabled plugin path has no parent"))?;
let directory_file = create_private_dir(directory)?;
rustix::fs::flock(&directory_file, rustix::fs::FlockOperation::LockExclusive)
.map_err(errno_error)?;
Ok(directory_file)
}
fn write_enabled_plugins_locked(
ids: &BTreeSet<Uuid>,
path: &Path,
directory_file: &fs::File,
) -> io::Result<()> {
let file_name = path
.file_name()
.ok_or_else(|| invalid_data("enabled plugin path has no file name"))?;
let temporary_name = format!(".enabled-plugins.{}.tmp", Uuid::now_v7());
let result = (|| {
let owned_fd = rustix::fs::openat(
directory_file,
temporary_name.as_str(),
rustix::fs::OFlags::WRONLY
| rustix::fs::OFlags::CREATE
| rustix::fs::OFlags::EXCL
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
)
.map_err(errno_error)?;
let mut file = fs::File::from(owned_fd);
for id in ids {
writeln!(file, "{id}")?;
}
file.sync_all()?;
drop(file);
rustix::fs::renameat(
directory_file,
temporary_name.as_str(),
directory_file,
file_name,
)
.map_err(errno_error)?;
directory_file.sync_all()
})();
if result.is_err() {
let _ = rustix::fs::unlinkat(
directory_file,
temporary_name.as_str(),
rustix::fs::AtFlags::empty(),
);
}
result
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"))
}
fn invalid_data(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
fn invalid_json(error: impl std::fmt::Display) -> io::Error {
invalid_data(format!("invalid plugin manifest: {error}"))
}
fn errno_error(error: rustix::io::Errno) -> io::Error {
io::Error::from_raw_os_error(error.raw_os_error())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct TestDir(PathBuf);
impl TestDir {
fn new(label: &str) -> Self {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir()
.join(format!("guth-cli-{label}-{}-{nonce}", std::process::id()));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn test_manifest(executable: PathBuf) -> PluginManifest {
PluginManifest {
schema: MANIFEST_SCHEMA,
id: Uuid::now_v7(),
name: "Test Sync".to_string(),
version: "0.1.0".to_string(),
executable,
description: "A test plugin".to_string(),
capabilities: vec!["sync".to_string()],
}
}
#[test]
fn manifests_require_uuid_v7_and_absolute_executables() {
let mut manifest = test_manifest(PathBuf::from("relative"));
assert!(manifest.validate().is_err());
manifest.executable = PathBuf::from("/bin/true");
manifest.id = Uuid::nil();
assert!(manifest.validate().is_err());
}
#[cfg(unix)]
#[test]
fn installed_manifests_are_private_and_discoverable() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let root = TestDir::new("discovery");
let executable = root.0.join("plugin");
fs::write(&executable, b"#!/bin/sh\nexit 0\n").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
let manifest = test_manifest(executable);
let directory = root.0.join("manifests");
let installed = install_manifest_in(&manifest, &directory).unwrap();
assert_eq!(fs::metadata(&directory).unwrap().mode() & 0o777, 0o700);
assert_eq!(fs::metadata(&installed).unwrap().mode() & 0o777, 0o600);
assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
}
#[cfg(unix)]
#[test]
fn writable_executables_are_rejected() {
use std::os::unix::fs::PermissionsExt;
let root = TestDir::new("permissions");
let executable = root.0.join("plugin");
fs::write(&executable, b"plugin").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o722)).unwrap();
let error = open_verified_file(&executable, true, false).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
}
#[cfg(unix)]
#[test]
fn malformed_neighbors_do_not_hide_valid_plugins() {
use std::os::unix::fs::PermissionsExt;
let root = TestDir::new("malformed-neighbor");
let executable = root.0.join("plugin");
fs::write(&executable, b"plugin").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
let manifest = test_manifest(executable);
let directory = root.0.join("manifests");
install_manifest_in(&manifest, &directory).unwrap();
let malformed = directory.join(format!("{}.json", Uuid::now_v7()));
fs::write(&malformed, b"not json").unwrap();
fs::set_permissions(&malformed, fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(discover_plugins_in(&directory).unwrap(), vec![manifest]);
}
#[cfg(unix)]
#[test]
fn special_file_candidates_do_not_block_discovery() {
let root = TestDir::new("special-file");
let fifo = root.0.join(format!("{}.json", Uuid::now_v7()));
rustix::fs::mkfifoat(
rustix::fs::CWD,
&fifo,
rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
)
.unwrap();
assert!(discover_plugins_in(&root.0).unwrap().is_empty());
}
#[test]
fn enabled_plugin_state_round_trips_uuid_v7_ids() {
let root = TestDir::new("enabled-state");
let path = root.0.join("guth/enabled-plugins.conf");
let ids = BTreeSet::from([Uuid::now_v7(), Uuid::now_v7()]);
write_enabled_plugins_to(&ids, &path).unwrap();
assert_eq!(load_enabled_plugins_from(&path).unwrap(), ids);
}
#[cfg(unix)]
#[test]
fn spawned_plugins_capture_output_and_exit_status() {
use std::os::unix::fs::PermissionsExt;
let root = TestDir::new("spawn-output");
let executable = root.0.join("plugin");
fs::write(
&executable,
b"#!/bin/sh\nprintf 'converted:%s' \"$1\"\nprintf 'diagnostic' >&2\nexit 7\n",
)
.unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
let mut process =
spawn_plugin(&test_manifest(executable), [OsString::from("track.wav")]).unwrap();
let output = loop {
if let Some(output) = process.try_wait().unwrap() {
break output;
}
thread::sleep(Duration::from_millis(5));
};
assert_eq!(output.status.code(), Some(7));
assert_eq!(output.stdout, b"converted:track.wav");
assert_eq!(output.stderr, b"diagnostic");
}
#[cfg(unix)]
#[test]
fn spawned_plugins_can_be_cancelled_and_reaped() {
use std::os::unix::fs::PermissionsExt;
let root = TestDir::new("spawn-cancel");
let executable = root.0.join("plugin");
fs::write(&executable, b"#!/bin/sh\nwhile :; do sleep 1; done\n").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
let mut process = spawn_plugin(&test_manifest(executable), []).unwrap();
thread::sleep(Duration::from_millis(50));
process.cancel().unwrap();
assert!(process.cancellation_requested());
let output = loop {
if let Some(output) = process.try_wait().unwrap() {
break output;
}
thread::sleep(Duration::from_millis(5));
};
assert!(!output.status.success());
}
#[cfg(unix)]
#[test]
fn cancellation_escalates_when_term_is_ignored() {
use std::os::unix::fs::PermissionsExt;
let root = TestDir::new("spawn-cancel-escalation");
let executable = root.0.join("plugin");
fs::write(
&executable,
b"#!/bin/sh\ntrap '' TERM\nwhile :; do sleep 1; done\n",
)
.unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
let mut process = spawn_plugin(&test_manifest(executable), []).unwrap();
thread::sleep(Duration::from_millis(50));
process.cancel().unwrap();
let started = Instant::now();
let output = loop {
if let Some(output) = process.try_wait().unwrap() {
break output;
}
assert!(started.elapsed() < Duration::from_secs(3));
thread::sleep(Duration::from_millis(10));
};
assert!(!output.status.success());
}
#[cfg(unix)]
#[test]
fn dropping_plugin_with_a_descendant_is_bounded() {
use std::os::unix::fs::PermissionsExt;
let root = TestDir::new("spawn-drop-descendant");
let executable = root.0.join("plugin");
fs::write(&executable, b"#!/bin/sh\nsleep 30 &\nexit 0\n").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
let process = spawn_plugin(&test_manifest(executable), []).unwrap();
let started = Instant::now();
drop(process);
assert!(started.elapsed() < Duration::from_secs(3));
}
}