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