Skip to main content

synaps_cli/skills/
post_install.rs

1//! Post-install setup-script execution.
2//!
3//! When a plugin manifest declares a setup script, the marketplace
4//! install flow auto-runs it after the plugin's source is in place.
5//! This is how source-shipped plugins (e.g. ones that ship a Rust
6//! crate and need `cargo build --release` to produce the binary
7//! [`extension.command`] points at) get built without forcing the
8//! user to run `scripts/setup.sh` by hand.
9//!
10//! Two manifest slots are recognised, in priority order:
11//!
12//! 1. `extension.setup` — the extension's own build script. Checked
13//!    first because the extension binary is what the host will spawn
14//!    immediately on session start.
15//! 2. `provides.sidecar.setup` — the sidecar's build script (legacy
16//!    slot; still honoured for sidecar-only plugins).
17//!
18//! At most one script runs per install. Plugins that ship both an
19//! extension and a sidecar from one repo should drive both builds
20//! from a single `scripts/setup.sh` referenced via `extension.setup`.
21//!
22//! ## Security
23//!
24//! Setup scripts are arbitrary shell. We mitigate by:
25//! - Refusing setup paths that escape the plugin dir (`..`, absolute).
26//! - Refusing setup paths that don't resolve (canonicalize) inside the
27//!   plugin dir.
28//! - Requiring the script file exists and is executable.
29//! - Capping wall-clock runtime at [`SETUP_TIMEOUT`].
30//! - Capturing stdout+stderr to a per-install log (no swallowing).
31//!
32//! ## Failure mode
33//!
34//! A failed setup script does **not** roll back the install — the
35//! source is on disk and the user can rerun the script manually. The
36//! caller surfaces the failure to the UI with a pointer to the log
37//! file.
38//!
39//! Pure helpers live here so they can be unit-tested without a real
40//! tokio runtime; the async runner is a thin shell over
41//! `tokio::process::Command`.
42
43use std::path::{Component, Path, PathBuf};
44use std::time::Duration;
45
46use crate::skills::manifest::PluginManifest;
47
48/// Stable host-triple string used as the lookup key in
49/// [`crate::extensions::manifest::ExtensionManifest::prebuilt`]. We
50/// intentionally use a compact `<os>-<arch>` form (e.g.
51/// `linux-x86_64`, `darwin-arm64`, `windows-x86_64`) rather than full
52/// Rust target triples (`x86_64-unknown-linux-gnu`) because plugin
53/// authors hand-write these strings into JSON manifests — readability
54/// > pedantry.
55///
56/// Returns `None` for hosts we don't have a stable name for (caller
57/// then skips the prebuilt-fallback path and falls back to the setup
58/// script).
59
60/// Maximum accepted prebuilt archive size (128 MiB). Downloads are streamed and
61/// rejected before extraction if this cap is exceeded.
62pub const MAX_PREBUILT_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
63
64const PREBUILT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
65const PREBUILT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
66
67/// Sanitize a manifest-controlled string before using it as a filename fragment
68/// or displaying it in terse user-facing hints. Unsafe/control characters become
69/// `_`; empty/all-unsafe input becomes `plugin`.
70pub fn safe_name_fragment(input: &str) -> String {
71    let mut out = String::with_capacity(input.len().min(80));
72    for ch in input.chars().take(80) {
73        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
74            out.push(ch);
75        } else {
76            out.push('_');
77        }
78    }
79    let trimmed = out.trim_matches('.').trim_matches('_').to_string();
80    if trimmed.is_empty() || trimmed == ".." {
81        "plugin".to_string()
82    } else {
83        trimmed
84    }
85}
86
87/// Normalize SHA-256 manifest values to lower-case hex. Rejects anything other
88/// than exactly 64 ASCII hex characters.
89pub fn normalize_sha256(value: &str) -> Option<String> {
90    let trimmed = value.trim();
91    if trimmed.len() == 64 && trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
92        Some(trimmed.to_ascii_lowercase())
93    } else {
94        None
95    }
96}
97
98fn prebuilt_url_allowed(url: &str) -> bool {
99    if url.starts_with("https://") {
100        return true;
101    }
102    #[cfg(test)]
103    {
104        if url.starts_with("file://") {
105            return true;
106        }
107    }
108    false
109}
110
111fn archive_suffix(url_for_suffix: &str) -> Result<&'static str, PrebuiltError> {
112    let url_clean = url_for_suffix
113        .split(['?', '#'])
114        .next()
115        .unwrap_or(url_for_suffix)
116        .to_ascii_lowercase();
117    if url_clean.ends_with(".tar.gz") || url_clean.ends_with(".tgz") {
118        Ok("tar.gz")
119    } else if url_clean.ends_with(".zip") {
120        Ok("zip")
121    } else if url_clean.ends_with(".tar.xz") || url_clean.ends_with(".tar.bz2") {
122        Err(PrebuiltError::UnsupportedArchive {
123            url: format!("{url_for_suffix} (xz/bz2 prebuilt archives are not supported by the hardened extractor; use .tar.gz or .zip)"),
124        })
125    } else {
126        Err(PrebuiltError::UnsupportedArchive {
127            url: url_for_suffix.to_string(),
128        })
129    }
130}
131
132fn validate_archive_relative_path(path: &Path) -> Result<(), PrebuiltError> {
133    if path.is_absolute() {
134        return Err(PrebuiltError::Extract(format!(
135            "archive entry '{}' is absolute",
136            path.display()
137        )));
138    }
139    if path.components().any(|c| matches!(c, Component::ParentDir | Component::Prefix(_))) {
140        return Err(PrebuiltError::Extract(format!(
141            "archive entry '{}' escapes extraction directory",
142            path.display()
143        )));
144    }
145    Ok(())
146}
147
148fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
149    for entry in std::fs::read_dir(src)? {
150        let entry = entry?;
151        let from = entry.path();
152        let to = dest.join(entry.file_name());
153        let meta = entry.file_type()?;
154        if meta.is_dir() {
155            std::fs::create_dir_all(&to)?;
156            copy_dir_contents(&from, &to)?;
157        } else if meta.is_file() {
158            if let Some(parent) = to.parent() {
159                std::fs::create_dir_all(parent)?;
160            }
161            std::fs::copy(&from, &to)?;
162            #[cfg(unix)]
163            {
164                use std::os::unix::fs::PermissionsExt;
165                let mode = std::fs::metadata(&from)?.permissions().mode();
166                std::fs::set_permissions(&to, std::fs::Permissions::from_mode(mode))?;
167            }
168        }
169    }
170    Ok(())
171}
172
173pub fn host_triple() -> Option<&'static str> {
174    let os = if cfg!(target_os = "linux") {
175        "linux"
176    } else if cfg!(target_os = "macos") {
177        "darwin"
178    } else if cfg!(target_os = "windows") {
179        "windows"
180    } else {
181        return None;
182    };
183    let arch = if cfg!(target_arch = "x86_64") {
184        "x86_64"
185    } else if cfg!(target_arch = "aarch64") {
186        "arm64"
187    } else {
188        return None;
189    };
190    // Use a small static table so the returned slice is `'static`.
191    Some(match (os, arch) {
192        ("linux", "x86_64") => "linux-x86_64",
193        ("linux", "arm64") => "linux-arm64",
194        ("darwin", "x86_64") => "darwin-x86_64",
195        ("darwin", "arm64") => "darwin-arm64",
196        ("windows", "x86_64") => "windows-x86_64",
197        ("windows", "arm64") => "windows-arm64",
198        _ => return None,
199    })
200}
201
202/// Wall-clock cap on a single setup script. Sample from-scratch
203/// builds run ~5 minutes on a modern dev box; 10 minutes leaves a
204/// healthy margin for slower CI/older hardware without making a
205/// runaway script wedge the install flow forever.
206pub const SETUP_TIMEOUT: Duration = Duration::from_secs(600);
207
208/// Outcome of a successful setup-script run.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct SetupOutcome {
211    /// Path to the log file containing combined stdout+stderr.
212    pub log_path: PathBuf,
213    /// Process exit status (always 0 on the success path).
214    pub exit_code: i32,
215}
216
217/// Why a setup script could not be run, or why it failed once started.
218#[derive(Debug, thiserror::Error)]
219pub enum SetupError {
220    /// Manifest declared a setup path but it points outside the plugin
221    /// directory or contains a `..` component.
222    #[error("setup script path '{path}' escapes plugin directory")]
223    EscapesPluginDir { path: String },
224
225    /// Manifest declared a setup path that doesn't exist on disk after
226    /// canonicalization.
227    #[error("setup script '{path}' not found in plugin directory")]
228    NotFound { path: String },
229
230    /// Setup ran but exited non-zero. `log_path` points at captured
231    /// stdout+stderr; UI should surface it to the user.
232    #[error("setup script exited with code {exit_code}; see {}", log_path.display())]
233    NonZeroExit { exit_code: i32, log_path: PathBuf },
234
235    /// Setup exceeded [`SETUP_TIMEOUT`] and was killed.
236    #[error("setup script timed out after {secs}s; see {}", log_path.display())]
237    Timeout { secs: u64, log_path: PathBuf },
238
239    /// I/O error setting up the log file or spawning the process.
240    #[error("setup script io: {0}")]
241    Io(#[from] std::io::Error),
242}
243
244/// Why an extension command verification failed. Distinct from
245/// [`SetupError`] because the failure mode and remediation are
246/// different — here, the build "succeeded" but the artifact the
247/// manifest promised isn't there.
248#[derive(Debug, thiserror::Error, PartialEq, Eq)]
249pub enum CommandVerifyError {
250    /// `extension.command` resolves to a relative path that escapes the
251    /// plugin directory (`..` traversal or symlink that points outside).
252    #[error("extension command path '{path}' escapes plugin directory")]
253    EscapesPluginDir { path: String },
254
255    /// The resolved path doesn't exist on disk. Most common cause:
256    /// setup script ran, exited 0, but didn't actually produce the
257    /// declared binary.
258    #[error("extension command '{path}' does not exist (resolved to {})", resolved.display())]
259    Missing { path: String, resolved: PathBuf },
260
261    /// The path exists but isn't executable (Unix only — Windows skips
262    /// this check). Common cause: build artifact missing the +x bit
263    /// after extraction from a source archive.
264    #[cfg(unix)]
265    #[error("extension command '{path}' exists but is not executable (mode {mode:o})")]
266    NotExecutable { path: String, mode: u32 },
267
268    /// The path resolves to a directory, not a file.
269    #[error("extension command '{path}' is a directory, not a file")]
270    NotAFile { path: String },
271}
272
273/// Verify that the extension binary declared by
274/// [`crate::extensions::manifest::ExtensionManifest::command`] actually
275/// exists and is executable inside `plugin_dir`. Used as the
276/// post-condition check after [`run_setup_script`] succeeds, so a
277/// build script that exits 0 but doesn't produce the promised binary
278/// surfaces a clear error instead of silently breaking spawn at
279/// runtime.
280///
281/// Mirrors the host-side resolution rules in
282/// [`crate::extensions::manager`] except absolute plugin extension commands are
283/// rejected: shipped extension binaries must be plugin-relative.
284/// - `command` is **absolute**: reject
285/// - `command` is **bare** (no path separator): skip — it's a PATH
286///   lookup, not a plugin-shipped artifact
287/// - `command` is **relative with separators**: join with `plugin_dir`,
288///   canonicalize, ensure it stays inside `plugin_dir`, then verify
289///
290/// Returns `Ok(None)` when the manifest declares no extension or the
291/// command is a bare PATH lookup (nothing to verify).
292/// Returns `Ok(Some(resolved_path))` on successful verification.
293pub fn verify_extension_command(
294    manifest: &PluginManifest,
295    plugin_dir: &Path,
296) -> Result<Option<PathBuf>, CommandVerifyError> {
297    let Some(ext) = manifest.extension.as_ref() else {
298        return Ok(None);
299    };
300    let cmd = &ext.command;
301    let cmd_path = Path::new(cmd);
302
303    // Bare command name (e.g. "python3") — defer to PATH at spawn time.
304    if !cmd.contains(std::path::MAIN_SEPARATOR) && !cmd.contains('/') {
305        return Ok(None);
306    }
307
308    let resolved = if cmd_path.is_absolute() {
309        return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
310    } else {
311        // Reject `..` traversal up front (don't rely on canonicalize).
312        if cmd_path
313            .components()
314            .any(|c| matches!(c, std::path::Component::ParentDir))
315        {
316            return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
317        }
318        let joined = plugin_dir.join(cmd_path);
319        match joined.canonicalize() {
320            Ok(p) => {
321                let canonical_dir = plugin_dir
322                    .canonicalize()
323                    .unwrap_or_else(|_| plugin_dir.to_path_buf());
324                if !p.starts_with(&canonical_dir) {
325                    return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
326                }
327                p
328            }
329            Err(_) => {
330                return Err(CommandVerifyError::Missing {
331                    path: cmd.clone(),
332                    resolved: joined,
333                });
334            }
335        }
336    };
337
338    if !resolved.exists() {
339        return Err(CommandVerifyError::Missing {
340            path: cmd.clone(),
341            resolved,
342        });
343    }
344    let meta = std::fs::metadata(&resolved).map_err(|_| CommandVerifyError::Missing {
345        path: cmd.clone(),
346        resolved: resolved.clone(),
347    })?;
348    if meta.is_dir() {
349        return Err(CommandVerifyError::NotAFile { path: cmd.clone() });
350    }
351    #[cfg(unix)]
352    {
353        use std::os::unix::fs::PermissionsExt;
354        let mode = meta.permissions().mode();
355        // Any execute bit is enough — owner/group/other.
356        if mode & 0o111 == 0 {
357            return Err(CommandVerifyError::NotExecutable {
358                path: cmd.clone(),
359                mode,
360            });
361        }
362    }
363    Ok(Some(resolved))
364}
365
366/// Why a prebuilt-binary install attempt failed. Variants distinguish
367/// "no asset for this host" (caller falls back to the setup script)
368/// from "asset matched but couldn't be installed" (caller surfaces
369/// the error — security failures and network issues should not
370/// silently trigger a build).
371#[derive(Debug, thiserror::Error)]
372pub enum PrebuiltError {
373    /// Host triple has no entry in `extension.prebuilt`. Caller should
374    /// fall back to the setup script. Not really an error — just a
375    /// signal that there's nothing to try.
376    #[error("no prebuilt asset declared for this host")]
377    NoMatchingAsset,
378
379    /// Network / HTTP problem fetching the URL.
380    #[error("download failed: {0}")]
381    Download(String),
382
383    /// Downloaded bytes don't match the declared SHA-256. Treated as a
384    /// hard failure (don't fall back to setup) since this could
385    /// indicate tampering, mirror corruption, or a stale manifest.
386    #[error("checksum mismatch: expected {expected}, got {actual}")]
387    ChecksumMismatch { expected: String, actual: String },
388
389    /// `tar` / `unzip` exited non-zero or wasn't on PATH.
390    #[error("archive extraction failed: {0}")]
391    Extract(String),
392
393    /// The archive doesn't end in a recognized suffix (we support
394    /// `.tar.gz` / `.tgz` / `.tar.xz` / `.tar.bz2` / `.zip`).
395    #[error("unsupported archive type for url '{url}'")]
396    UnsupportedArchive { url: String },
397
398    /// Asset URL must be `https://` (or `file://` in tests).
399    #[error("refusing non-https prebuilt url '{url}'")]
400    UnsafeUrl { url: String },
401
402    /// Manifest checksum is not exactly 64 hex characters.
403    #[error("invalid sha256 '{sha256}'; expected exactly 64 hex characters")]
404    InvalidSha256 { sha256: String },
405
406    /// Prebuilt response or stream exceeded the configured size cap.
407    #[error("prebuilt archive exceeds maximum size of {max} bytes")]
408    TooLarge { max: u64 },
409
410    /// I/O setting up the temp file or moving extracted artifacts.
411    #[error("io: {0}")]
412    Io(#[from] std::io::Error),
413
414    /// Asset extracted but the manifest's `extension.command` still
415    /// doesn't resolve. The archive layout is wrong.
416    #[error("prebuilt extracted but extension command not found: {0}")]
417    Verify(#[from] CommandVerifyError),
418}
419
420/// Lower-case hex encode of arbitrary bytes. Inlined to avoid pulling
421/// in a `hex` crate just for this one site.
422fn hex_encode_lower(bytes: &[u8]) -> String {
423    let mut out = String::with_capacity(bytes.len() * 2);
424    for b in bytes {
425        out.push(char::from_digit((*b >> 4) as u32, 16).unwrap());
426        out.push(char::from_digit((*b & 0x0f) as u32, 16).unwrap());
427    }
428    out
429}
430
431/// Try to install the extension binary from
432/// [`crate::extensions::manifest::ExtensionManifest::prebuilt`] for
433/// the current host. Lookup is by [`host_triple`].
434///
435/// On success: downloads the URL, verifies the SHA-256, extracts
436/// the archive into `plugin_dir`, then runs
437/// [`verify_extension_command`] to confirm the layout was correct.
438/// Returns `Ok(Some(path))` pointing at the resolved binary.
439///
440/// On `Err(PrebuiltError::NoMatchingAsset)`: no entry for this host
441/// — caller should fall back to the setup script.
442///
443/// On any other `Err`: surface to the user; do **not** silently fall
444/// back to the setup script (a checksum failure could mean tampering;
445/// a network failure means the user wanted prebuilt and should know).
446pub async fn try_install_from_prebuilt(
447    manifest: &PluginManifest,
448    plugin_dir: &Path,
449) -> Result<PathBuf, PrebuiltError> {
450    let Some(ext) = manifest.extension.as_ref() else {
451        return Err(PrebuiltError::NoMatchingAsset);
452    };
453    let Some(triple) = host_triple() else {
454        return Err(PrebuiltError::NoMatchingAsset);
455    };
456    let Some(asset) = ext.prebuilt.get(triple) else {
457        return Err(PrebuiltError::NoMatchingAsset);
458    };
459
460    if !prebuilt_url_allowed(&asset.url) {
461        return Err(PrebuiltError::UnsafeUrl {
462            url: asset.url.clone(),
463        });
464    }
465    let expected_sha = normalize_sha256(&asset.sha256).ok_or_else(|| PrebuiltError::InvalidSha256 {
466        sha256: asset.sha256.clone(),
467    })?;
468
469    let tmp_archive = plugin_dir.join(format!(".prebuilt-{triple}-download"));
470    match std::fs::remove_file(&tmp_archive) {
471        Ok(()) => {}
472        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
473        Err(e) => return Err(PrebuiltError::Io(e)),
474    }
475
476    let download_res = download_prebuilt_to_file(&asset.url, &tmp_archive, MAX_PREBUILT_ARCHIVE_BYTES).await;
477    let actual = match download_res {
478        Ok(sha) => sha,
479        Err(e) => {
480            let _ = std::fs::remove_file(&tmp_archive);
481            return Err(e);
482        }
483    };
484    if actual != expected_sha {
485        let _ = std::fs::remove_file(&tmp_archive);
486        return Err(PrebuiltError::ChecksumMismatch {
487            expected: expected_sha,
488            actual,
489        });
490    }
491
492    let archive = tmp_archive.clone();
493    let dest = plugin_dir.to_path_buf();
494    let url = asset.url.clone();
495    let extract_res = tokio::task::spawn_blocking(move || extract_archive(&archive, &dest, &url))
496        .await
497        .map_err(|e| PrebuiltError::Extract(format!("extract task join error: {e}")))?;
498    let _ = std::fs::remove_file(&tmp_archive);
499    extract_res?;
500
501    // Post-condition: the binary the manifest promised must now resolve.
502    let resolved = verify_extension_command(manifest, plugin_dir)?
503        .ok_or_else(|| {
504            PrebuiltError::Verify(CommandVerifyError::Missing {
505                path: ext.command.clone(),
506                resolved: plugin_dir.join(&ext.command),
507            })
508        })?;
509    Ok(resolved)
510}
511
512async fn download_prebuilt_to_file(
513    url: &str,
514    tmp_archive: &Path,
515    max_bytes: u64,
516) -> Result<String, PrebuiltError> {
517    use sha2::{Digest, Sha256};
518
519    let mut hasher = Sha256::new();
520    let mut written: u64 = 0;
521
522    if let Some(path) = url.strip_prefix("file://") {
523        #[cfg(not(test))]
524        {
525            let _ = path;
526            return Err(PrebuiltError::UnsafeUrl { url: url.to_string() });
527        }
528        #[cfg(test)]
529        {
530            let mut input = std::fs::File::open(path)
531                .map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
532            let mut output = std::fs::File::create(tmp_archive)?;
533            let mut buf = [0u8; 8192];
534            loop {
535                let n = std::io::Read::read(&mut input, &mut buf)
536                    .map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
537                if n == 0 {
538                    break;
539                }
540                written += n as u64;
541                if written > max_bytes {
542                    return Err(PrebuiltError::TooLarge { max: max_bytes });
543                }
544                hasher.update(&buf[..n]);
545                std::io::Write::write_all(&mut output, &buf[..n])?;
546            }
547            return Ok(hex_encode_lower(&hasher.finalize()));
548        }
549    }
550
551    let client = reqwest::Client::builder()
552        .connect_timeout(PREBUILT_CONNECT_TIMEOUT)
553        .timeout(PREBUILT_REQUEST_TIMEOUT)
554        .build()
555        .map_err(|e| PrebuiltError::Download(e.to_string()))?;
556    let mut response = client
557        .get(url)
558        .send()
559        .await
560        .map_err(|e| PrebuiltError::Download(e.to_string()))?;
561    if !response.status().is_success() {
562        return Err(PrebuiltError::Download(format!("HTTP {}", response.status())));
563    }
564    if let Some(len) = response.content_length() {
565        if len > max_bytes {
566            return Err(PrebuiltError::TooLarge { max: max_bytes });
567        }
568    }
569
570    let mut output = tokio::fs::File::create(tmp_archive).await?;
571    while let Some(chunk) = response
572        .chunk()
573        .await
574        .map_err(|e| PrebuiltError::Download(e.to_string()))?
575    {
576        written += chunk.len() as u64;
577        if written > max_bytes {
578            return Err(PrebuiltError::TooLarge { max: max_bytes });
579        }
580        hasher.update(&chunk);
581        tokio::io::AsyncWriteExt::write_all(&mut output, &chunk).await?;
582    }
583    tokio::io::AsyncWriteExt::flush(&mut output).await?;
584    Ok(hex_encode_lower(&hasher.finalize()))
585}
586
587/// Extract a prebuilt archive into a sandbox temp directory, validate entry
588/// paths, then copy validated contents into `dest_dir`. The hardened extractor
589/// supports `.tar.gz`/`.tgz` and `.zip`; xz/bz2 are rejected until a native
590/// decoder is added.
591fn extract_archive(
592    archive: &Path,
593    dest_dir: &Path,
594    url_for_suffix: &str,
595) -> Result<(), PrebuiltError> {
596    let kind = archive_suffix(url_for_suffix)?;
597    let extract_root = dest_dir.join(format!(
598        ".prebuilt-extract-{}",
599        chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
600    ));
601    match std::fs::remove_dir_all(&extract_root) {
602        Ok(()) => {}
603        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
604        Err(e) => return Err(PrebuiltError::Io(e)),
605    }
606    std::fs::create_dir_all(&extract_root)?;
607    let result = match kind {
608        "tar.gz" => extract_tar_gz_safe(archive, &extract_root),
609        "zip" => extract_zip_safe(archive, &extract_root),
610        _ => unreachable!(),
611    }
612    .and_then(|_| copy_dir_contents(&extract_root, dest_dir).map_err(PrebuiltError::Io));
613    let cleanup = std::fs::remove_dir_all(&extract_root);
614    match (result, cleanup) {
615        (Ok(()), Ok(())) => Ok(()),
616        (Ok(()), Err(e)) => Err(PrebuiltError::Extract(format!(
617            "failed to clean extraction directory {}: {e}",
618            extract_root.display()
619        ))),
620        (Err(e), _) => Err(e),
621    }
622}
623
624fn extract_tar_gz_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
625    use std::process::{Command, Stdio};
626
627    let list = Command::new("tar")
628        .arg("-tzf")
629        .arg(archive)
630        .output()
631        .map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
632    if !list.status.success() {
633        return Err(PrebuiltError::Extract(format!(
634            "tar list exited {}: {}",
635            list.status,
636            String::from_utf8_lossy(&list.stderr).trim()
637        )));
638    }
639    for line in String::from_utf8_lossy(&list.stdout).lines() {
640        validate_archive_relative_path(Path::new(line))?;
641    }
642    let out = Command::new("tar")
643        .arg("--no-same-owner")
644        .arg("--no-same-permissions")
645        .arg("-xzf")
646        .arg(archive)
647        .arg("-C")
648        .arg(root)
649        .stdin(Stdio::null())
650        .output()
651        .map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
652    if !out.status.success() {
653        return Err(PrebuiltError::Extract(format!(
654            "tar exited {}: {}",
655            out.status,
656            String::from_utf8_lossy(&out.stderr).trim()
657        )));
658    }
659    validate_extracted_tree(root)
660}
661
662fn extract_zip_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
663    use std::process::{Command, Stdio};
664
665    let list = Command::new("unzip")
666        .arg("-Z1")
667        .arg(archive)
668        .output()
669        .map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
670    if !list.status.success() {
671        return Err(PrebuiltError::Extract(format!(
672            "unzip list exited {}: {}",
673            list.status,
674            String::from_utf8_lossy(&list.stderr).trim()
675        )));
676    }
677    for line in String::from_utf8_lossy(&list.stdout).lines() {
678        validate_archive_relative_path(Path::new(line))?;
679    }
680    let out = Command::new("unzip")
681        .arg("-q")
682        .arg(archive)
683        .arg("-d")
684        .arg(root)
685        .stdin(Stdio::null())
686        .output()
687        .map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
688    if !out.status.success() {
689        return Err(PrebuiltError::Extract(format!(
690            "unzip exited {}: {}",
691            out.status,
692            String::from_utf8_lossy(&out.stderr).trim()
693        )));
694    }
695    validate_extracted_tree(root)
696}
697
698fn validate_extracted_tree(root: &Path) -> Result<(), PrebuiltError> {
699    let canonical_root = root.canonicalize()?;
700    fn walk(path: &Path, root: &Path) -> Result<(), PrebuiltError> {
701        for entry in std::fs::read_dir(path)? {
702            let entry = entry?;
703            let ty = entry.file_type()?;
704            let p = entry.path();
705            if ty.is_symlink() {
706                let target = std::fs::canonicalize(&p).map_err(|e| {
707                    PrebuiltError::Extract(format!("symlink '{}' cannot be resolved: {e}", p.display()))
708                })?;
709                if !target.starts_with(root) {
710                    return Err(PrebuiltError::Extract(format!(
711                        "symlink '{}' escapes extraction directory",
712                        p.display()
713                    )));
714                }
715            } else if ty.is_dir() {
716                let c = p.canonicalize()?;
717                if !c.starts_with(root) {
718                    return Err(PrebuiltError::Extract(format!(
719                        "directory '{}' escapes extraction directory",
720                        p.display()
721                    )));
722                }
723                walk(&p, root)?;
724            } else if ty.is_file() {
725                let c = p.canonicalize()?;
726                if !c.starts_with(root) {
727                    return Err(PrebuiltError::Extract(format!(
728                        "file '{}' escapes extraction directory",
729                        p.display()
730                    )));
731                }
732            } else {
733                return Err(PrebuiltError::Extract(format!(
734                    "unsupported archive entry type '{}'",
735                    p.display()
736                )));
737            }
738        }
739        Ok(())
740    }
741    walk(&canonical_root, &canonical_root)
742}
743
744/// Resolve the manifest-declared setup script to an absolute path
745/// inside `plugin_dir`, or return `Ok(None)` if no setup is declared.
746///
747/// Returns `Err(EscapesPluginDir)` if the declared path is absolute
748/// or contains `..`, or if the canonicalized path lives outside
749/// `plugin_dir`. Returns `Err(NotFound)` if the resolved path doesn't
750/// exist on disk.
751///
752/// This is the security gate — the async runner trusts the path it
753/// gets from this function.
754pub fn resolve_setup_script(
755    manifest: &PluginManifest,
756    plugin_dir: &Path,
757) -> Result<Option<PathBuf>, SetupError> {
758    // 1. Extension setup wins. The extension binary is what the host
759    //    spawns immediately on session start, so its build script gets
760    //    priority over the sidecar's.
761    if let Some(ext) = manifest.extension.as_ref() {
762        if let Some(setup) = ext.setup.as_deref() {
763            return validate_setup_path(setup, plugin_dir).map(Some);
764        }
765    }
766    // 2. Fall back to the sidecar's setup (legacy slot).
767    if let Some(provides) = manifest.provides.as_ref() {
768        if let Some(sidecar) = provides.sidecar.as_ref() {
769            if let Some(setup) = sidecar.setup.as_deref() {
770                return validate_setup_path(setup, plugin_dir).map(Some);
771            }
772        }
773    }
774    Ok(None)
775}
776
777/// Security-validate a setup-script path declared in the manifest and
778/// resolve it to an absolute path inside `plugin_dir`.
779///
780/// Shared by both the `extension.setup` and `provides.sidecar.setup`
781/// resolution paths so the rules stay identical.
782fn validate_setup_path(setup: &str, plugin_dir: &Path) -> Result<PathBuf, SetupError> {
783    let setup_path = Path::new(setup);
784    if setup_path.is_absolute()
785        || setup_path
786            .components()
787            .any(|c| matches!(c, std::path::Component::ParentDir))
788    {
789        return Err(SetupError::EscapesPluginDir {
790            path: setup.to_string(),
791        });
792    }
793    let joined = plugin_dir.join(setup_path);
794    let canonical = match joined.canonicalize() {
795        Ok(p) => p,
796        Err(_) => {
797            return Err(SetupError::NotFound {
798                path: setup.to_string(),
799            });
800        }
801    };
802    let canonical_dir = plugin_dir
803        .canonicalize()
804        .unwrap_or_else(|_| plugin_dir.to_path_buf());
805    if !canonical.starts_with(&canonical_dir) {
806        return Err(SetupError::EscapesPluginDir {
807            path: setup.to_string(),
808        });
809    }
810    Ok(canonical)
811}
812
813/// Build the per-install log path. Caller is expected to create the
814/// parent directory before opening it. Format:
815///
816/// `{logs_root}/install/{plugin}-{rfc3339}.log`
817///
818/// where rfc3339 has colons replaced with `-` so the filename is safe
819/// on Windows (and grep-friendly).
820pub fn install_log_path(logs_root: &Path, plugin_name: &str, now_rfc3339: &str) -> PathBuf {
821    let safe_ts = safe_name_fragment(&now_rfc3339.replace(':', "-"));
822    let safe_plugin = safe_name_fragment(plugin_name);
823    logs_root
824        .join("install")
825        .join(format!("{safe_plugin}-{safe_ts}.log"))
826}
827
828/// Run the resolved setup script against `plugin_dir`, streaming
829/// combined stdout+stderr to `log_path`. Returns on success, exit
830/// code, timeout, or I/O error.
831///
832/// The script is invoked as `bash <script>` (POSIX shells only — no
833/// Windows .bat/.ps1 support in v1; plugins on Windows can ship a
834/// shim or rely on the native binary already being committed).
835///
836/// `cwd` is set to `plugin_dir` so scripts can use relative paths
837/// like `target/release/...`.
838pub async fn run_setup_script(
839    script: &Path,
840    plugin_dir: &Path,
841    log_path: &Path,
842    timeout: Duration,
843) -> Result<SetupOutcome, SetupError> {
844    use std::process::Stdio;
845    use tokio::io::AsyncWriteExt;
846    use tokio::process::Command;
847
848    if let Some(parent) = log_path.parent() {
849        std::fs::create_dir_all(parent)?;
850    }
851    let mut log_file = tokio::fs::File::create(log_path).await?;
852    let header = format!(
853        "$ bash {} (cwd: {})\n",
854        script.display(),
855        plugin_dir.display()
856    );
857    log_file.write_all(header.as_bytes()).await?;
858
859    if cfg!(windows) {
860        return Err(SetupError::Io(std::io::Error::new(
861            std::io::ErrorKind::Unsupported,
862            "setup scripts require bash and are not supported on Windows in this release",
863        )));
864    }
865
866    let mut cmd = Command::new("bash");
867    cmd.arg(script)
868        .current_dir(plugin_dir)
869        .env_clear()
870        .env("PATH", std::env::var_os("PATH").unwrap_or_default())
871        .env("HOME", std::env::var_os("HOME").unwrap_or_default())
872        .env("USER", std::env::var_os("USER").unwrap_or_default())
873        .env("SHELL", std::env::var_os("SHELL").unwrap_or_default())
874        .env("SYNAPS_PLUGIN_DIR", plugin_dir)
875        .stdin(Stdio::null())
876        .stdout(Stdio::piped())
877        .stderr(Stdio::piped())
878        .kill_on_drop(true);
879
880    let mut child = cmd.spawn()?;
881    let mut stdout = child.stdout.take().expect("piped stdout");
882    let mut stderr = child.stderr.take().expect("piped stderr");
883
884    let copy_out = async {
885        tokio::io::copy(&mut stdout, &mut log_file).await?;
886        log_file.flush().await?;
887        Ok::<_, std::io::Error>(log_file)
888    };
889    let collect_err = async {
890        let mut buf = Vec::new();
891        tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut buf).await?;
892        Ok::<_, std::io::Error>(buf)
893    };
894
895    let wait = async {
896        let (out_res, err_res, status) = tokio::join!(copy_out, collect_err, child.wait());
897        let mut log_file = out_res?;
898        let err_buf = err_res?;
899        if !err_buf.is_empty() {
900            log_file.write_all(b"\n--- stderr ---\n").await?;
901            log_file.write_all(&err_buf).await?;
902            log_file.flush().await?;
903        }
904        Ok::<_, std::io::Error>(status?)
905    };
906
907    let status = match tokio::time::timeout(timeout, wait).await {
908        Ok(res) => res?,
909        Err(_) => {
910            return Err(SetupError::Timeout {
911                secs: timeout.as_secs(),
912                log_path: log_path.to_path_buf(),
913            });
914        }
915    };
916
917    let exit_code = status.code().unwrap_or(-1);
918    if status.success() {
919        Ok(SetupOutcome {
920            log_path: log_path.to_path_buf(),
921            exit_code,
922        })
923    } else {
924        Err(SetupError::NonZeroExit {
925            exit_code,
926            log_path: log_path.to_path_buf(),
927        })
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use crate::extensions::manifest::{ExtensionManifest, ExtensionRuntime};
935    use crate::skills::manifest::{PluginProvides, SidecarManifest};
936    use std::fs;
937
938    #[test]
939    fn host_triple_matches_compiled_target_when_supported() {
940        // Run-time check: triple must be one of the known stable strings
941        // on supported hosts (we test on linux/macos/windows in CI).
942        let known = [
943            "linux-x86_64", "linux-arm64",
944            "darwin-x86_64", "darwin-arm64",
945            "windows-x86_64", "windows-arm64",
946        ];
947        let got = host_triple();
948        if cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows"))
949            && cfg!(any(target_arch = "x86_64", target_arch = "aarch64"))
950        {
951            let s = got.expect("supported host should yield a triple");
952            assert!(known.contains(&s), "unexpected triple: {}", s);
953        }
954    }
955
956    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
957    #[test]
958    fn host_triple_is_linux_x86_64_on_this_box() {
959        // Sanity-pin for the dev box this is being authored on; harmless
960        // elsewhere because of the cfg gate.
961        assert_eq!(host_triple(), Some("linux-x86_64"));
962    }
963
964    fn manifest_with_setup(setup: Option<&str>) -> PluginManifest {
965        PluginManifest {
966            name: "test-plugin".to_string(),
967            version: None,
968            description: None,
969            keybinds: vec![],
970            compatibility: None,
971            commands: vec![],
972            extension: None,
973            help_entries: vec![],
974            provides: Some(PluginProvides {
975                sidecar: Some(SidecarManifest {
976                    command: "bin/sidecar".to_string(),
977                    setup: setup.map(|s| s.to_string()),
978                    protocol_version: 1,
979                    model: None,
980                    lifecycle: None,
981                }),
982            }),
983            settings: None,
984        }
985    }
986
987    /// Build an extension-only manifest with the given setup-script slot.
988    fn manifest_with_extension_setup(setup: Option<&str>) -> PluginManifest {
989        PluginManifest {
990            name: "test-plugin".to_string(),
991            version: None,
992            description: None,
993            keybinds: vec![],
994            compatibility: None,
995            commands: vec![],
996            extension: Some(ExtensionManifest {
997                protocol_version: 1,
998                runtime: ExtensionRuntime::Process,
999                command: "bin/ext".to_string(),
1000                setup: setup.map(|s| s.to_string()),
1001                prebuilt: ::std::collections::HashMap::new(),
1002                args: vec![],
1003                permissions: vec![],
1004                hooks: vec![],
1005                config: vec![],
1006            }),
1007            help_entries: vec![],
1008            provides: None,
1009            settings: None,
1010        }
1011    }
1012
1013    /// Build a manifest with BOTH extension and sidecar setup slots.
1014    /// Used to verify extension wins when both are present.
1015    fn manifest_with_both_setup(ext_setup: &str, side_setup: &str) -> PluginManifest {
1016        let mut m = manifest_with_extension_setup(Some(ext_setup));
1017        m.provides = Some(PluginProvides {
1018            sidecar: Some(SidecarManifest {
1019                command: "bin/sidecar".to_string(),
1020                setup: Some(side_setup.to_string()),
1021                protocol_version: 1,
1022                model: None,
1023                lifecycle: None,
1024            }),
1025        });
1026        m
1027    }
1028
1029    #[test]
1030    fn resolve_returns_none_when_no_setup_declared() {
1031        let m = manifest_with_setup(None);
1032        let dir = tempfile::tempdir().unwrap();
1033        let res = resolve_setup_script(&m, dir.path()).unwrap();
1034        assert!(res.is_none());
1035    }
1036
1037    #[test]
1038    fn resolve_returns_none_when_no_provides() {
1039        let mut m = manifest_with_setup(None);
1040        m.provides = None;
1041        let dir = tempfile::tempdir().unwrap();
1042        assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1043    }
1044
1045    #[test]
1046    fn resolve_resolves_relative_path_inside_plugin_dir() {
1047        let dir = tempfile::tempdir().unwrap();
1048        let scripts = dir.path().join("scripts");
1049        fs::create_dir(&scripts).unwrap();
1050        fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
1051        let m = manifest_with_setup(Some("scripts/setup.sh"));
1052        let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1053        assert!(resolved.ends_with("scripts/setup.sh"));
1054        assert!(resolved.is_absolute());
1055    }
1056
1057    #[test]
1058    fn resolve_rejects_absolute_path() {
1059        let dir = tempfile::tempdir().unwrap();
1060        let m = manifest_with_setup(Some("/etc/passwd"));
1061        let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1062        assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1063    }
1064
1065    #[test]
1066    fn resolve_rejects_parent_dir_traversal() {
1067        let dir = tempfile::tempdir().unwrap();
1068        let m = manifest_with_setup(Some("../escape.sh"));
1069        let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1070        assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1071    }
1072
1073    #[test]
1074    fn resolve_rejects_embedded_parent_dir() {
1075        let dir = tempfile::tempdir().unwrap();
1076        let m = manifest_with_setup(Some("scripts/../../etc/passwd"));
1077        let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1078        assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1079    }
1080
1081    #[test]
1082    fn resolve_returns_not_found_when_script_missing() {
1083        let dir = tempfile::tempdir().unwrap();
1084        let m = manifest_with_setup(Some("scripts/missing.sh"));
1085        let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1086        assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
1087    }
1088
1089    #[test]
1090    fn resolve_rejects_symlink_pointing_outside_plugin_dir() {
1091        // Symlinks that escape via canonicalize should be caught by the
1092        // starts_with(canonical_dir) check.
1093        let outer = tempfile::tempdir().unwrap();
1094        let dir = tempfile::tempdir().unwrap();
1095        let target = outer.path().join("escape.sh");
1096        fs::write(&target, "#!/bin/bash").unwrap();
1097        let scripts = dir.path().join("scripts");
1098        fs::create_dir(&scripts).unwrap();
1099        let link = scripts.join("setup.sh");
1100        std::os::unix::fs::symlink(&target, &link).unwrap();
1101        let m = manifest_with_setup(Some("scripts/setup.sh"));
1102        let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1103        assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1104    }
1105
1106    #[test]
1107    fn install_log_path_substitutes_colons() {
1108        let path = install_log_path(
1109            Path::new("/tmp/logs"),
1110            "sample-sidecar",
1111            "2026-05-02T19:30:45-04:00",
1112        );
1113        assert_eq!(
1114            path,
1115            PathBuf::from("/tmp/logs/install/sample-sidecar-2026-05-02T19-30-45-04-00.log")
1116        );
1117    }
1118
1119    #[tokio::test]
1120    async fn run_setup_succeeds_for_simple_script() {
1121        let dir = tempfile::tempdir().unwrap();
1122        let scripts = dir.path().join("scripts");
1123        fs::create_dir(&scripts).unwrap();
1124        let script = scripts.join("setup.sh");
1125        fs::write(
1126            &script,
1127            "#!/bin/bash\necho hello-from-setup\necho 'on stderr' >&2\n",
1128        )
1129        .unwrap();
1130        use std::os::unix::fs::PermissionsExt;
1131        fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1132        let log = dir.path().join("install.log");
1133        let outcome = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1134            .await
1135            .unwrap();
1136        assert_eq!(outcome.exit_code, 0);
1137        assert_eq!(outcome.log_path, log);
1138        let captured = fs::read_to_string(&log).unwrap();
1139        assert!(captured.contains("hello-from-setup"));
1140        assert!(captured.contains("on stderr"));
1141    }
1142
1143    #[tokio::test]
1144    async fn run_setup_returns_non_zero_exit() {
1145        let dir = tempfile::tempdir().unwrap();
1146        let script = dir.path().join("fail.sh");
1147        fs::write(&script, "#!/bin/bash\necho boom\nexit 7\n").unwrap();
1148        let log = dir.path().join("install.log");
1149        let err = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1150            .await
1151            .unwrap_err();
1152        match err {
1153            SetupError::NonZeroExit { exit_code, log_path } => {
1154                assert_eq!(exit_code, 7);
1155                assert_eq!(log_path, log);
1156                let captured = fs::read_to_string(&log).unwrap();
1157                assert!(captured.contains("boom"));
1158            }
1159            other => panic!("expected NonZeroExit, got {other:?}"),
1160        }
1161    }
1162
1163    #[tokio::test]
1164    async fn run_setup_times_out() {
1165        let dir = tempfile::tempdir().unwrap();
1166        let script = dir.path().join("loop.sh");
1167        fs::write(&script, "#!/bin/bash\nsleep 5\n").unwrap();
1168        let log = dir.path().join("install.log");
1169        let err = run_setup_script(&script, dir.path(), &log, Duration::from_millis(200))
1170            .await
1171            .unwrap_err();
1172        assert!(matches!(err, SetupError::Timeout { .. }), "got {err:?}");
1173    }
1174
1175    // ── extension.setup coverage (added by feat/extension-setup-script) ──
1176
1177    #[test]
1178    fn resolve_resolves_extension_setup_path() {
1179        let dir = tempfile::tempdir().unwrap();
1180        let scripts = dir.path().join("scripts");
1181        fs::create_dir(&scripts).unwrap();
1182        fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
1183        let m = manifest_with_extension_setup(Some("scripts/setup.sh"));
1184        let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1185        assert!(resolved.ends_with("scripts/setup.sh"));
1186        assert!(resolved.is_absolute());
1187    }
1188
1189    #[test]
1190    fn resolve_returns_none_when_extension_has_no_setup() {
1191        let m = manifest_with_extension_setup(None);
1192        let dir = tempfile::tempdir().unwrap();
1193        assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1194    }
1195
1196    #[test]
1197    fn resolve_rejects_extension_setup_with_parent_dir() {
1198        let dir = tempfile::tempdir().unwrap();
1199        let m = manifest_with_extension_setup(Some("../escape.sh"));
1200        let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1201        assert!(
1202            matches!(err, SetupError::EscapesPluginDir { .. }),
1203            "got {err:?}"
1204        );
1205    }
1206
1207    #[test]
1208    fn resolve_rejects_extension_setup_when_absolute() {
1209        let dir = tempfile::tempdir().unwrap();
1210        let m = manifest_with_extension_setup(Some("/etc/passwd"));
1211        let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1212        assert!(
1213            matches!(err, SetupError::EscapesPluginDir { .. }),
1214            "got {err:?}"
1215        );
1216    }
1217
1218    #[test]
1219    fn resolve_returns_not_found_for_missing_extension_setup() {
1220        let dir = tempfile::tempdir().unwrap();
1221        let m = manifest_with_extension_setup(Some("scripts/missing.sh"));
1222        let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1223        assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
1224    }
1225
1226    #[test]
1227    fn resolve_prefers_extension_setup_over_sidecar_setup() {
1228        // When both slots are populated, extension wins — the host spawns
1229        // the extension binary first on session start, so its build must
1230        // run first.
1231        let dir = tempfile::tempdir().unwrap();
1232        let scripts = dir.path().join("scripts");
1233        fs::create_dir(&scripts).unwrap();
1234        fs::write(scripts.join("ext.sh"), "#!/bin/bash\necho ext").unwrap();
1235        fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
1236        let m = manifest_with_both_setup("scripts/ext.sh", "scripts/side.sh");
1237        let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1238        assert!(
1239            resolved.ends_with("scripts/ext.sh"),
1240            "expected extension setup to win, got {resolved:?}"
1241        );
1242    }
1243
1244    #[test]
1245    fn resolve_falls_back_to_sidecar_when_extension_has_no_setup() {
1246        // Plugin has an extension but no extension.setup, plus a sidecar
1247        // with setup. The sidecar's setup should still run (legacy slot
1248        // remains honoured).
1249        let dir = tempfile::tempdir().unwrap();
1250        let scripts = dir.path().join("scripts");
1251        fs::create_dir(&scripts).unwrap();
1252        fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
1253        let mut m = manifest_with_extension_setup(None); // extension present, no setup
1254        m.provides = Some(PluginProvides {
1255            sidecar: Some(SidecarManifest {
1256                command: "bin/sidecar".to_string(),
1257                setup: Some("scripts/side.sh".to_string()),
1258                protocol_version: 1,
1259                model: None,
1260                lifecycle: None,
1261            }),
1262        });
1263        let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1264        assert!(resolved.ends_with("scripts/side.sh"));
1265    }
1266
1267    #[test]
1268    fn resolve_returns_none_when_neither_slot_has_setup() {
1269        // Plugin has both extension and sidecar declared, but neither
1270        // declares a setup script — function returns Ok(None).
1271        let dir = tempfile::tempdir().unwrap();
1272        let mut m = manifest_with_extension_setup(None);
1273        m.provides = Some(PluginProvides {
1274            sidecar: Some(SidecarManifest {
1275                command: "bin/sidecar".to_string(),
1276                setup: None,
1277                protocol_version: 1,
1278                model: None,
1279                lifecycle: None,
1280            }),
1281        });
1282        assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1283    }
1284
1285    // ---- verify_extension_command tests (slice C) ----
1286
1287    /// Helper: build a manifest whose extension declares `command`.
1288    fn manifest_with_extension_command(command: &str) -> PluginManifest {
1289        PluginManifest {
1290            name: "test-plugin".to_string(),
1291            version: None,
1292            description: None,
1293            keybinds: vec![],
1294            compatibility: None,
1295            commands: vec![],
1296            extension: Some(ExtensionManifest {
1297                protocol_version: 1,
1298                runtime: ExtensionRuntime::Process,
1299                command: command.to_string(),
1300                setup: None,
1301                prebuilt: ::std::collections::HashMap::new(),
1302                args: vec![],
1303                permissions: vec![],
1304                hooks: vec![],
1305                config: vec![],
1306            }),
1307            help_entries: vec![],
1308            provides: None,
1309            settings: None,
1310        }
1311    }
1312
1313    #[test]
1314    fn verify_returns_ok_none_when_no_extension() {
1315        let dir = tempfile::tempdir().unwrap();
1316        let m = manifest_with_setup(None); // sidecar-only manifest
1317        assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1318    }
1319
1320    #[test]
1321    fn verify_returns_ok_none_for_bare_command_name() {
1322        // Bare names defer to PATH lookup at spawn time, not our concern.
1323        let dir = tempfile::tempdir().unwrap();
1324        let m = manifest_with_extension_command("python3");
1325        assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1326    }
1327
1328    #[test]
1329    fn verify_succeeds_when_relative_binary_exists_and_is_executable() {
1330        let dir = tempfile::tempdir().unwrap();
1331        let bin = dir.path().join("bin/ext");
1332        fs::create_dir_all(bin.parent().unwrap()).unwrap();
1333        fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
1334        #[cfg(unix)]
1335        {
1336            use std::os::unix::fs::PermissionsExt;
1337            fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
1338        }
1339        let m = manifest_with_extension_command("bin/ext");
1340        let resolved = verify_extension_command(&m, dir.path()).unwrap();
1341        assert!(resolved.is_some(), "should return resolved path");
1342    }
1343
1344    #[test]
1345    fn verify_returns_missing_when_binary_absent() {
1346        let dir = tempfile::tempdir().unwrap();
1347        let m = manifest_with_extension_command("bin/ext");
1348        let err = verify_extension_command(&m, dir.path()).unwrap_err();
1349        assert!(matches!(err, CommandVerifyError::Missing { .. }), "got: {err:?}");
1350    }
1351
1352    #[cfg(unix)]
1353    #[test]
1354    fn verify_returns_not_executable_when_bit_missing() {
1355        let dir = tempfile::tempdir().unwrap();
1356        let bin = dir.path().join("bin/ext");
1357        fs::create_dir_all(bin.parent().unwrap()).unwrap();
1358        fs::write(&bin, "data").unwrap();
1359        use std::os::unix::fs::PermissionsExt;
1360        fs::set_permissions(&bin, fs::Permissions::from_mode(0o644)).unwrap();
1361        let m = manifest_with_extension_command("bin/ext");
1362        let err = verify_extension_command(&m, dir.path()).unwrap_err();
1363        assert!(matches!(err, CommandVerifyError::NotExecutable { .. }), "got: {err:?}");
1364    }
1365
1366    #[test]
1367    fn verify_returns_not_a_file_when_path_is_directory() {
1368        let dir = tempfile::tempdir().unwrap();
1369        let bin = dir.path().join("bin/ext");
1370        fs::create_dir_all(&bin).unwrap();
1371        let m = manifest_with_extension_command("bin/ext");
1372        let err = verify_extension_command(&m, dir.path()).unwrap_err();
1373        assert!(matches!(err, CommandVerifyError::NotAFile { .. }), "got: {err:?}");
1374    }
1375
1376    #[test]
1377    fn verify_rejects_parent_dir_traversal_in_command() {
1378        let dir = tempfile::tempdir().unwrap();
1379        let m = manifest_with_extension_command("../escape/bin");
1380        let err = verify_extension_command(&m, dir.path()).unwrap_err();
1381        assert!(matches!(err, CommandVerifyError::EscapesPluginDir { .. }), "got: {err:?}");
1382    }
1383
1384    #[cfg(unix)]
1385    #[test]
1386    fn verify_rejects_symlink_pointing_outside_plugin_dir() {
1387        let outer = tempfile::tempdir().unwrap();
1388        let plugin = tempfile::tempdir().unwrap();
1389        // Create a target binary outside the plugin dir.
1390        let target = outer.path().join("real-bin");
1391        fs::write(&target, "x").unwrap();
1392        use std::os::unix::fs::PermissionsExt;
1393        fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
1394        // Symlink inside the plugin dir to that outside binary.
1395        let link = plugin.path().join("bin/ext");
1396        fs::create_dir_all(link.parent().unwrap()).unwrap();
1397        std::os::unix::fs::symlink(&target, &link).unwrap();
1398        let m = manifest_with_extension_command("bin/ext");
1399        let err = verify_extension_command(&m, plugin.path()).unwrap_err();
1400        assert!(
1401            matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
1402            "got: {err:?}"
1403        );
1404    }
1405
1406
1407    #[test]
1408    fn verify_rejects_absolute_extension_command() {
1409        let dir = tempfile::tempdir().unwrap();
1410        let m = manifest_with_extension_command("/tmp/ext");
1411        let err = verify_extension_command(&m, dir.path()).unwrap_err();
1412        assert!(matches!(err, CommandVerifyError::EscapesPluginDir { .. }), "got: {err:?}");
1413    }
1414
1415    #[test]
1416    fn policy_normalizes_sha256_and_sanitizes_names() {
1417        assert_eq!(normalize_sha256(&"A".repeat(64)).unwrap(), "a".repeat(64));
1418        assert!(normalize_sha256("not-a-sha").is_none());
1419        assert_eq!(safe_name_fragment("../bad\nname"), "bad_name");
1420        assert_eq!(safe_name_fragment("normal-name_1.2"), "normal-name_1.2");
1421    }
1422
1423    // ---- try_install_from_prebuilt tests (slice E) ----
1424
1425    fn manifest_with_prebuilt(
1426        command: &str,
1427        triple: &str,
1428        url: &str,
1429        sha256: &str,
1430    ) -> PluginManifest {
1431        let mut prebuilt = std::collections::HashMap::new();
1432        prebuilt.insert(
1433            triple.to_string(),
1434            crate::extensions::manifest::PrebuiltAsset {
1435                url: url.to_string(),
1436                sha256: sha256.to_string(),
1437            },
1438        );
1439        PluginManifest {
1440            name: "test-plugin".to_string(),
1441            version: None,
1442            description: None,
1443            keybinds: vec![],
1444            compatibility: None,
1445            commands: vec![],
1446            extension: Some(ExtensionManifest {
1447                protocol_version: 1,
1448                runtime: ExtensionRuntime::Process,
1449                command: command.to_string(),
1450                setup: None,
1451                prebuilt,
1452                args: vec![],
1453                permissions: vec![],
1454                hooks: vec![],
1455                config: vec![],
1456            }),
1457            help_entries: vec![],
1458            provides: None,
1459            settings: None,
1460        }
1461    }
1462
1463    /// Helper: create a tar.gz archive containing one executable file at
1464    /// the given relative path inside the archive. Returns the archive
1465    /// path and its SHA-256.
1466    fn mk_tarball(staging: &Path, archive_name: &str, inner_path: &str) -> (PathBuf, String) {
1467        let work = staging.join("staging");
1468        fs::create_dir_all(&work).unwrap();
1469        let payload = work.join(inner_path);
1470        fs::create_dir_all(payload.parent().unwrap()).unwrap();
1471        fs::write(&payload, "#!/bin/sh\necho prebuilt-bin\n").unwrap();
1472        #[cfg(unix)]
1473        {
1474            use std::os::unix::fs::PermissionsExt;
1475            fs::set_permissions(&payload, fs::Permissions::from_mode(0o755)).unwrap();
1476        }
1477        let archive = staging.join(archive_name);
1478        let out = std::process::Command::new("tar")
1479            .arg("-czf")
1480            .arg(&archive)
1481            .arg("-C")
1482            .arg(&work)
1483            .arg(inner_path)
1484            .output()
1485            .expect("system tar must be present");
1486        assert!(out.status.success(), "tar failed: {:?}", out);
1487        let bytes = fs::read(&archive).unwrap();
1488        use sha2::{Digest, Sha256};
1489        let mut h = Sha256::new();
1490        h.update(&bytes);
1491        let sha = hex_encode_lower(&h.finalize());
1492        (archive, sha)
1493    }
1494
1495    #[tokio::test(flavor = "current_thread")]
1496    async fn prebuilt_returns_no_matching_asset_when_triple_missing() {
1497        let dir = tempfile::tempdir().unwrap();
1498        // Asset under a deliberately wrong host triple.
1499        let m = manifest_with_prebuilt("bin/ext", "fake-triple-9999", "https://x", "00");
1500        let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1501        assert!(matches!(err, PrebuiltError::NoMatchingAsset), "got: {err:?}");
1502    }
1503
1504    #[tokio::test(flavor = "current_thread")]
1505    async fn prebuilt_rejects_non_https_url_in_production_builds() {
1506        // file:// is test-only; http:// is always blocked.
1507        let dir = tempfile::tempdir().unwrap();
1508        let triple = host_triple().expect("supported host");
1509        let m = manifest_with_prebuilt("bin/ext", triple, "http://example.com/x.tar.gz", "00");
1510        let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1511        assert!(matches!(err, PrebuiltError::UnsafeUrl { .. }), "got: {err:?}");
1512    }
1513
1514    #[tokio::test(flavor = "current_thread")]
1515    async fn prebuilt_succeeds_with_valid_tarball_and_checksum() {
1516                let staging = tempfile::tempdir().unwrap();
1517        let plugin = tempfile::tempdir().unwrap();
1518        let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1519        let url = format!("file://{}", archive.display());
1520        let triple = host_triple().expect("supported host");
1521        let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1522        let resolved = try_install_from_prebuilt(&m, plugin.path()).await.unwrap();
1523        assert!(resolved.exists(), "extracted binary should exist at {}", resolved.display());
1524        // Also confirm the temp download file was cleaned up.
1525        let leftover = plugin.path().join(format!(".prebuilt-{}-download", triple));
1526        assert!(!leftover.exists(), "temp archive should be removed");
1527    }
1528
1529    #[tokio::test(flavor = "current_thread")]
1530    async fn prebuilt_aborts_on_checksum_mismatch_without_extracting() {
1531                let staging = tempfile::tempdir().unwrap();
1532        let plugin = tempfile::tempdir().unwrap();
1533        let (archive, _real_sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1534        let url = format!("file://{}", archive.display());
1535        let triple = host_triple().expect("supported host");
1536        let bad_sha = "0".repeat(64);
1537        let m = manifest_with_prebuilt("bin/ext", triple, &url, &bad_sha);
1538        let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
1539        match err {
1540            PrebuiltError::ChecksumMismatch { expected, actual } => {
1541                assert_eq!(expected, bad_sha);
1542                assert_eq!(actual.len(), 64, "actual sha should be lowercase hex");
1543            }
1544            other => panic!("expected ChecksumMismatch, got {other:?}"),
1545        }
1546        // No artifact should have been written.
1547        assert!(!plugin.path().join("bin/ext").exists());
1548    }
1549
1550    #[tokio::test(flavor = "current_thread")]
1551    async fn prebuilt_rejects_unsupported_archive_suffix() {
1552                let staging = tempfile::tempdir().unwrap();
1553        let plugin = tempfile::tempdir().unwrap();
1554        // Make a .rar-named file (we only support tar/zip variants).
1555        let archive = staging.path().join("ext.rar");
1556        fs::write(&archive, b"not really a rar").unwrap();
1557        let bytes = fs::read(&archive).unwrap();
1558        use sha2::{Digest, Sha256};
1559        let mut h = Sha256::new();
1560        h.update(&bytes);
1561        let sha = hex_encode_lower(&h.finalize());
1562        let url = format!("file://{}", archive.display());
1563        let triple = host_triple().expect("supported host");
1564        let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1565        let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
1566        assert!(matches!(err, PrebuiltError::UnsupportedArchive { .. }), "got: {err:?}");
1567    }
1568
1569    #[tokio::test(flavor = "current_thread")]
1570    async fn prebuilt_fails_verify_when_archive_does_not_contain_declared_command() {
1571                let staging = tempfile::tempdir().unwrap();
1572        let plugin = tempfile::tempdir().unwrap();
1573        // Archive ships at bin/wrong-name but manifest declares bin/ext.
1574        let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/wrong-name");
1575        let url = format!("file://{}", archive.display());
1576        let triple = host_triple().expect("supported host");
1577        let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1578        let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
1579        assert!(matches!(err, PrebuiltError::Verify(_)), "got: {err:?}");
1580    }
1581}