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::{Command, ExitStatus, Stdio};
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;
#[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()
}
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);
}
}