use guth_cli::{discover_plugins, PluginManifest};
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_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";
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>,
}
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 _ = repair_official_plugin_permissions();
Self {
external: discover_plugins().unwrap_or_default(),
}
}
pub fn all(&self) -> &'static [PluginDescriptor] {
&BUILTIN_PLUGINS
}
pub fn external(&self) -> &[PluginManifest] {
&self.external
}
pub fn refresh_external(&mut self) -> Result<(), String> {
repair_official_plugin_permissions()?;
self.external = discover_plugins().map_err(|error| error.to_string())?;
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 install_guth_sync(cancellation: Arc<AtomicBool>) -> Result<(), String> {
install_crates_plugin(
PluginPackage {
name: "Guth Sync",
crate_name: "guth-sync",
version: GUTH_SYNC_VERSION,
executable_name: "guth-sync",
plugin_id: GUTH_SYNC_PLUGIN_ID,
capability: SYNC_READ_ONLY_CAPABILITY,
requirements: &[
RuntimeRequirement {
program: "rsync",
arguments: &["--version"],
},
RuntimeRequirement {
program: "ssh",
arguments: &["-V"],
},
],
},
cancellation,
)
}
pub fn install_guth_caery(cancellation: Arc<AtomicBool>) -> Result<(), String> {
install_crates_plugin(
PluginPackage {
name: "Guth Caery",
crate_name: "guth-caery",
version: GUTH_CAERY_VERSION,
executable_name: "guth-caery",
plugin_id: GUTH_CAERY_PLUGIN_ID,
capability: CAERY_CONVERT_CAPABILITY,
requirements: &[RuntimeRequirement {
program: "ffmpeg",
arguments: &["-version"],
}],
},
cancellation,
)
}
struct PluginPackage {
name: &'static str,
crate_name: &'static str,
version: &'static str,
executable_name: &'static str,
plugin_id: &'static str,
capability: &'static str,
requirements: &'static [RuntimeRequirement],
}
struct RuntimeRequirement {
program: &'static str,
arguments: &'static [&'static str],
}
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 & 0o022 == 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()
.map_err(|error| error.to_string())?
.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.version != package.version
|| !plugin.supports(package.capability)
|| plugin.executable != expected_executable
{
return Err(format!(
"{} manifest did not match the requested crates.io package",
package.name
));
}
Ok(())
}
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);
}
#[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);
}
}