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