use guth_cli::{
discover_plugins_with_diagnostics, PluginActionContribution, PluginManifest,
PLUGIN_DIAGNOSTIC_LIMIT,
};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Read;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
pub const KNOTWORK_NAME: &str = "Knotwork";
pub const MEDIA_PREVIEW_PLUGIN_ID: &str = "knotlook-media";
pub const TEXT_PREVIEW_PLUGIN_ID: &str = "knotread-text";
pub const GUTH_SYNC_PLUGIN_ID: &str = "019f613e-14ad-7205-a36d-73f8ac49c24e";
pub const SYNC_CAPABILITY: &str = "sync";
pub const SYNC_READ_ONLY_CAPABILITY: &str = "sync-read-only";
pub const GUTH_CAERY_PLUGIN_ID: &str = "019f6547-2faf-7b67-b43e-3a077e2fef7f";
pub const CAERY_CONVERT_CAPABILITY: &str = "media-convert";
pub const GUTH_SYNC_VERSION: &str = "0.1.1";
pub const GUTH_CAERY_VERSION: &str = "0.1.1";
const INSTALL_STDERR_LIMIT: usize = 16 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PluginSlot {
Overlay,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PluginDescriptor {
pub id: &'static str,
pub name: &'static str,
pub slot: PluginSlot,
pub shortcut: &'static str,
pub description: &'static str,
}
#[derive(Clone, Debug, Default)]
pub struct PluginRegistry {
external: Vec<PluginManifest>,
action_contributions: BTreeMap<String, PluginActionContribution>,
diagnostics: Vec<String>,
diagnostics_limited: bool,
}
const BUILTIN_PLUGINS: [PluginDescriptor; 2] = [
PluginDescriptor {
id: MEDIA_PREVIEW_PLUGIN_ID,
name: "Knotlook Media",
slot: PluginSlot::Overlay,
shortcut: "Space",
description: "Inline image and video preview using native Guth surfaces.",
},
PluginDescriptor {
id: TEXT_PREVIEW_PLUGIN_ID,
name: "Knotread Text",
slot: PluginSlot::Overlay,
shortcut: "Space",
description: "Bounded inline text, code, markdown, CSV, and log preview.",
},
];
impl PluginRegistry {
pub fn builtin() -> Self {
let mut diagnostics = Vec::new();
let mut diagnostics_limited = false;
if let Err(error) = repair_official_plugin_permissions() {
diagnostics.push(format!("Official plugin repair failed: {error}"));
}
let (external, action_contributions) = match discover_plugins_with_diagnostics() {
Ok(report) => {
let action_contributions = report
.action_contributions
.into_iter()
.map(|contribution| {
(contribution.plugin_id.to_string(), contribution.declaration)
})
.collect();
diagnostics.extend(report.diagnostics.into_iter().map(|diagnostic| {
format!("{}: {}", diagnostic.path.display(), diagnostic.message)
}));
diagnostics_limited = report.diagnostics_limited;
(report.plugins, action_contributions)
}
Err(error) => {
diagnostics.push(format!("Plugin discovery failed: {error}"));
(Vec::new(), BTreeMap::new())
}
};
if diagnostics.len() > PLUGIN_DIAGNOSTIC_LIMIT {
diagnostics.truncate(PLUGIN_DIAGNOSTIC_LIMIT);
diagnostics_limited = true;
}
Self {
external,
action_contributions,
diagnostics,
diagnostics_limited,
}
}
pub fn all(&self) -> &'static [PluginDescriptor] {
&BUILTIN_PLUGINS
}
pub fn external(&self) -> &[PluginManifest] {
&self.external
}
pub fn action_contribution(&self, plugin_id: &str) -> Option<&PluginActionContribution> {
self.action_contributions.get(plugin_id)
}
pub fn action_contributors(
&self,
) -> impl Iterator<Item = (&PluginManifest, &PluginActionContribution)> {
self.external.iter().filter_map(|plugin| {
self.action_contributions
.get(&plugin.id.to_string())
.map(|contribution| (plugin, contribution))
})
}
pub fn enabled_action_contributors<'a>(
&'a self,
enabled: &'a BTreeSet<String>,
) -> impl Iterator<Item = (&'a PluginManifest, &'a PluginActionContribution)> + 'a {
self.action_contributors()
.filter(|(plugin, _)| enabled.contains(&plugin.id.to_string()))
}
pub fn diagnostics(&self) -> &[String] {
&self.diagnostics
}
pub fn diagnostics_limited(&self) -> bool {
self.diagnostics_limited
}
pub fn refresh_external(&mut self) -> Result<(), String> {
let repair_error = repair_official_plugin_permissions().err();
let report = discover_plugins_with_diagnostics().map_err(|error| error.to_string())?;
self.external = report.plugins;
self.action_contributions = report
.action_contributions
.into_iter()
.map(|contribution| (contribution.plugin_id.to_string(), contribution.declaration))
.collect();
self.diagnostics =
repair_error
.into_iter()
.map(|error| format!("Official plugin repair failed: {error}"))
.chain(report.diagnostics.into_iter().map(|diagnostic| {
format!("{}: {}", diagnostic.path.display(), diagnostic.message)
}))
.collect();
self.diagnostics_limited = report.diagnostics_limited;
if self.diagnostics.len() > PLUGIN_DIAGNOSTIC_LIMIT {
self.diagnostics.truncate(PLUGIN_DIAGNOSTIC_LIMIT);
self.diagnostics_limited = true;
}
Ok(())
}
pub fn installed_count(&self) -> usize {
self.all().len() + self.external.len()
}
pub fn media_preview(&self) -> PluginDescriptor {
BUILTIN_PLUGINS[0]
}
pub fn text_preview(&self) -> PluginDescriptor {
BUILTIN_PLUGINS[1]
}
}
pub fn external_plugin_gui_compatible(
plugin: &PluginManifest,
requested_id: &str,
capability: &str,
) -> bool {
if plugin.id.to_string() != requested_id || !plugin.supports(capability) {
return false;
}
match requested_id {
GUTH_SYNC_PLUGIN_ID => plugin_contract_matches(&guth_sync_package(), plugin),
GUTH_CAERY_PLUGIN_ID => plugin_contract_matches(&guth_caery_package(), plugin),
_ => false,
}
}
pub fn install_guth_sync(cancellation: Arc<AtomicBool>) -> Result<(), String> {
install_crates_plugin(guth_sync_package(), cancellation)
}
pub fn install_guth_caery(cancellation: Arc<AtomicBool>) -> Result<(), String> {
install_crates_plugin(guth_caery_package(), cancellation)
}
struct PluginPackage {
name: &'static str,
crate_name: &'static str,
version: &'static str,
executable_name: &'static str,
plugin_id: &'static str,
required_capabilities: &'static [&'static str],
requirements: &'static [RuntimeRequirement],
}
struct RuntimeRequirement {
program: &'static str,
arguments: &'static [&'static str],
}
fn guth_sync_package() -> PluginPackage {
PluginPackage {
name: "Guth Sync",
crate_name: "guth-sync",
version: GUTH_SYNC_VERSION,
executable_name: "guth-sync",
plugin_id: GUTH_SYNC_PLUGIN_ID,
required_capabilities: &[SYNC_CAPABILITY, SYNC_READ_ONLY_CAPABILITY],
requirements: &[
RuntimeRequirement {
program: "rsync",
arguments: &["--version"],
},
RuntimeRequirement {
program: "ssh",
arguments: &["-V"],
},
],
}
}
fn guth_caery_package() -> PluginPackage {
PluginPackage {
name: "Guth Caery",
crate_name: "guth-caery",
version: GUTH_CAERY_VERSION,
executable_name: "guth-caery",
plugin_id: GUTH_CAERY_PLUGIN_ID,
required_capabilities: &[CAERY_CONVERT_CAPABILITY],
requirements: &[RuntimeRequirement {
program: "ffmpeg",
arguments: &["-version"],
}],
}
}
fn install_crates_plugin(
package: PluginPackage,
cancellation: Arc<AtomicBool>,
) -> Result<(), String> {
let temporary = PrivateInstallDirectory::new()?;
for requirement in package.requirements {
let mut command = Command::new(requirement.program);
command.args(requirement.arguments);
run_bounded(
command,
&format!("{} check", requirement.program),
Duration::from_secs(15),
&cancellation,
)
.map_err(|error| {
format!(
"{error}. Install {} before enabling {}.",
requirement.program, package.name
)
})?;
}
let install_root = cargo_install_root()?;
let version_requirement = format!("={}", package.version);
let mut install = Command::new("cargo");
install
.args(["install", "--locked", package.crate_name, "--version"])
.arg(version_requirement)
.args(["--force", "--root"])
.arg(&install_root)
.current_dir(&temporary.path);
run_bounded(
install,
&format!("{} installation", package.name),
Duration::from_secs(10 * 60),
&cancellation,
)?;
let executable = install_root.join("bin").join(package.executable_name);
harden_plugin_executable(&executable)?;
let mut manifest = Command::new(&executable);
manifest.arg("install-plugin");
run_bounded(
manifest,
&format!("{} manifest installation", package.name),
Duration::from_secs(15),
&cancellation,
)?;
verify_plugin_manifest(&package, &executable)?;
Ok(())
}
fn run_bounded(
mut command: Command,
label: &str,
timeout: Duration,
cancellation: &AtomicBool,
) -> Result<(), String> {
use std::os::unix::process::CommandExt;
if cancellation.load(Ordering::Acquire) {
return Err(format!("{label} was cancelled"));
}
command.process_group(0);
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("{label} could not start: {error}"))?;
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(format!("{label} process ID is out of range"));
}
};
let Some(stderr) = child.stderr.take() else {
terminate_process_group(&mut child, process_group);
return Err(format!("{label} stderr was not captured"));
};
let stderr = spawn_stderr_reader(stderr).map_err(|error| {
terminate_process_group(&mut child, process_group);
format!("{label} output reader could not start: {error}")
})?;
let deadline = Instant::now() + timeout;
loop {
if cancellation.load(Ordering::Acquire) {
terminate_process_group(&mut child, process_group);
return Err(format!("{label} was cancelled"));
}
match child.try_wait() {
Ok(Some(status)) => {
let _ = rustix::process::kill_process_group(
process_group,
rustix::process::Signal::KILL,
);
if status.success() {
return Ok(());
}
let detail = collect_stderr(stderr);
return Err(if detail.is_empty() {
format!("{label} exited with {status}")
} else {
format!("{label} exited with {status}: {detail}")
});
}
Ok(None) if Instant::now() < deadline => {
thread::sleep(Duration::from_millis(50));
}
Ok(None) => {
terminate_process_group(&mut child, process_group);
return Err(format!("{label} timed out"));
}
Err(error) => {
terminate_process_group(&mut child, process_group);
return Err(format!("{label} failed: {error}"));
}
}
}
}
fn cargo_install_root() -> Result<PathBuf, String> {
let cargo_home = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))
.ok_or_else(|| "Cannot determine Cargo's installation directory".to_string())?;
if !cargo_home.exists() {
use std::os::unix::fs::DirBuilderExt;
let mut builder = fs::DirBuilder::new();
builder.recursive(true).mode(0o700);
builder
.create(&cargo_home)
.map_err(|error| format!("Could not create Cargo's installation directory: {error}"))?;
}
fs::canonicalize(&cargo_home)
.map_err(|error| format!("Could not locate Cargo's installation directory: {error}"))
}
fn repair_official_plugin_permissions() -> Result<(), String> {
let install_root = cargo_install_root()?;
for executable_name in ["guth-sync", "guth-caery"] {
let executable = install_root.join("bin").join(executable_name);
match fs::symlink_metadata(&executable) {
Ok(_) => harden_plugin_executable(&executable)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"Could not inspect {}: {error}",
executable.display()
))
}
}
}
Ok(())
}
fn harden_plugin_executable(path: &std::path::Path) -> Result<(), String> {
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(|error| format!("Could not secure {}: {error}", path.display()))?;
let file = fs::File::from(owned_fd);
let metadata = file
.metadata()
.map_err(|error| format!("Could not inspect {}: {error}", path.display()))?;
if !metadata.is_file() {
return Err(format!(
"Could not secure {}: plugin executable is not a regular file",
path.display()
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let mode = metadata.mode();
if mode & 0o7022 == 0 && mode & 0o111 != 0 {
return Ok(());
}
if metadata.uid() != rustix::process::geteuid().as_raw() {
return Err(format!(
"Could not secure {}: executable is not owned by the current user",
path.display()
));
}
}
rustix::fs::fchmod(&file, rustix::fs::Mode::RWXU)
.map_err(|error| format!("Could not secure {}: {error}", path.display()))
}
fn verify_plugin_manifest(
package: &PluginPackage,
executable: &std::path::Path,
) -> Result<(), String> {
let expected_executable = fs::canonicalize(executable)
.map_err(|error| format!("Could not verify {} executable: {error}", package.name))?;
let plugin = discover_plugins_with_diagnostics()
.map_err(|error| error.to_string())?
.plugins
.into_iter()
.find(|plugin| plugin.id.to_string() == package.plugin_id)
.ok_or_else(|| {
format!(
"{} manifest was not discovered after installation",
package.name
)
})?;
if !plugin_manifest_matches(package, &plugin, &expected_executable) {
return Err(format!(
"{} manifest did not match the requested crates.io package",
package.name
));
}
Ok(())
}
fn plugin_manifest_matches(
package: &PluginPackage,
plugin: &PluginManifest,
expected_executable: &std::path::Path,
) -> bool {
plugin_contract_matches(package, plugin) && plugin.executable == expected_executable
}
fn plugin_contract_matches(package: &PluginPackage, plugin: &PluginManifest) -> bool {
plugin.id.to_string() == package.plugin_id
&& plugin.version == package.version
&& package
.required_capabilities
.iter()
.all(|capability| plugin.supports(capability))
}
fn spawn_stderr_reader(
mut stderr: impl Read + Send + 'static,
) -> std::io::Result<Receiver<Vec<u8>>> {
let (sender, receiver) = mpsc::sync_channel(1);
thread::Builder::new()
.name("guth-plugin-install-stderr".to_string())
.spawn(move || {
let mut retained = Vec::new();
let mut buffer = [0_u8; 4096];
while let Ok(count) = stderr.read(&mut buffer) {
if count == 0 {
break;
}
let overflow = retained
.len()
.saturating_add(count)
.saturating_sub(INSTALL_STDERR_LIMIT);
if overflow > 0 {
retained.drain(..overflow.min(retained.len()));
}
retained.extend_from_slice(&buffer[..count]);
}
let _ = sender.send(retained);
})?;
Ok(receiver)
}
fn collect_stderr(stderr: Receiver<Vec<u8>>) -> String {
let bytes = stderr
.recv_timeout(Duration::from_millis(250))
.unwrap_or_default();
let normalized = String::from_utf8_lossy(&bytes)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let count = normalized.chars().count();
if count <= 2048 {
normalized
} else {
normalized.chars().skip(count - 2048).collect()
}
}
fn terminate_process_group(child: &mut std::process::Child, process_group: rustix::process::Pid) {
let _ = rustix::process::kill_process_group(process_group, rustix::process::Signal::TERM);
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => thread::sleep(Duration::from_millis(25)),
Err(_) => break,
}
}
let _ = rustix::process::kill_process_group(process_group, rustix::process::Signal::KILL);
let _ = child.kill();
let _ = child.wait();
}
struct PrivateInstallDirectory {
path: PathBuf,
}
impl PrivateInstallDirectory {
fn new() -> Result<Self, String> {
use std::os::unix::fs::DirBuilderExt;
use std::time::{SystemTime, UNIX_EPOCH};
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| error.to_string())?
.as_nanos();
for attempt in 0..32 {
let path = std::env::temp_dir().join(format!(
"guth-plugin-install-{}-{nonce}-{attempt}",
std::process::id()
));
let mut builder = fs::DirBuilder::new();
builder.mode(0o700);
match builder.create(&path) {
Ok(()) => return Ok(Self { path }),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"Could not create a private install directory: {error}"
))
}
}
}
Err("Could not reserve a private install directory".to_string())
}
}
impl Drop for PrivateInstallDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_registry_exposes_media_preview_plugin() {
let registry = PluginRegistry::builtin();
let plugin = registry.media_preview();
assert_eq!(plugin.id, MEDIA_PREVIEW_PLUGIN_ID);
assert_eq!(registry.all()[0], plugin);
assert_eq!(registry.text_preview().id, TEXT_PREVIEW_PLUGIN_ID);
}
#[test]
fn official_packages_require_their_complete_capability_sets() {
assert_eq!(
guth_sync_package().required_capabilities,
&[SYNC_CAPABILITY, SYNC_READ_ONLY_CAPABILITY]
);
assert_eq!(
guth_caery_package().required_capabilities,
&[CAERY_CONVERT_CAPABILITY]
);
}
#[test]
fn official_manifest_match_requires_every_capability() {
let package = guth_sync_package();
let executable = PathBuf::from("/usr/bin/guth-sync");
let mut plugin = PluginManifest {
schema: guth_cli::MANIFEST_SCHEMA,
id: GUTH_SYNC_PLUGIN_ID.parse().unwrap(),
name: "Guth Sync".to_string(),
version: GUTH_SYNC_VERSION.to_string(),
executable: executable.clone(),
description: "Test Guth Sync manifest".to_string(),
capabilities: vec![SYNC_READ_ONLY_CAPABILITY.to_string()],
};
assert!(!plugin_manifest_matches(&package, &plugin, &executable));
plugin.capabilities.push(SYNC_CAPABILITY.to_string());
assert!(plugin_manifest_matches(&package, &plugin, &executable));
}
#[test]
fn gui_contract_rejects_stale_and_arbitrary_external_plugins() {
let mut sync = PluginManifest {
schema: guth_cli::MANIFEST_SCHEMA,
id: GUTH_SYNC_PLUGIN_ID.parse().unwrap(),
name: "Guth Sync".to_string(),
version: GUTH_SYNC_VERSION.to_string(),
executable: PathBuf::from("/usr/bin/guth-sync"),
description: "Test Guth Sync manifest".to_string(),
capabilities: vec![
SYNC_CAPABILITY.to_string(),
SYNC_READ_ONLY_CAPABILITY.to_string(),
],
};
assert!(external_plugin_gui_compatible(
&sync,
GUTH_SYNC_PLUGIN_ID,
SYNC_CAPABILITY
));
sync.version = "0.1.0".to_string();
assert!(!external_plugin_gui_compatible(
&sync,
GUTH_SYNC_PLUGIN_ID,
SYNC_CAPABILITY
));
sync.id = "019f6e39-b35e-7a11-a65d-65ac5cc73342".parse().unwrap();
sync.version = GUTH_SYNC_VERSION.to_string();
assert!(!external_plugin_gui_compatible(
&sync,
&sync.id.to_string(),
SYNC_CAPABILITY
));
}
#[test]
fn registry_exposes_only_declared_and_enabled_action_contributors() {
let action_plugin = PluginManifest {
schema: guth_cli::ACTION_MANIFEST_SCHEMA,
id: "019f6e39-b35e-7a11-a65d-65ac5cc73342".parse().unwrap(),
name: "Action plugin".to_string(),
version: "1.0.0".to_string(),
executable: PathBuf::from("/usr/bin/action-plugin"),
description: "Contributes bounded file actions".to_string(),
capabilities: vec![guth_cli::ACTION_CONTRIBUTION_CAPABILITY.to_string()],
};
let legacy_plugin = PluginManifest {
schema: guth_cli::MANIFEST_SCHEMA,
id: "019f6e39-b35e-7a11-a65d-65ac5cc73343".parse().unwrap(),
name: "Legacy plugin".to_string(),
version: "1.0.0".to_string(),
executable: PathBuf::from("/usr/bin/legacy-plugin"),
description: "Does not contribute GUI actions".to_string(),
capabilities: vec!["sync".to_string()],
};
let registry = PluginRegistry {
external: vec![action_plugin.clone(), legacy_plugin],
action_contributions: BTreeMap::from([(
action_plugin.id.to_string(),
PluginActionContribution {
protocol: guth_cli::ACTION_PROTOCOL_VERSION,
},
)]),
diagnostics: Vec::new(),
diagnostics_limited: false,
};
assert_eq!(registry.action_contributors().count(), 1);
assert_eq!(
registry
.enabled_action_contributors(&BTreeSet::new())
.count(),
0
);
let enabled = BTreeSet::from([action_plugin.id.to_string()]);
let contributors = registry
.enabled_action_contributors(&enabled)
.collect::<Vec<_>>();
assert_eq!(contributors.len(), 1);
assert_eq!(contributors[0].0.id, action_plugin.id);
assert_eq!(
contributors[0].1.protocol,
guth_cli::ACTION_PROTOCOL_VERSION
);
}
#[cfg(unix)]
#[test]
fn plugin_installs_use_private_temporary_directories() {
use std::os::unix::fs::MetadataExt;
let temporary = PrivateInstallDirectory::new().unwrap();
let path = temporary.path.clone();
assert_eq!(fs::metadata(&path).unwrap().mode() & 0o777, 0o700);
drop(temporary);
assert!(!path.exists());
}
#[cfg(unix)]
#[test]
fn plugin_installs_remove_unsafe_write_permissions() {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let temporary = PrivateInstallDirectory::new().unwrap();
let executable = temporary.path.join("plugin");
fs::write(&executable, b"binary").unwrap();
fs::set_permissions(&executable, fs::Permissions::from_mode(0o777)).unwrap();
harden_plugin_executable(&executable).unwrap();
assert_eq!(fs::metadata(executable).unwrap().mode() & 0o777, 0o700);
}
}