Skip to main content

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