use std::path::{Component, Path, PathBuf};
use std::time::Duration;
use crate::skills::manifest::PluginManifest;
pub const MAX_PREBUILT_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
const PREBUILT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const PREBUILT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
pub fn safe_name_fragment(input: &str) -> String {
let mut out = String::with_capacity(input.len().min(80));
for ch in input.chars().take(80) {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
out.push(ch);
} else {
out.push('_');
}
}
let trimmed = out.trim_matches('.').trim_matches('_').to_string();
if trimmed.is_empty() || trimmed == ".." {
"plugin".to_string()
} else {
trimmed
}
}
pub fn normalize_sha256(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.len() == 64 && trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
Some(trimmed.to_ascii_lowercase())
} else {
None
}
}
fn prebuilt_url_allowed(url: &str) -> bool {
if url.starts_with("https://") {
return true;
}
#[cfg(test)]
{
if url.starts_with("file://") {
return true;
}
}
false
}
fn archive_suffix(url_for_suffix: &str) -> Result<&'static str, PrebuiltError> {
let url_clean = url_for_suffix
.split(['?', '#'])
.next()
.unwrap_or(url_for_suffix)
.to_ascii_lowercase();
if url_clean.ends_with(".tar.gz") || url_clean.ends_with(".tgz") {
Ok("tar.gz")
} else if url_clean.ends_with(".zip") {
Ok("zip")
} else if url_clean.ends_with(".tar.xz") || url_clean.ends_with(".tar.bz2") {
Err(PrebuiltError::UnsupportedArchive {
url: format!("{url_for_suffix} (xz/bz2 prebuilt archives are not supported by the hardened extractor; use .tar.gz or .zip)"),
})
} else {
Err(PrebuiltError::UnsupportedArchive {
url: url_for_suffix.to_string(),
})
}
}
fn validate_archive_relative_path(path: &Path) -> Result<(), PrebuiltError> {
if path.is_absolute() {
return Err(PrebuiltError::Extract(format!(
"archive entry '{}' is absolute",
path.display()
)));
}
if path.components().any(|c| matches!(c, Component::ParentDir | Component::Prefix(_))) {
return Err(PrebuiltError::Extract(format!(
"archive entry '{}' escapes extraction directory",
path.display()
)));
}
Ok(())
}
fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dest.join(entry.file_name());
let meta = entry.file_type()?;
if meta.is_dir() {
std::fs::create_dir_all(&to)?;
copy_dir_contents(&from, &to)?;
} else if meta.is_file() {
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&from, &to)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&from)?.permissions().mode();
std::fs::set_permissions(&to, std::fs::Permissions::from_mode(mode))?;
}
}
}
Ok(())
}
pub fn host_triple() -> Option<&'static str> {
let os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"darwin"
} else if cfg!(target_os = "windows") {
"windows"
} else {
return None;
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
return None;
};
Some(match (os, arch) {
("linux", "x86_64") => "linux-x86_64",
("linux", "arm64") => "linux-arm64",
("darwin", "x86_64") => "darwin-x86_64",
("darwin", "arm64") => "darwin-arm64",
("windows", "x86_64") => "windows-x86_64",
("windows", "arm64") => "windows-arm64",
_ => return None,
})
}
pub const SETUP_TIMEOUT: Duration = Duration::from_secs(600);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetupOutcome {
pub log_path: PathBuf,
pub exit_code: i32,
}
#[derive(Debug, thiserror::Error)]
pub enum SetupError {
#[error("setup script path '{path}' escapes plugin directory")]
EscapesPluginDir { path: String },
#[error("setup script '{path}' not found in plugin directory")]
NotFound { path: String },
#[error("setup script exited with code {exit_code}; see {}", log_path.display())]
NonZeroExit { exit_code: i32, log_path: PathBuf },
#[error("setup script timed out after {secs}s; see {}", log_path.display())]
Timeout { secs: u64, log_path: PathBuf },
#[error("setup script io: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum CommandVerifyError {
#[error("extension command path '{path}' escapes plugin directory")]
EscapesPluginDir { path: String },
#[error("extension command '{path}' does not exist (resolved to {})", resolved.display())]
Missing { path: String, resolved: PathBuf },
#[cfg(unix)]
#[error("extension command '{path}' exists but is not executable (mode {mode:o})")]
NotExecutable { path: String, mode: u32 },
#[error("extension command '{path}' is a directory, not a file")]
NotAFile { path: String },
}
pub fn verify_extension_command(
manifest: &PluginManifest,
plugin_dir: &Path,
) -> Result<Option<PathBuf>, CommandVerifyError> {
let Some(ext) = manifest.extension.as_ref() else {
return Ok(None);
};
let cmd = &ext.command;
let cmd_path = Path::new(cmd);
if !cmd.contains(std::path::MAIN_SEPARATOR) && !cmd.contains('/') {
return Ok(None);
}
let resolved = if cmd_path.is_absolute() {
return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
} else {
if cmd_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
}
let joined = plugin_dir.join(cmd_path);
match joined.canonicalize() {
Ok(p) => {
let canonical_dir = plugin_dir
.canonicalize()
.unwrap_or_else(|_| plugin_dir.to_path_buf());
if !p.starts_with(&canonical_dir) {
return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
}
p
}
Err(_) => {
return Err(CommandVerifyError::Missing {
path: cmd.clone(),
resolved: joined,
});
}
}
};
if !resolved.exists() {
return Err(CommandVerifyError::Missing {
path: cmd.clone(),
resolved,
});
}
let meta = std::fs::metadata(&resolved).map_err(|_| CommandVerifyError::Missing {
path: cmd.clone(),
resolved: resolved.clone(),
})?;
if meta.is_dir() {
return Err(CommandVerifyError::NotAFile { path: cmd.clone() });
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = meta.permissions().mode();
if mode & 0o111 == 0 {
return Err(CommandVerifyError::NotExecutable {
path: cmd.clone(),
mode,
});
}
}
Ok(Some(resolved))
}
#[derive(Debug, thiserror::Error)]
pub enum PrebuiltError {
#[error("no prebuilt asset declared for this host")]
NoMatchingAsset,
#[error("download failed: {0}")]
Download(String),
#[error("checksum mismatch: expected {expected}, got {actual}")]
ChecksumMismatch { expected: String, actual: String },
#[error("archive extraction failed: {0}")]
Extract(String),
#[error("unsupported archive type for url '{url}'")]
UnsupportedArchive { url: String },
#[error("refusing non-https prebuilt url '{url}'")]
UnsafeUrl { url: String },
#[error("invalid sha256 '{sha256}'; expected exactly 64 hex characters")]
InvalidSha256 { sha256: String },
#[error("prebuilt archive exceeds maximum size of {max} bytes")]
TooLarge { max: u64 },
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("prebuilt extracted but extension command not found: {0}")]
Verify(#[from] CommandVerifyError),
}
fn hex_encode_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(char::from_digit((*b >> 4) as u32, 16).unwrap());
out.push(char::from_digit((*b & 0x0f) as u32, 16).unwrap());
}
out
}
pub async fn try_install_from_prebuilt(
manifest: &PluginManifest,
plugin_dir: &Path,
) -> Result<PathBuf, PrebuiltError> {
let Some(ext) = manifest.extension.as_ref() else {
return Err(PrebuiltError::NoMatchingAsset);
};
let Some(triple) = host_triple() else {
return Err(PrebuiltError::NoMatchingAsset);
};
let Some(asset) = ext.prebuilt.get(triple) else {
return Err(PrebuiltError::NoMatchingAsset);
};
if !prebuilt_url_allowed(&asset.url) {
return Err(PrebuiltError::UnsafeUrl {
url: asset.url.clone(),
});
}
let expected_sha = normalize_sha256(&asset.sha256).ok_or_else(|| PrebuiltError::InvalidSha256 {
sha256: asset.sha256.clone(),
})?;
let tmp_archive = plugin_dir.join(format!(".prebuilt-{triple}-download"));
match std::fs::remove_file(&tmp_archive) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(PrebuiltError::Io(e)),
}
let download_res = download_prebuilt_to_file(&asset.url, &tmp_archive, MAX_PREBUILT_ARCHIVE_BYTES).await;
let actual = match download_res {
Ok(sha) => sha,
Err(e) => {
let _ = std::fs::remove_file(&tmp_archive);
return Err(e);
}
};
if actual != expected_sha {
let _ = std::fs::remove_file(&tmp_archive);
return Err(PrebuiltError::ChecksumMismatch {
expected: expected_sha,
actual,
});
}
let archive = tmp_archive.clone();
let dest = plugin_dir.to_path_buf();
let url = asset.url.clone();
let extract_res = tokio::task::spawn_blocking(move || extract_archive(&archive, &dest, &url))
.await
.map_err(|e| PrebuiltError::Extract(format!("extract task join error: {e}")))?;
let _ = std::fs::remove_file(&tmp_archive);
extract_res?;
let resolved = verify_extension_command(manifest, plugin_dir)?
.ok_or_else(|| {
PrebuiltError::Verify(CommandVerifyError::Missing {
path: ext.command.clone(),
resolved: plugin_dir.join(&ext.command),
})
})?;
Ok(resolved)
}
async fn download_prebuilt_to_file(
url: &str,
tmp_archive: &Path,
max_bytes: u64,
) -> Result<String, PrebuiltError> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
let mut written: u64 = 0;
if let Some(path) = url.strip_prefix("file://") {
#[cfg(not(test))]
{
let _ = path;
return Err(PrebuiltError::UnsafeUrl { url: url.to_string() });
}
#[cfg(test)]
{
let mut input = std::fs::File::open(path)
.map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
let mut output = std::fs::File::create(tmp_archive)?;
let mut buf = [0u8; 8192];
loop {
let n = std::io::Read::read(&mut input, &mut buf)
.map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
if n == 0 {
break;
}
written += n as u64;
if written > max_bytes {
return Err(PrebuiltError::TooLarge { max: max_bytes });
}
hasher.update(&buf[..n]);
std::io::Write::write_all(&mut output, &buf[..n])?;
}
return Ok(hex_encode_lower(&hasher.finalize()));
}
}
let client = reqwest::Client::builder()
.connect_timeout(PREBUILT_CONNECT_TIMEOUT)
.timeout(PREBUILT_REQUEST_TIMEOUT)
.build()
.map_err(|e| PrebuiltError::Download(e.to_string()))?;
let mut response = client
.get(url)
.send()
.await
.map_err(|e| PrebuiltError::Download(e.to_string()))?;
if !response.status().is_success() {
return Err(PrebuiltError::Download(format!("HTTP {}", response.status())));
}
if let Some(len) = response.content_length() {
if len > max_bytes {
return Err(PrebuiltError::TooLarge { max: max_bytes });
}
}
let mut output = tokio::fs::File::create(tmp_archive).await?;
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| PrebuiltError::Download(e.to_string()))?
{
written += chunk.len() as u64;
if written > max_bytes {
return Err(PrebuiltError::TooLarge { max: max_bytes });
}
hasher.update(&chunk);
tokio::io::AsyncWriteExt::write_all(&mut output, &chunk).await?;
}
tokio::io::AsyncWriteExt::flush(&mut output).await?;
Ok(hex_encode_lower(&hasher.finalize()))
}
fn extract_archive(
archive: &Path,
dest_dir: &Path,
url_for_suffix: &str,
) -> Result<(), PrebuiltError> {
let kind = archive_suffix(url_for_suffix)?;
let extract_root = dest_dir.join(format!(
".prebuilt-extract-{}",
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
));
match std::fs::remove_dir_all(&extract_root) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(PrebuiltError::Io(e)),
}
std::fs::create_dir_all(&extract_root)?;
let result = match kind {
"tar.gz" => extract_tar_gz_safe(archive, &extract_root),
"zip" => extract_zip_safe(archive, &extract_root),
_ => unreachable!(),
}
.and_then(|_| copy_dir_contents(&extract_root, dest_dir).map_err(PrebuiltError::Io));
let cleanup = std::fs::remove_dir_all(&extract_root);
match (result, cleanup) {
(Ok(()), Ok(())) => Ok(()),
(Ok(()), Err(e)) => Err(PrebuiltError::Extract(format!(
"failed to clean extraction directory {}: {e}",
extract_root.display()
))),
(Err(e), _) => Err(e),
}
}
fn extract_tar_gz_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
use std::process::{Command, Stdio};
let list = Command::new("tar")
.arg("-tzf")
.arg(archive)
.output()
.map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
if !list.status.success() {
return Err(PrebuiltError::Extract(format!(
"tar list exited {}: {}",
list.status,
String::from_utf8_lossy(&list.stderr).trim()
)));
}
for line in String::from_utf8_lossy(&list.stdout).lines() {
validate_archive_relative_path(Path::new(line))?;
}
let out = Command::new("tar")
.arg("--no-same-owner")
.arg("--no-same-permissions")
.arg("-xzf")
.arg(archive)
.arg("-C")
.arg(root)
.stdin(Stdio::null())
.output()
.map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
if !out.status.success() {
return Err(PrebuiltError::Extract(format!(
"tar exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
)));
}
validate_extracted_tree(root)
}
fn extract_zip_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
use std::process::{Command, Stdio};
let list = Command::new("unzip")
.arg("-Z1")
.arg(archive)
.output()
.map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
if !list.status.success() {
return Err(PrebuiltError::Extract(format!(
"unzip list exited {}: {}",
list.status,
String::from_utf8_lossy(&list.stderr).trim()
)));
}
for line in String::from_utf8_lossy(&list.stdout).lines() {
validate_archive_relative_path(Path::new(line))?;
}
let out = Command::new("unzip")
.arg("-q")
.arg(archive)
.arg("-d")
.arg(root)
.stdin(Stdio::null())
.output()
.map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
if !out.status.success() {
return Err(PrebuiltError::Extract(format!(
"unzip exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
)));
}
validate_extracted_tree(root)
}
fn validate_extracted_tree(root: &Path) -> Result<(), PrebuiltError> {
let canonical_root = root.canonicalize()?;
fn walk(path: &Path, root: &Path) -> Result<(), PrebuiltError> {
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let ty = entry.file_type()?;
let p = entry.path();
if ty.is_symlink() {
let target = std::fs::canonicalize(&p).map_err(|e| {
PrebuiltError::Extract(format!("symlink '{}' cannot be resolved: {e}", p.display()))
})?;
if !target.starts_with(root) {
return Err(PrebuiltError::Extract(format!(
"symlink '{}' escapes extraction directory",
p.display()
)));
}
} else if ty.is_dir() {
let c = p.canonicalize()?;
if !c.starts_with(root) {
return Err(PrebuiltError::Extract(format!(
"directory '{}' escapes extraction directory",
p.display()
)));
}
walk(&p, root)?;
} else if ty.is_file() {
let c = p.canonicalize()?;
if !c.starts_with(root) {
return Err(PrebuiltError::Extract(format!(
"file '{}' escapes extraction directory",
p.display()
)));
}
} else {
return Err(PrebuiltError::Extract(format!(
"unsupported archive entry type '{}'",
p.display()
)));
}
}
Ok(())
}
walk(&canonical_root, &canonical_root)
}
pub fn resolve_setup_script(
manifest: &PluginManifest,
plugin_dir: &Path,
) -> Result<Option<PathBuf>, SetupError> {
if let Some(ext) = manifest.extension.as_ref() {
if let Some(setup) = ext.setup.as_deref() {
return validate_setup_path(setup, plugin_dir).map(Some);
}
}
if let Some(provides) = manifest.provides.as_ref() {
if let Some(sidecar) = provides.sidecar.as_ref() {
if let Some(setup) = sidecar.setup.as_deref() {
return validate_setup_path(setup, plugin_dir).map(Some);
}
}
}
Ok(None)
}
fn validate_setup_path(setup: &str, plugin_dir: &Path) -> Result<PathBuf, SetupError> {
let setup_path = Path::new(setup);
if setup_path.is_absolute()
|| setup_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(SetupError::EscapesPluginDir {
path: setup.to_string(),
});
}
let joined = plugin_dir.join(setup_path);
let canonical = match joined.canonicalize() {
Ok(p) => p,
Err(_) => {
return Err(SetupError::NotFound {
path: setup.to_string(),
});
}
};
let canonical_dir = plugin_dir
.canonicalize()
.unwrap_or_else(|_| plugin_dir.to_path_buf());
if !canonical.starts_with(&canonical_dir) {
return Err(SetupError::EscapesPluginDir {
path: setup.to_string(),
});
}
Ok(canonical)
}
pub fn install_log_path(logs_root: &Path, plugin_name: &str, now_rfc3339: &str) -> PathBuf {
let safe_ts = safe_name_fragment(&now_rfc3339.replace(':', "-"));
let safe_plugin = safe_name_fragment(plugin_name);
logs_root
.join("install")
.join(format!("{safe_plugin}-{safe_ts}.log"))
}
pub async fn run_setup_script(
script: &Path,
plugin_dir: &Path,
log_path: &Path,
timeout: Duration,
) -> Result<SetupOutcome, SetupError> {
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut log_file = tokio::fs::File::create(log_path).await?;
let header = format!(
"$ bash {} (cwd: {})\n",
script.display(),
plugin_dir.display()
);
log_file.write_all(header.as_bytes()).await?;
if cfg!(windows) {
return Err(SetupError::Io(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"setup scripts require bash and are not supported on Windows in this release",
)));
}
let mut cmd = Command::new("bash");
cmd.arg(script)
.current_dir(plugin_dir)
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.env("HOME", std::env::var_os("HOME").unwrap_or_default())
.env("USER", std::env::var_os("USER").unwrap_or_default())
.env("SHELL", std::env::var_os("SHELL").unwrap_or_default())
.env("SYNAPS_PLUGIN_DIR", plugin_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn()?;
let mut stdout = child.stdout.take().expect("piped stdout");
let mut stderr = child.stderr.take().expect("piped stderr");
let copy_out = async {
tokio::io::copy(&mut stdout, &mut log_file).await?;
log_file.flush().await?;
Ok::<_, std::io::Error>(log_file)
};
let collect_err = async {
let mut buf = Vec::new();
tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut buf).await?;
Ok::<_, std::io::Error>(buf)
};
let wait = async {
let (out_res, err_res, status) = tokio::join!(copy_out, collect_err, child.wait());
let mut log_file = out_res?;
let err_buf = err_res?;
if !err_buf.is_empty() {
log_file.write_all(b"\n--- stderr ---\n").await?;
log_file.write_all(&err_buf).await?;
log_file.flush().await?;
}
Ok::<_, std::io::Error>(status?)
};
let status = match tokio::time::timeout(timeout, wait).await {
Ok(res) => res?,
Err(_) => {
return Err(SetupError::Timeout {
secs: timeout.as_secs(),
log_path: log_path.to_path_buf(),
});
}
};
let exit_code = status.code().unwrap_or(-1);
if status.success() {
Ok(SetupOutcome {
log_path: log_path.to_path_buf(),
exit_code,
})
} else {
Err(SetupError::NonZeroExit {
exit_code,
log_path: log_path.to_path_buf(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extensions::manifest::{ExtensionManifest, ExtensionRuntime};
use crate::skills::manifest::{PluginProvides, SidecarManifest};
use std::fs;
#[test]
fn host_triple_matches_compiled_target_when_supported() {
let known = [
"linux-x86_64", "linux-arm64",
"darwin-x86_64", "darwin-arm64",
"windows-x86_64", "windows-arm64",
];
let got = host_triple();
if cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows"))
&& cfg!(any(target_arch = "x86_64", target_arch = "aarch64"))
{
let s = got.expect("supported host should yield a triple");
assert!(known.contains(&s), "unexpected triple: {}", s);
}
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
#[test]
fn host_triple_is_linux_x86_64_on_this_box() {
assert_eq!(host_triple(), Some("linux-x86_64"));
}
fn manifest_with_setup(setup: Option<&str>) -> PluginManifest {
PluginManifest {
name: "test-plugin".to_string(),
version: None,
description: None,
keybinds: vec![],
compatibility: None,
commands: vec![],
extension: None,
help_entries: vec![],
provides: Some(PluginProvides {
sidecar: Some(SidecarManifest {
command: "bin/sidecar".to_string(),
setup: setup.map(|s| s.to_string()),
protocol_version: 1,
model: None,
lifecycle: None,
}),
}),
settings: None,
}
}
fn manifest_with_extension_setup(setup: Option<&str>) -> PluginManifest {
PluginManifest {
name: "test-plugin".to_string(),
version: None,
description: None,
keybinds: vec![],
compatibility: None,
commands: vec![],
extension: Some(ExtensionManifest {
protocol_version: 1,
runtime: ExtensionRuntime::Process,
command: "bin/ext".to_string(),
setup: setup.map(|s| s.to_string()),
prebuilt: ::std::collections::HashMap::new(),
args: vec![],
permissions: vec![],
hooks: vec![],
config: vec![],
}),
help_entries: vec![],
provides: None,
settings: None,
}
}
fn manifest_with_both_setup(ext_setup: &str, side_setup: &str) -> PluginManifest {
let mut m = manifest_with_extension_setup(Some(ext_setup));
m.provides = Some(PluginProvides {
sidecar: Some(SidecarManifest {
command: "bin/sidecar".to_string(),
setup: Some(side_setup.to_string()),
protocol_version: 1,
model: None,
lifecycle: None,
}),
});
m
}
#[test]
fn resolve_returns_none_when_no_setup_declared() {
let m = manifest_with_setup(None);
let dir = tempfile::tempdir().unwrap();
let res = resolve_setup_script(&m, dir.path()).unwrap();
assert!(res.is_none());
}
#[test]
fn resolve_returns_none_when_no_provides() {
let mut m = manifest_with_setup(None);
m.provides = None;
let dir = tempfile::tempdir().unwrap();
assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
}
#[test]
fn resolve_resolves_relative_path_inside_plugin_dir() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join("scripts");
fs::create_dir(&scripts).unwrap();
fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
let m = manifest_with_setup(Some("scripts/setup.sh"));
let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
assert!(resolved.ends_with("scripts/setup.sh"));
assert!(resolved.is_absolute());
}
#[test]
fn resolve_rejects_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_setup(Some("/etc/passwd"));
let err = resolve_setup_script(&m, dir.path()).unwrap_err();
assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
}
#[test]
fn resolve_rejects_parent_dir_traversal() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_setup(Some("../escape.sh"));
let err = resolve_setup_script(&m, dir.path()).unwrap_err();
assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
}
#[test]
fn resolve_rejects_embedded_parent_dir() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_setup(Some("scripts/../../etc/passwd"));
let err = resolve_setup_script(&m, dir.path()).unwrap_err();
assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
}
#[test]
fn resolve_returns_not_found_when_script_missing() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_setup(Some("scripts/missing.sh"));
let err = resolve_setup_script(&m, dir.path()).unwrap_err();
assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
}
#[test]
fn resolve_rejects_symlink_pointing_outside_plugin_dir() {
let outer = tempfile::tempdir().unwrap();
let dir = tempfile::tempdir().unwrap();
let target = outer.path().join("escape.sh");
fs::write(&target, "#!/bin/bash").unwrap();
let scripts = dir.path().join("scripts");
fs::create_dir(&scripts).unwrap();
let link = scripts.join("setup.sh");
std::os::unix::fs::symlink(&target, &link).unwrap();
let m = manifest_with_setup(Some("scripts/setup.sh"));
let err = resolve_setup_script(&m, dir.path()).unwrap_err();
assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
}
#[test]
fn install_log_path_substitutes_colons() {
let path = install_log_path(
Path::new("/tmp/logs"),
"sample-sidecar",
"2026-05-02T19:30:45-04:00",
);
assert_eq!(
path,
PathBuf::from("/tmp/logs/install/sample-sidecar-2026-05-02T19-30-45-04-00.log")
);
}
#[tokio::test]
async fn run_setup_succeeds_for_simple_script() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join("scripts");
fs::create_dir(&scripts).unwrap();
let script = scripts.join("setup.sh");
fs::write(
&script,
"#!/bin/bash\necho hello-from-setup\necho 'on stderr' >&2\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
let log = dir.path().join("install.log");
let outcome = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
.await
.unwrap();
assert_eq!(outcome.exit_code, 0);
assert_eq!(outcome.log_path, log);
let captured = fs::read_to_string(&log).unwrap();
assert!(captured.contains("hello-from-setup"));
assert!(captured.contains("on stderr"));
}
#[tokio::test]
async fn run_setup_returns_non_zero_exit() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("fail.sh");
fs::write(&script, "#!/bin/bash\necho boom\nexit 7\n").unwrap();
let log = dir.path().join("install.log");
let err = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
.await
.unwrap_err();
match err {
SetupError::NonZeroExit { exit_code, log_path } => {
assert_eq!(exit_code, 7);
assert_eq!(log_path, log);
let captured = fs::read_to_string(&log).unwrap();
assert!(captured.contains("boom"));
}
other => panic!("expected NonZeroExit, got {other:?}"),
}
}
#[tokio::test]
async fn run_setup_times_out() {
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("loop.sh");
fs::write(&script, "#!/bin/bash\nsleep 5\n").unwrap();
let log = dir.path().join("install.log");
let err = run_setup_script(&script, dir.path(), &log, Duration::from_millis(200))
.await
.unwrap_err();
assert!(matches!(err, SetupError::Timeout { .. }), "got {err:?}");
}
#[test]
fn resolve_resolves_extension_setup_path() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join("scripts");
fs::create_dir(&scripts).unwrap();
fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
let m = manifest_with_extension_setup(Some("scripts/setup.sh"));
let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
assert!(resolved.ends_with("scripts/setup.sh"));
assert!(resolved.is_absolute());
}
#[test]
fn resolve_returns_none_when_extension_has_no_setup() {
let m = manifest_with_extension_setup(None);
let dir = tempfile::tempdir().unwrap();
assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
}
#[test]
fn resolve_rejects_extension_setup_with_parent_dir() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_extension_setup(Some("../escape.sh"));
let err = resolve_setup_script(&m, dir.path()).unwrap_err();
assert!(
matches!(err, SetupError::EscapesPluginDir { .. }),
"got {err:?}"
);
}
#[test]
fn resolve_rejects_extension_setup_when_absolute() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_extension_setup(Some("/etc/passwd"));
let err = resolve_setup_script(&m, dir.path()).unwrap_err();
assert!(
matches!(err, SetupError::EscapesPluginDir { .. }),
"got {err:?}"
);
}
#[test]
fn resolve_returns_not_found_for_missing_extension_setup() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_extension_setup(Some("scripts/missing.sh"));
let err = resolve_setup_script(&m, dir.path()).unwrap_err();
assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
}
#[test]
fn resolve_prefers_extension_setup_over_sidecar_setup() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join("scripts");
fs::create_dir(&scripts).unwrap();
fs::write(scripts.join("ext.sh"), "#!/bin/bash\necho ext").unwrap();
fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
let m = manifest_with_both_setup("scripts/ext.sh", "scripts/side.sh");
let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
assert!(
resolved.ends_with("scripts/ext.sh"),
"expected extension setup to win, got {resolved:?}"
);
}
#[test]
fn resolve_falls_back_to_sidecar_when_extension_has_no_setup() {
let dir = tempfile::tempdir().unwrap();
let scripts = dir.path().join("scripts");
fs::create_dir(&scripts).unwrap();
fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
let mut m = manifest_with_extension_setup(None); m.provides = Some(PluginProvides {
sidecar: Some(SidecarManifest {
command: "bin/sidecar".to_string(),
setup: Some("scripts/side.sh".to_string()),
protocol_version: 1,
model: None,
lifecycle: None,
}),
});
let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
assert!(resolved.ends_with("scripts/side.sh"));
}
#[test]
fn resolve_returns_none_when_neither_slot_has_setup() {
let dir = tempfile::tempdir().unwrap();
let mut m = manifest_with_extension_setup(None);
m.provides = Some(PluginProvides {
sidecar: Some(SidecarManifest {
command: "bin/sidecar".to_string(),
setup: None,
protocol_version: 1,
model: None,
lifecycle: None,
}),
});
assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
}
fn manifest_with_extension_command(command: &str) -> PluginManifest {
PluginManifest {
name: "test-plugin".to_string(),
version: None,
description: None,
keybinds: vec![],
compatibility: None,
commands: vec![],
extension: Some(ExtensionManifest {
protocol_version: 1,
runtime: ExtensionRuntime::Process,
command: command.to_string(),
setup: None,
prebuilt: ::std::collections::HashMap::new(),
args: vec![],
permissions: vec![],
hooks: vec![],
config: vec![],
}),
help_entries: vec![],
provides: None,
settings: None,
}
}
#[test]
fn verify_returns_ok_none_when_no_extension() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_setup(None); assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
}
#[test]
fn verify_returns_ok_none_for_bare_command_name() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_extension_command("python3");
assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
}
#[test]
fn verify_succeeds_when_relative_binary_exists_and_is_executable() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin/ext");
fs::create_dir_all(bin.parent().unwrap()).unwrap();
fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
}
let m = manifest_with_extension_command("bin/ext");
let resolved = verify_extension_command(&m, dir.path()).unwrap();
assert!(resolved.is_some(), "should return resolved path");
}
#[test]
fn verify_returns_missing_when_binary_absent() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_extension_command("bin/ext");
let err = verify_extension_command(&m, dir.path()).unwrap_err();
assert!(matches!(err, CommandVerifyError::Missing { .. }), "got: {err:?}");
}
#[cfg(unix)]
#[test]
fn verify_returns_not_executable_when_bit_missing() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin/ext");
fs::create_dir_all(bin.parent().unwrap()).unwrap();
fs::write(&bin, "data").unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&bin, fs::Permissions::from_mode(0o644)).unwrap();
let m = manifest_with_extension_command("bin/ext");
let err = verify_extension_command(&m, dir.path()).unwrap_err();
assert!(matches!(err, CommandVerifyError::NotExecutable { .. }), "got: {err:?}");
}
#[test]
fn verify_returns_not_a_file_when_path_is_directory() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin/ext");
fs::create_dir_all(&bin).unwrap();
let m = manifest_with_extension_command("bin/ext");
let err = verify_extension_command(&m, dir.path()).unwrap_err();
assert!(matches!(err, CommandVerifyError::NotAFile { .. }), "got: {err:?}");
}
#[test]
fn verify_rejects_parent_dir_traversal_in_command() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_extension_command("../escape/bin");
let err = verify_extension_command(&m, dir.path()).unwrap_err();
assert!(matches!(err, CommandVerifyError::EscapesPluginDir { .. }), "got: {err:?}");
}
#[cfg(unix)]
#[test]
fn verify_rejects_symlink_pointing_outside_plugin_dir() {
let outer = tempfile::tempdir().unwrap();
let plugin = tempfile::tempdir().unwrap();
let target = outer.path().join("real-bin");
fs::write(&target, "x").unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
let link = plugin.path().join("bin/ext");
fs::create_dir_all(link.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
let m = manifest_with_extension_command("bin/ext");
let err = verify_extension_command(&m, plugin.path()).unwrap_err();
assert!(
matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
"got: {err:?}"
);
}
#[test]
fn verify_rejects_absolute_extension_command() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_extension_command("/tmp/ext");
let err = verify_extension_command(&m, dir.path()).unwrap_err();
assert!(matches!(err, CommandVerifyError::EscapesPluginDir { .. }), "got: {err:?}");
}
#[test]
fn policy_normalizes_sha256_and_sanitizes_names() {
assert_eq!(normalize_sha256(&"A".repeat(64)).unwrap(), "a".repeat(64));
assert!(normalize_sha256("not-a-sha").is_none());
assert_eq!(safe_name_fragment("../bad\nname"), "bad_name");
assert_eq!(safe_name_fragment("normal-name_1.2"), "normal-name_1.2");
}
fn manifest_with_prebuilt(
command: &str,
triple: &str,
url: &str,
sha256: &str,
) -> PluginManifest {
let mut prebuilt = std::collections::HashMap::new();
prebuilt.insert(
triple.to_string(),
crate::extensions::manifest::PrebuiltAsset {
url: url.to_string(),
sha256: sha256.to_string(),
},
);
PluginManifest {
name: "test-plugin".to_string(),
version: None,
description: None,
keybinds: vec![],
compatibility: None,
commands: vec![],
extension: Some(ExtensionManifest {
protocol_version: 1,
runtime: ExtensionRuntime::Process,
command: command.to_string(),
setup: None,
prebuilt,
args: vec![],
permissions: vec![],
hooks: vec![],
config: vec![],
}),
help_entries: vec![],
provides: None,
settings: None,
}
}
fn mk_tarball(staging: &Path, archive_name: &str, inner_path: &str) -> (PathBuf, String) {
let work = staging.join("staging");
fs::create_dir_all(&work).unwrap();
let payload = work.join(inner_path);
fs::create_dir_all(payload.parent().unwrap()).unwrap();
fs::write(&payload, "#!/bin/sh\necho prebuilt-bin\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&payload, fs::Permissions::from_mode(0o755)).unwrap();
}
let archive = staging.join(archive_name);
let out = std::process::Command::new("tar")
.arg("-czf")
.arg(&archive)
.arg("-C")
.arg(&work)
.arg(inner_path)
.output()
.expect("system tar must be present");
assert!(out.status.success(), "tar failed: {:?}", out);
let bytes = fs::read(&archive).unwrap();
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(&bytes);
let sha = hex_encode_lower(&h.finalize());
(archive, sha)
}
#[tokio::test(flavor = "current_thread")]
async fn prebuilt_returns_no_matching_asset_when_triple_missing() {
let dir = tempfile::tempdir().unwrap();
let m = manifest_with_prebuilt("bin/ext", "fake-triple-9999", "https://x", "00");
let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
assert!(matches!(err, PrebuiltError::NoMatchingAsset), "got: {err:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn prebuilt_rejects_non_https_url_in_production_builds() {
let dir = tempfile::tempdir().unwrap();
let triple = host_triple().expect("supported host");
let m = manifest_with_prebuilt("bin/ext", triple, "http://example.com/x.tar.gz", "00");
let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
assert!(matches!(err, PrebuiltError::UnsafeUrl { .. }), "got: {err:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn prebuilt_succeeds_with_valid_tarball_and_checksum() {
let staging = tempfile::tempdir().unwrap();
let plugin = tempfile::tempdir().unwrap();
let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
let url = format!("file://{}", archive.display());
let triple = host_triple().expect("supported host");
let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
let resolved = try_install_from_prebuilt(&m, plugin.path()).await.unwrap();
assert!(resolved.exists(), "extracted binary should exist at {}", resolved.display());
let leftover = plugin.path().join(format!(".prebuilt-{}-download", triple));
assert!(!leftover.exists(), "temp archive should be removed");
}
#[tokio::test(flavor = "current_thread")]
async fn prebuilt_aborts_on_checksum_mismatch_without_extracting() {
let staging = tempfile::tempdir().unwrap();
let plugin = tempfile::tempdir().unwrap();
let (archive, _real_sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
let url = format!("file://{}", archive.display());
let triple = host_triple().expect("supported host");
let bad_sha = "0".repeat(64);
let m = manifest_with_prebuilt("bin/ext", triple, &url, &bad_sha);
let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
match err {
PrebuiltError::ChecksumMismatch { expected, actual } => {
assert_eq!(expected, bad_sha);
assert_eq!(actual.len(), 64, "actual sha should be lowercase hex");
}
other => panic!("expected ChecksumMismatch, got {other:?}"),
}
assert!(!plugin.path().join("bin/ext").exists());
}
#[tokio::test(flavor = "current_thread")]
async fn prebuilt_rejects_unsupported_archive_suffix() {
let staging = tempfile::tempdir().unwrap();
let plugin = tempfile::tempdir().unwrap();
let archive = staging.path().join("ext.rar");
fs::write(&archive, b"not really a rar").unwrap();
let bytes = fs::read(&archive).unwrap();
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(&bytes);
let sha = hex_encode_lower(&h.finalize());
let url = format!("file://{}", archive.display());
let triple = host_triple().expect("supported host");
let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
assert!(matches!(err, PrebuiltError::UnsupportedArchive { .. }), "got: {err:?}");
}
#[tokio::test(flavor = "current_thread")]
async fn prebuilt_fails_verify_when_archive_does_not_contain_declared_command() {
let staging = tempfile::tempdir().unwrap();
let plugin = tempfile::tempdir().unwrap();
let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/wrong-name");
let url = format!("file://{}", archive.display());
let triple = host_triple().expect("supported host");
let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
assert!(matches!(err, PrebuiltError::Verify(_)), "got: {err:?}");
}
}