1use std::path::{Component, Path, PathBuf};
44use std::time::Duration;
45
46use crate::skills::manifest::PluginManifest;
47
48pub 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
66pub 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
86pub fn normalize_sha256(value: &str) -> Option<String> {
89 let trimmed = value.trim();
90 if trimmed.len() == 64 && trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
91 Some(trimmed.to_ascii_lowercase())
92 } else {
93 None
94 }
95}
96
97fn prebuilt_url_allowed(url: &str) -> bool {
98 if url.starts_with("https://") {
99 return true;
100 }
101 #[cfg(test)]
102 {
103 if url.starts_with("file://") {
104 return true;
105 }
106 }
107 false
108}
109
110fn archive_suffix(url_for_suffix: &str) -> Result<&'static str, PrebuiltError> {
111 let url_clean = url_for_suffix
112 .split(['?', '#'])
113 .next()
114 .unwrap_or(url_for_suffix)
115 .to_ascii_lowercase();
116 if url_clean.ends_with(".tar.gz") || url_clean.ends_with(".tgz") {
117 Ok("tar.gz")
118 } else if url_clean.ends_with(".zip") {
119 Ok("zip")
120 } else if url_clean.ends_with(".tar.xz") || url_clean.ends_with(".tar.bz2") {
121 Err(PrebuiltError::UnsupportedArchive {
122 url: format!("{url_for_suffix} (xz/bz2 prebuilt archives are not supported by the hardened extractor; use .tar.gz or .zip)"),
123 })
124 } else {
125 Err(PrebuiltError::UnsupportedArchive {
126 url: url_for_suffix.to_string(),
127 })
128 }
129}
130
131fn validate_archive_relative_path(path: &Path) -> Result<(), PrebuiltError> {
132 if path.is_absolute() {
133 return Err(PrebuiltError::Extract(format!(
134 "archive entry '{}' is absolute",
135 path.display()
136 )));
137 }
138 if path.components().any(|c| matches!(c, Component::ParentDir | Component::Prefix(_))) {
139 return Err(PrebuiltError::Extract(format!(
140 "archive entry '{}' escapes extraction directory",
141 path.display()
142 )));
143 }
144 Ok(())
145}
146
147fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
148 for entry in std::fs::read_dir(src)? {
149 let entry = entry?;
150 let from = entry.path();
151 let to = dest.join(entry.file_name());
152 let meta = entry.file_type()?;
153 if meta.is_dir() {
154 std::fs::create_dir_all(&to)?;
155 copy_dir_contents(&from, &to)?;
156 } else if meta.is_file() {
157 if let Some(parent) = to.parent() {
158 std::fs::create_dir_all(parent)?;
159 }
160 std::fs::copy(&from, &to)?;
161 #[cfg(unix)]
162 {
163 use std::os::unix::fs::PermissionsExt;
164 let mode = std::fs::metadata(&from)?.permissions().mode();
165 std::fs::set_permissions(&to, std::fs::Permissions::from_mode(mode))?;
166 }
167 }
168 }
169 Ok(())
170}
171
172pub fn host_triple() -> Option<&'static str> {
173 let os = if cfg!(target_os = "linux") {
174 "linux"
175 } else if cfg!(target_os = "macos") {
176 "darwin"
177 } else if cfg!(target_os = "windows") {
178 "windows"
179 } else {
180 return None;
181 };
182 let arch = if cfg!(target_arch = "x86_64") {
183 "x86_64"
184 } else if cfg!(target_arch = "aarch64") {
185 "arm64"
186 } else {
187 return None;
188 };
189 Some(match (os, arch) {
191 ("linux", "x86_64") => "linux-x86_64",
192 ("linux", "arm64") => "linux-arm64",
193 ("darwin", "x86_64") => "darwin-x86_64",
194 ("darwin", "arm64") => "darwin-arm64",
195 ("windows", "x86_64") => "windows-x86_64",
196 ("windows", "arm64") => "windows-arm64",
197 _ => return None,
198 })
199}
200
201pub const SETUP_TIMEOUT: Duration = Duration::from_secs(600);
206
207#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct SetupOutcome {
210 pub log_path: PathBuf,
212 pub exit_code: i32,
214}
215
216#[derive(Debug, thiserror::Error)]
218pub enum SetupError {
219 #[error("setup script path '{path}' escapes plugin directory")]
222 EscapesPluginDir { path: String },
223
224 #[error("setup script '{path}' not found in plugin directory")]
227 NotFound { path: String },
228
229 #[error("setup script exited with code {exit_code}; see {}", log_path.display())]
232 NonZeroExit { exit_code: i32, log_path: PathBuf },
233
234 #[error("setup script timed out after {secs}s; see {}", log_path.display())]
236 Timeout { secs: u64, log_path: PathBuf },
237
238 #[error("setup script io: {0}")]
240 Io(#[from] std::io::Error),
241}
242
243#[derive(Debug, thiserror::Error, PartialEq, Eq)]
248pub enum CommandVerifyError {
249 #[error("extension command path '{path}' escapes plugin directory")]
252 EscapesPluginDir { path: String },
253
254 #[error("extension command '{path}' does not exist (resolved to {})", resolved.display())]
258 Missing { path: String, resolved: PathBuf },
259
260 #[cfg(unix)]
264 #[error("extension command '{path}' exists but is not executable (mode {mode:o})")]
265 NotExecutable { path: String, mode: u32 },
266
267 #[error("extension command '{path}' is a directory, not a file")]
269 NotAFile { path: String },
270}
271
272pub fn verify_extension_command(
293 manifest: &PluginManifest,
294 plugin_dir: &Path,
295) -> Result<Option<PathBuf>, CommandVerifyError> {
296 let Some(ext) = manifest.extension.as_ref() else {
297 return Ok(None);
298 };
299 let cmd = &ext.command;
300 let cmd_path = Path::new(cmd);
301
302 if !cmd.contains(std::path::MAIN_SEPARATOR) && !cmd.contains('/') {
304 return Ok(None);
305 }
306
307 let resolved = if cmd_path.is_absolute() {
308 return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
309 } else {
310 if cmd_path
312 .components()
313 .any(|c| matches!(c, std::path::Component::ParentDir))
314 {
315 return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
316 }
317 let joined = plugin_dir.join(cmd_path);
318 match joined.canonicalize() {
319 Ok(p) => {
320 let canonical_dir = plugin_dir
321 .canonicalize()
322 .unwrap_or_else(|_| plugin_dir.to_path_buf());
323 if !p.starts_with(&canonical_dir) {
324 return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
325 }
326 p
327 }
328 Err(_) => {
329 return Err(CommandVerifyError::Missing {
330 path: cmd.clone(),
331 resolved: joined,
332 });
333 }
334 }
335 };
336
337 if !resolved.exists() {
338 return Err(CommandVerifyError::Missing {
339 path: cmd.clone(),
340 resolved,
341 });
342 }
343 let meta = std::fs::metadata(&resolved).map_err(|_| CommandVerifyError::Missing {
344 path: cmd.clone(),
345 resolved: resolved.clone(),
346 })?;
347 if meta.is_dir() {
348 return Err(CommandVerifyError::NotAFile { path: cmd.clone() });
349 }
350 #[cfg(unix)]
351 {
352 use std::os::unix::fs::PermissionsExt;
353 let mode = meta.permissions().mode();
354 if mode & 0o111 == 0 {
356 return Err(CommandVerifyError::NotExecutable {
357 path: cmd.clone(),
358 mode,
359 });
360 }
361 }
362 Ok(Some(resolved))
363}
364
365#[derive(Debug, thiserror::Error)]
371pub enum PrebuiltError {
372 #[error("no prebuilt asset declared for this host")]
376 NoMatchingAsset,
377
378 #[error("download failed: {0}")]
380 Download(String),
381
382 #[error("checksum mismatch: expected {expected}, got {actual}")]
386 ChecksumMismatch { expected: String, actual: String },
387
388 #[error("archive extraction failed: {0}")]
390 Extract(String),
391
392 #[error("unsupported archive type for url '{url}'")]
395 UnsupportedArchive { url: String },
396
397 #[error("refusing non-https prebuilt url '{url}'")]
399 UnsafeUrl { url: String },
400
401 #[error("invalid sha256 '{sha256}'; expected exactly 64 hex characters")]
403 InvalidSha256 { sha256: String },
404
405 #[error("prebuilt archive exceeds maximum size of {max} bytes")]
407 TooLarge { max: u64 },
408
409 #[error("io: {0}")]
411 Io(#[from] std::io::Error),
412
413 #[error("prebuilt extracted but extension command not found: {0}")]
416 Verify(#[from] CommandVerifyError),
417}
418
419fn hex_encode_lower(bytes: &[u8]) -> String {
422 let mut out = String::with_capacity(bytes.len() * 2);
423 for b in bytes {
424 out.push(char::from_digit((*b >> 4) as u32, 16).unwrap());
425 out.push(char::from_digit((*b & 0x0f) as u32, 16).unwrap());
426 }
427 out
428}
429
430pub async fn try_install_from_prebuilt(
446 manifest: &PluginManifest,
447 plugin_dir: &Path,
448) -> Result<PathBuf, PrebuiltError> {
449 let Some(ext) = manifest.extension.as_ref() else {
450 return Err(PrebuiltError::NoMatchingAsset);
451 };
452 let Some(triple) = host_triple() else {
453 return Err(PrebuiltError::NoMatchingAsset);
454 };
455 let Some(asset) = ext.prebuilt.get(triple) else {
456 return Err(PrebuiltError::NoMatchingAsset);
457 };
458
459 if !prebuilt_url_allowed(&asset.url) {
460 return Err(PrebuiltError::UnsafeUrl {
461 url: asset.url.clone(),
462 });
463 }
464 let expected_sha = normalize_sha256(&asset.sha256).ok_or_else(|| PrebuiltError::InvalidSha256 {
465 sha256: asset.sha256.clone(),
466 })?;
467
468 let tmp_archive = plugin_dir.join(format!(".prebuilt-{triple}-download"));
469 match std::fs::remove_file(&tmp_archive) {
470 Ok(()) => {}
471 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
472 Err(e) => return Err(PrebuiltError::Io(e)),
473 }
474
475 let download_res = download_prebuilt_to_file(&asset.url, &tmp_archive, MAX_PREBUILT_ARCHIVE_BYTES).await;
476 let actual = match download_res {
477 Ok(sha) => sha,
478 Err(e) => {
479 let _ = std::fs::remove_file(&tmp_archive);
480 return Err(e);
481 }
482 };
483 if actual != expected_sha {
484 let _ = std::fs::remove_file(&tmp_archive);
485 return Err(PrebuiltError::ChecksumMismatch {
486 expected: expected_sha,
487 actual,
488 });
489 }
490
491 let archive = tmp_archive.clone();
492 let dest = plugin_dir.to_path_buf();
493 let url = asset.url.clone();
494 let extract_res = tokio::task::spawn_blocking(move || extract_archive(&archive, &dest, &url))
495 .await
496 .map_err(|e| PrebuiltError::Extract(format!("extract task join error: {e}")))?;
497 let _ = std::fs::remove_file(&tmp_archive);
498 extract_res?;
499
500 let resolved = verify_extension_command(manifest, plugin_dir)?
502 .ok_or_else(|| {
503 PrebuiltError::Verify(CommandVerifyError::Missing {
504 path: ext.command.clone(),
505 resolved: plugin_dir.join(&ext.command),
506 })
507 })?;
508 Ok(resolved)
509}
510
511async fn download_prebuilt_to_file(
512 url: &str,
513 tmp_archive: &Path,
514 max_bytes: u64,
515) -> Result<String, PrebuiltError> {
516 use sha2::{Digest, Sha256};
517
518 let mut hasher = Sha256::new();
519 let mut written: u64 = 0;
520
521 if let Some(path) = url.strip_prefix("file://") {
522 #[cfg(not(test))]
523 {
524 let _ = path;
525 return Err(PrebuiltError::UnsafeUrl { url: url.to_string() });
526 }
527 #[cfg(test)]
528 {
529 let mut input = std::fs::File::open(path)
530 .map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
531 let mut output = std::fs::File::create(tmp_archive)?;
532 let mut buf = [0u8; 8192];
533 loop {
534 let n = std::io::Read::read(&mut input, &mut buf)
535 .map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
536 if n == 0 {
537 break;
538 }
539 written += n as u64;
540 if written > max_bytes {
541 return Err(PrebuiltError::TooLarge { max: max_bytes });
542 }
543 hasher.update(&buf[..n]);
544 std::io::Write::write_all(&mut output, &buf[..n])?;
545 }
546 return Ok(hex_encode_lower(&hasher.finalize()));
547 }
548 }
549
550 let client = reqwest::Client::builder()
551 .tls_built_in_webpki_certs(true)
552 .tls_built_in_native_certs(true)
553 .connect_timeout(PREBUILT_CONNECT_TIMEOUT)
554 .timeout(PREBUILT_REQUEST_TIMEOUT)
555 .build()
556 .map_err(|e| PrebuiltError::Download(e.to_string()))?;
557 let mut response = client
558 .get(url)
559 .send()
560 .await
561 .map_err(|e| PrebuiltError::Download(e.to_string()))?;
562 if !response.status().is_success() {
563 return Err(PrebuiltError::Download(format!("HTTP {}", response.status())));
564 }
565 if let Some(len) = response.content_length() {
566 if len > max_bytes {
567 return Err(PrebuiltError::TooLarge { max: max_bytes });
568 }
569 }
570
571 let mut output = tokio::fs::File::create(tmp_archive).await?;
572 while let Some(chunk) = response
573 .chunk()
574 .await
575 .map_err(|e| PrebuiltError::Download(e.to_string()))?
576 {
577 written += chunk.len() as u64;
578 if written > max_bytes {
579 return Err(PrebuiltError::TooLarge { max: max_bytes });
580 }
581 hasher.update(&chunk);
582 tokio::io::AsyncWriteExt::write_all(&mut output, &chunk).await?;
583 }
584 tokio::io::AsyncWriteExt::flush(&mut output).await?;
585 Ok(hex_encode_lower(&hasher.finalize()))
586}
587
588fn extract_archive(
593 archive: &Path,
594 dest_dir: &Path,
595 url_for_suffix: &str,
596) -> Result<(), PrebuiltError> {
597 let kind = archive_suffix(url_for_suffix)?;
598 let extract_root = dest_dir.join(format!(
599 ".prebuilt-extract-{}",
600 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
601 ));
602 match std::fs::remove_dir_all(&extract_root) {
603 Ok(()) => {}
604 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
605 Err(e) => return Err(PrebuiltError::Io(e)),
606 }
607 std::fs::create_dir_all(&extract_root)?;
608 let result = match kind {
609 "tar.gz" => extract_tar_gz_safe(archive, &extract_root),
610 "zip" => extract_zip_safe(archive, &extract_root),
611 _ => unreachable!(),
612 }
613 .and_then(|_| copy_dir_contents(&extract_root, dest_dir).map_err(PrebuiltError::Io));
614 let cleanup = std::fs::remove_dir_all(&extract_root);
615 match (result, cleanup) {
616 (Ok(()), Ok(())) => Ok(()),
617 (Ok(()), Err(e)) => Err(PrebuiltError::Extract(format!(
618 "failed to clean extraction directory {}: {e}",
619 extract_root.display()
620 ))),
621 (Err(e), _) => Err(e),
622 }
623}
624
625fn extract_tar_gz_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
626 use std::process::{Command, Stdio};
627
628 let list = Command::new("tar")
629 .arg("-tzf")
630 .arg(archive)
631 .output()
632 .map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
633 if !list.status.success() {
634 return Err(PrebuiltError::Extract(format!(
635 "tar list exited {}: {}",
636 list.status,
637 String::from_utf8_lossy(&list.stderr).trim()
638 )));
639 }
640 for line in String::from_utf8_lossy(&list.stdout).lines() {
641 validate_archive_relative_path(Path::new(line))?;
642 }
643 let out = Command::new("tar")
644 .arg("--no-same-owner")
645 .arg("--no-same-permissions")
646 .arg("-xzf")
647 .arg(archive)
648 .arg("-C")
649 .arg(root)
650 .stdin(Stdio::null())
651 .output()
652 .map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
653 if !out.status.success() {
654 return Err(PrebuiltError::Extract(format!(
655 "tar exited {}: {}",
656 out.status,
657 String::from_utf8_lossy(&out.stderr).trim()
658 )));
659 }
660 validate_extracted_tree(root)
661}
662
663fn extract_zip_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
664 use std::process::{Command, Stdio};
665
666 let list = Command::new("unzip")
667 .arg("-Z1")
668 .arg(archive)
669 .output()
670 .map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
671 if !list.status.success() {
672 return Err(PrebuiltError::Extract(format!(
673 "unzip list exited {}: {}",
674 list.status,
675 String::from_utf8_lossy(&list.stderr).trim()
676 )));
677 }
678 for line in String::from_utf8_lossy(&list.stdout).lines() {
679 validate_archive_relative_path(Path::new(line))?;
680 }
681 let out = Command::new("unzip")
682 .arg("-q")
683 .arg(archive)
684 .arg("-d")
685 .arg(root)
686 .stdin(Stdio::null())
687 .output()
688 .map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
689 if !out.status.success() {
690 return Err(PrebuiltError::Extract(format!(
691 "unzip exited {}: {}",
692 out.status,
693 String::from_utf8_lossy(&out.stderr).trim()
694 )));
695 }
696 validate_extracted_tree(root)
697}
698
699fn validate_extracted_tree(root: &Path) -> Result<(), PrebuiltError> {
700 let canonical_root = root.canonicalize()?;
701 fn walk(path: &Path, root: &Path) -> Result<(), PrebuiltError> {
702 for entry in std::fs::read_dir(path)? {
703 let entry = entry?;
704 let ty = entry.file_type()?;
705 let p = entry.path();
706 if ty.is_symlink() {
707 let target = std::fs::canonicalize(&p).map_err(|e| {
708 PrebuiltError::Extract(format!("symlink '{}' cannot be resolved: {e}", p.display()))
709 })?;
710 if !target.starts_with(root) {
711 return Err(PrebuiltError::Extract(format!(
712 "symlink '{}' escapes extraction directory",
713 p.display()
714 )));
715 }
716 } else if ty.is_dir() {
717 let c = p.canonicalize()?;
718 if !c.starts_with(root) {
719 return Err(PrebuiltError::Extract(format!(
720 "directory '{}' escapes extraction directory",
721 p.display()
722 )));
723 }
724 walk(&p, root)?;
725 } else if ty.is_file() {
726 let c = p.canonicalize()?;
727 if !c.starts_with(root) {
728 return Err(PrebuiltError::Extract(format!(
729 "file '{}' escapes extraction directory",
730 p.display()
731 )));
732 }
733 } else {
734 return Err(PrebuiltError::Extract(format!(
735 "unsupported archive entry type '{}'",
736 p.display()
737 )));
738 }
739 }
740 Ok(())
741 }
742 walk(&canonical_root, &canonical_root)
743}
744
745pub fn resolve_setup_script(
756 manifest: &PluginManifest,
757 plugin_dir: &Path,
758) -> Result<Option<PathBuf>, SetupError> {
759 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 if let Some(provides) = manifest.provides.as_ref() {
769 if let Some(sidecar) = provides.sidecar.as_ref() {
770 if let Some(setup) = sidecar.setup.as_deref() {
771 return validate_setup_path(setup, plugin_dir).map(Some);
772 }
773 }
774 }
775 Ok(None)
776}
777
778fn validate_setup_path(setup: &str, plugin_dir: &Path) -> Result<PathBuf, SetupError> {
784 let setup_path = Path::new(setup);
785 if setup_path.is_absolute()
786 || setup_path
787 .components()
788 .any(|c| matches!(c, std::path::Component::ParentDir))
789 {
790 return Err(SetupError::EscapesPluginDir {
791 path: setup.to_string(),
792 });
793 }
794 let joined = plugin_dir.join(setup_path);
795 let canonical = match joined.canonicalize() {
796 Ok(p) => p,
797 Err(_) => {
798 return Err(SetupError::NotFound {
799 path: setup.to_string(),
800 });
801 }
802 };
803 let canonical_dir = plugin_dir
804 .canonicalize()
805 .unwrap_or_else(|_| plugin_dir.to_path_buf());
806 if !canonical.starts_with(&canonical_dir) {
807 return Err(SetupError::EscapesPluginDir {
808 path: setup.to_string(),
809 });
810 }
811 Ok(canonical)
812}
813
814pub fn install_log_path(logs_root: &Path, plugin_name: &str, now_rfc3339: &str) -> PathBuf {
822 let safe_ts = safe_name_fragment(&now_rfc3339.replace(':', "-"));
823 let safe_plugin = safe_name_fragment(plugin_name);
824 logs_root
825 .join("install")
826 .join(format!("{safe_plugin}-{safe_ts}.log"))
827}
828
829pub async fn run_setup_script(
840 script: &Path,
841 plugin_dir: &Path,
842 log_path: &Path,
843 timeout: Duration,
844) -> Result<SetupOutcome, SetupError> {
845 use std::process::Stdio;
846 use tokio::io::AsyncWriteExt;
847 use tokio::process::Command;
848
849 if let Some(parent) = log_path.parent() {
850 std::fs::create_dir_all(parent)?;
851 }
852 let mut log_file = tokio::fs::File::create(log_path).await?;
853 let header = format!(
854 "$ bash {} (cwd: {})\n",
855 script.display(),
856 plugin_dir.display()
857 );
858 log_file.write_all(header.as_bytes()).await?;
859
860 if cfg!(windows) {
861 return Err(SetupError::Io(std::io::Error::new(
862 std::io::ErrorKind::Unsupported,
863 "setup scripts require bash and are not supported on Windows in this release",
864 )));
865 }
866
867 let mut cmd = Command::new("bash");
868 cmd.arg(script)
869 .current_dir(plugin_dir)
870 .env_clear()
871 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
872 .env("HOME", std::env::var_os("HOME").unwrap_or_default())
873 .env("USER", std::env::var_os("USER").unwrap_or_default())
874 .env("SHELL", std::env::var_os("SHELL").unwrap_or_default())
875 .env("SYNAPS_PLUGIN_DIR", plugin_dir)
876 .stdin(Stdio::null())
877 .stdout(Stdio::piped())
878 .stderr(Stdio::piped())
879 .kill_on_drop(true);
880
881 let mut child = cmd.spawn()?;
882 let mut stdout = child.stdout.take().expect("piped stdout");
883 let mut stderr = child.stderr.take().expect("piped stderr");
884
885 let copy_out = async {
886 tokio::io::copy(&mut stdout, &mut log_file).await?;
887 log_file.flush().await?;
888 Ok::<_, std::io::Error>(log_file)
889 };
890 let collect_err = async {
891 let mut buf = Vec::new();
892 tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut buf).await?;
893 Ok::<_, std::io::Error>(buf)
894 };
895
896 let wait = async {
897 let (out_res, err_res, status) = tokio::join!(copy_out, collect_err, child.wait());
898 let mut log_file = out_res?;
899 let err_buf = err_res?;
900 if !err_buf.is_empty() {
901 log_file.write_all(b"\n--- stderr ---\n").await?;
902 log_file.write_all(&err_buf).await?;
903 log_file.flush().await?;
904 }
905 #[allow(clippy::needless_question_mark)]
906 Ok::<_, std::io::Error>(status?)
907 };
908
909 let status = match tokio::time::timeout(timeout, wait).await {
910 Ok(res) => res?,
911 Err(_) => {
912 return Err(SetupError::Timeout {
913 secs: timeout.as_secs(),
914 log_path: log_path.to_path_buf(),
915 });
916 }
917 };
918
919 let exit_code = status.code().unwrap_or(-1);
920 if status.success() {
921 Ok(SetupOutcome {
922 log_path: log_path.to_path_buf(),
923 exit_code,
924 })
925 } else {
926 Err(SetupError::NonZeroExit {
927 exit_code,
928 log_path: log_path.to_path_buf(),
929 })
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936 use crate::extensions::manifest::{ExtensionManifest, ExtensionRuntime};
937 use crate::skills::manifest::{PluginProvides, SidecarManifest};
938 use std::fs;
939
940 #[test]
941 fn host_triple_matches_compiled_target_when_supported() {
942 let known = [
945 "linux-x86_64", "linux-arm64",
946 "darwin-x86_64", "darwin-arm64",
947 "windows-x86_64", "windows-arm64",
948 ];
949 let got = host_triple();
950 if cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows"))
951 && cfg!(any(target_arch = "x86_64", target_arch = "aarch64"))
952 {
953 let s = got.expect("supported host should yield a triple");
954 assert!(known.contains(&s), "unexpected triple: {}", s);
955 }
956 }
957
958 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
959 #[test]
960 fn host_triple_is_linux_x86_64_on_this_box() {
961 assert_eq!(host_triple(), Some("linux-x86_64"));
964 }
965
966 fn manifest_with_setup(setup: Option<&str>) -> PluginManifest {
967 PluginManifest {
968 name: "test-plugin".to_string(),
969 version: None,
970 description: None,
971 keybinds: vec![],
972 compatibility: None,
973 commands: vec![],
974 extension: None,
975 help_entries: vec![],
976 provides: Some(PluginProvides {
977 sidecar: Some(SidecarManifest {
978 command: "bin/sidecar".to_string(),
979 setup: setup.map(|s| s.to_string()),
980 protocol_version: 1,
981 model: None,
982 lifecycle: None,
983 }),
984 }),
985 settings: None,
986 }
987 }
988
989 fn manifest_with_extension_setup(setup: Option<&str>) -> PluginManifest {
991 PluginManifest {
992 name: "test-plugin".to_string(),
993 version: None,
994 description: None,
995 keybinds: vec![],
996 compatibility: None,
997 commands: vec![],
998 extension: Some(ExtensionManifest {
999 theme_tokens: Default::default(),
1000 protocol_version: 1,
1001 runtime: ExtensionRuntime::Process,
1002 command: "bin/ext".to_string(),
1003 setup: setup.map(|s| s.to_string()),
1004 prebuilt: ::std::collections::HashMap::new(),
1005 args: vec![],
1006 permissions: vec![],
1007 hooks: vec![],
1008 config: vec![],
1009 }),
1010 help_entries: vec![],
1011 provides: None,
1012 settings: None,
1013 }
1014 }
1015
1016 fn manifest_with_both_setup(ext_setup: &str, side_setup: &str) -> PluginManifest {
1019 let mut m = manifest_with_extension_setup(Some(ext_setup));
1020 m.provides = Some(PluginProvides {
1021 sidecar: Some(SidecarManifest {
1022 command: "bin/sidecar".to_string(),
1023 setup: Some(side_setup.to_string()),
1024 protocol_version: 1,
1025 model: None,
1026 lifecycle: None,
1027 }),
1028 });
1029 m
1030 }
1031
1032 #[test]
1033 fn resolve_returns_none_when_no_setup_declared() {
1034 let m = manifest_with_setup(None);
1035 let dir = tempfile::tempdir().unwrap();
1036 let res = resolve_setup_script(&m, dir.path()).unwrap();
1037 assert!(res.is_none());
1038 }
1039
1040 #[test]
1041 fn resolve_returns_none_when_no_provides() {
1042 let mut m = manifest_with_setup(None);
1043 m.provides = None;
1044 let dir = tempfile::tempdir().unwrap();
1045 assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1046 }
1047
1048 #[test]
1049 fn resolve_resolves_relative_path_inside_plugin_dir() {
1050 let dir = tempfile::tempdir().unwrap();
1051 let scripts = dir.path().join("scripts");
1052 fs::create_dir(&scripts).unwrap();
1053 fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
1054 let m = manifest_with_setup(Some("scripts/setup.sh"));
1055 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1056 assert!(resolved.ends_with("scripts/setup.sh"));
1057 assert!(resolved.is_absolute());
1058 }
1059
1060 #[test]
1061 fn resolve_rejects_absolute_path() {
1062 let dir = tempfile::tempdir().unwrap();
1063 let m = manifest_with_setup(Some("/etc/passwd"));
1064 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1065 assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1066 }
1067
1068 #[test]
1069 fn resolve_rejects_parent_dir_traversal() {
1070 let dir = tempfile::tempdir().unwrap();
1071 let m = manifest_with_setup(Some("../escape.sh"));
1072 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1073 assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1074 }
1075
1076 #[test]
1077 fn resolve_rejects_embedded_parent_dir() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let m = manifest_with_setup(Some("scripts/../../etc/passwd"));
1080 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1081 assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1082 }
1083
1084 #[test]
1085 fn resolve_returns_not_found_when_script_missing() {
1086 let dir = tempfile::tempdir().unwrap();
1087 let m = manifest_with_setup(Some("scripts/missing.sh"));
1088 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1089 assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
1090 }
1091
1092 #[test]
1093 fn resolve_rejects_symlink_pointing_outside_plugin_dir() {
1094 let outer = tempfile::tempdir().unwrap();
1097 let dir = tempfile::tempdir().unwrap();
1098 let target = outer.path().join("escape.sh");
1099 fs::write(&target, "#!/bin/bash").unwrap();
1100 let scripts = dir.path().join("scripts");
1101 fs::create_dir(&scripts).unwrap();
1102 let link = scripts.join("setup.sh");
1103 std::os::unix::fs::symlink(&target, &link).unwrap();
1104 let m = manifest_with_setup(Some("scripts/setup.sh"));
1105 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1106 assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1107 }
1108
1109 #[test]
1110 fn install_log_path_substitutes_colons() {
1111 let path = install_log_path(
1112 Path::new("/tmp/logs"),
1113 "sample-sidecar",
1114 "2026-05-02T19:30:45-04:00",
1115 );
1116 assert_eq!(
1117 path,
1118 PathBuf::from("/tmp/logs/install/sample-sidecar-2026-05-02T19-30-45-04-00.log")
1119 );
1120 }
1121
1122 #[tokio::test]
1123 async fn run_setup_succeeds_for_simple_script() {
1124 let dir = tempfile::tempdir().unwrap();
1125 let scripts = dir.path().join("scripts");
1126 fs::create_dir(&scripts).unwrap();
1127 let script = scripts.join("setup.sh");
1128 fs::write(
1129 &script,
1130 "#!/bin/bash\necho hello-from-setup\necho 'on stderr' >&2\n",
1131 )
1132 .unwrap();
1133 use std::os::unix::fs::PermissionsExt;
1134 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1135 let log = dir.path().join("install.log");
1136 let outcome = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1137 .await
1138 .unwrap();
1139 assert_eq!(outcome.exit_code, 0);
1140 assert_eq!(outcome.log_path, log);
1141 let captured = fs::read_to_string(&log).unwrap();
1142 assert!(captured.contains("hello-from-setup"));
1143 assert!(captured.contains("on stderr"));
1144 }
1145
1146 #[tokio::test]
1147 async fn run_setup_returns_non_zero_exit() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let script = dir.path().join("fail.sh");
1150 fs::write(&script, "#!/bin/bash\necho boom\nexit 7\n").unwrap();
1151 let log = dir.path().join("install.log");
1152 let err = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1153 .await
1154 .unwrap_err();
1155 match err {
1156 SetupError::NonZeroExit { exit_code, log_path } => {
1157 assert_eq!(exit_code, 7);
1158 assert_eq!(log_path, log);
1159 let captured = fs::read_to_string(&log).unwrap();
1160 assert!(captured.contains("boom"));
1161 }
1162 other => panic!("expected NonZeroExit, got {other:?}"),
1163 }
1164 }
1165
1166 #[tokio::test]
1167 async fn run_setup_times_out() {
1168 let dir = tempfile::tempdir().unwrap();
1169 let script = dir.path().join("loop.sh");
1170 fs::write(&script, "#!/bin/bash\nsleep 5\n").unwrap();
1171 let log = dir.path().join("install.log");
1172 let err = run_setup_script(&script, dir.path(), &log, Duration::from_millis(200))
1173 .await
1174 .unwrap_err();
1175 assert!(matches!(err, SetupError::Timeout { .. }), "got {err:?}");
1176 }
1177
1178 #[test]
1181 fn resolve_resolves_extension_setup_path() {
1182 let dir = tempfile::tempdir().unwrap();
1183 let scripts = dir.path().join("scripts");
1184 fs::create_dir(&scripts).unwrap();
1185 fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
1186 let m = manifest_with_extension_setup(Some("scripts/setup.sh"));
1187 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1188 assert!(resolved.ends_with("scripts/setup.sh"));
1189 assert!(resolved.is_absolute());
1190 }
1191
1192 #[test]
1193 fn resolve_returns_none_when_extension_has_no_setup() {
1194 let m = manifest_with_extension_setup(None);
1195 let dir = tempfile::tempdir().unwrap();
1196 assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1197 }
1198
1199 #[test]
1200 fn resolve_rejects_extension_setup_with_parent_dir() {
1201 let dir = tempfile::tempdir().unwrap();
1202 let m = manifest_with_extension_setup(Some("../escape.sh"));
1203 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1204 assert!(
1205 matches!(err, SetupError::EscapesPluginDir { .. }),
1206 "got {err:?}"
1207 );
1208 }
1209
1210 #[test]
1211 fn resolve_rejects_extension_setup_when_absolute() {
1212 let dir = tempfile::tempdir().unwrap();
1213 let m = manifest_with_extension_setup(Some("/etc/passwd"));
1214 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1215 assert!(
1216 matches!(err, SetupError::EscapesPluginDir { .. }),
1217 "got {err:?}"
1218 );
1219 }
1220
1221 #[test]
1222 fn resolve_returns_not_found_for_missing_extension_setup() {
1223 let dir = tempfile::tempdir().unwrap();
1224 let m = manifest_with_extension_setup(Some("scripts/missing.sh"));
1225 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1226 assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
1227 }
1228
1229 #[test]
1230 fn resolve_prefers_extension_setup_over_sidecar_setup() {
1231 let dir = tempfile::tempdir().unwrap();
1235 let scripts = dir.path().join("scripts");
1236 fs::create_dir(&scripts).unwrap();
1237 fs::write(scripts.join("ext.sh"), "#!/bin/bash\necho ext").unwrap();
1238 fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
1239 let m = manifest_with_both_setup("scripts/ext.sh", "scripts/side.sh");
1240 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1241 assert!(
1242 resolved.ends_with("scripts/ext.sh"),
1243 "expected extension setup to win, got {resolved:?}"
1244 );
1245 }
1246
1247 #[test]
1248 fn resolve_falls_back_to_sidecar_when_extension_has_no_setup() {
1249 let dir = tempfile::tempdir().unwrap();
1253 let scripts = dir.path().join("scripts");
1254 fs::create_dir(&scripts).unwrap();
1255 fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
1256 let mut m = manifest_with_extension_setup(None); m.provides = Some(PluginProvides {
1258 sidecar: Some(SidecarManifest {
1259 command: "bin/sidecar".to_string(),
1260 setup: Some("scripts/side.sh".to_string()),
1261 protocol_version: 1,
1262 model: None,
1263 lifecycle: None,
1264 }),
1265 });
1266 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1267 assert!(resolved.ends_with("scripts/side.sh"));
1268 }
1269
1270 #[test]
1271 fn resolve_returns_none_when_neither_slot_has_setup() {
1272 let dir = tempfile::tempdir().unwrap();
1275 let mut m = manifest_with_extension_setup(None);
1276 m.provides = Some(PluginProvides {
1277 sidecar: Some(SidecarManifest {
1278 command: "bin/sidecar".to_string(),
1279 setup: None,
1280 protocol_version: 1,
1281 model: None,
1282 lifecycle: None,
1283 }),
1284 });
1285 assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1286 }
1287
1288 fn manifest_with_extension_command(command: &str) -> PluginManifest {
1292 PluginManifest {
1293 name: "test-plugin".to_string(),
1294 version: None,
1295 description: None,
1296 keybinds: vec![],
1297 compatibility: None,
1298 commands: vec![],
1299 extension: Some(ExtensionManifest {
1300 theme_tokens: Default::default(),
1301 protocol_version: 1,
1302 runtime: ExtensionRuntime::Process,
1303 command: command.to_string(),
1304 setup: None,
1305 prebuilt: ::std::collections::HashMap::new(),
1306 args: vec![],
1307 permissions: vec![],
1308 hooks: vec![],
1309 config: vec![],
1310 }),
1311 help_entries: vec![],
1312 provides: None,
1313 settings: None,
1314 }
1315 }
1316
1317 #[test]
1318 fn verify_returns_ok_none_when_no_extension() {
1319 let dir = tempfile::tempdir().unwrap();
1320 let m = manifest_with_setup(None); assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1322 }
1323
1324 #[test]
1325 fn verify_returns_ok_none_for_bare_command_name() {
1326 let dir = tempfile::tempdir().unwrap();
1328 let m = manifest_with_extension_command("python3");
1329 assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1330 }
1331
1332 #[test]
1333 fn verify_succeeds_when_relative_binary_exists_and_is_executable() {
1334 let dir = tempfile::tempdir().unwrap();
1335 let bin = dir.path().join("bin/ext");
1336 fs::create_dir_all(bin.parent().unwrap()).unwrap();
1337 fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
1338 #[cfg(unix)]
1339 {
1340 use std::os::unix::fs::PermissionsExt;
1341 fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
1342 }
1343 let m = manifest_with_extension_command("bin/ext");
1344 let resolved = verify_extension_command(&m, dir.path()).unwrap();
1345 assert!(resolved.is_some(), "should return resolved path");
1346 }
1347
1348 #[test]
1349 fn verify_returns_missing_when_binary_absent() {
1350 let dir = tempfile::tempdir().unwrap();
1351 let m = manifest_with_extension_command("bin/ext");
1352 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1353 assert!(matches!(err, CommandVerifyError::Missing { .. }), "got: {err:?}");
1354 }
1355
1356 #[cfg(unix)]
1357 #[test]
1358 fn verify_returns_not_executable_when_bit_missing() {
1359 let dir = tempfile::tempdir().unwrap();
1360 let bin = dir.path().join("bin/ext");
1361 fs::create_dir_all(bin.parent().unwrap()).unwrap();
1362 fs::write(&bin, "data").unwrap();
1363 use std::os::unix::fs::PermissionsExt;
1364 fs::set_permissions(&bin, fs::Permissions::from_mode(0o644)).unwrap();
1365 let m = manifest_with_extension_command("bin/ext");
1366 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1367 assert!(matches!(err, CommandVerifyError::NotExecutable { .. }), "got: {err:?}");
1368 }
1369
1370 #[test]
1371 fn verify_returns_not_a_file_when_path_is_directory() {
1372 let dir = tempfile::tempdir().unwrap();
1373 let bin = dir.path().join("bin/ext");
1374 fs::create_dir_all(&bin).unwrap();
1375 let m = manifest_with_extension_command("bin/ext");
1376 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1377 assert!(matches!(err, CommandVerifyError::NotAFile { .. }), "got: {err:?}");
1378 }
1379
1380 #[test]
1381 fn verify_rejects_parent_dir_traversal_in_command() {
1382 let dir = tempfile::tempdir().unwrap();
1383 let m = manifest_with_extension_command("../escape/bin");
1384 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1385 assert!(matches!(err, CommandVerifyError::EscapesPluginDir { .. }), "got: {err:?}");
1386 }
1387
1388 #[cfg(unix)]
1389 #[test]
1390 fn verify_rejects_symlink_pointing_outside_plugin_dir() {
1391 let outer = tempfile::tempdir().unwrap();
1392 let plugin = tempfile::tempdir().unwrap();
1393 let target = outer.path().join("real-bin");
1395 fs::write(&target, "x").unwrap();
1396 use std::os::unix::fs::PermissionsExt;
1397 fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
1398 let link = plugin.path().join("bin/ext");
1400 fs::create_dir_all(link.parent().unwrap()).unwrap();
1401 std::os::unix::fs::symlink(&target, &link).unwrap();
1402 let m = manifest_with_extension_command("bin/ext");
1403 let err = verify_extension_command(&m, plugin.path()).unwrap_err();
1404 assert!(
1405 matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
1406 "got: {err:?}"
1407 );
1408 }
1409
1410
1411 #[test]
1412 fn verify_rejects_absolute_extension_command() {
1413 let dir = tempfile::tempdir().unwrap();
1414 let m = manifest_with_extension_command("/tmp/ext");
1415 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1416 assert!(matches!(err, CommandVerifyError::EscapesPluginDir { .. }), "got: {err:?}");
1417 }
1418
1419 #[test]
1420 fn policy_normalizes_sha256_and_sanitizes_names() {
1421 assert_eq!(normalize_sha256(&"A".repeat(64)).unwrap(), "a".repeat(64));
1422 assert!(normalize_sha256("not-a-sha").is_none());
1423 assert_eq!(safe_name_fragment("../bad\nname"), "bad_name");
1424 assert_eq!(safe_name_fragment("normal-name_1.2"), "normal-name_1.2");
1425 }
1426
1427 fn manifest_with_prebuilt(
1430 command: &str,
1431 triple: &str,
1432 url: &str,
1433 sha256: &str,
1434 ) -> PluginManifest {
1435 let mut prebuilt = std::collections::HashMap::new();
1436 prebuilt.insert(
1437 triple.to_string(),
1438 crate::extensions::manifest::PrebuiltAsset {
1439 url: url.to_string(),
1440 sha256: sha256.to_string(),
1441 },
1442 );
1443 PluginManifest {
1444 name: "test-plugin".to_string(),
1445 version: None,
1446 description: None,
1447 keybinds: vec![],
1448 compatibility: None,
1449 commands: vec![],
1450 extension: Some(ExtensionManifest {
1451 theme_tokens: Default::default(),
1452 protocol_version: 1,
1453 runtime: ExtensionRuntime::Process,
1454 command: command.to_string(),
1455 setup: None,
1456 prebuilt,
1457 args: vec![],
1458 permissions: vec![],
1459 hooks: vec![],
1460 config: vec![],
1461 }),
1462 help_entries: vec![],
1463 provides: None,
1464 settings: None,
1465 }
1466 }
1467
1468 fn mk_tarball(staging: &Path, archive_name: &str, inner_path: &str) -> (PathBuf, String) {
1472 let work = staging.join("staging");
1473 fs::create_dir_all(&work).unwrap();
1474 let payload = work.join(inner_path);
1475 fs::create_dir_all(payload.parent().unwrap()).unwrap();
1476 fs::write(&payload, "#!/bin/sh\necho prebuilt-bin\n").unwrap();
1477 #[cfg(unix)]
1478 {
1479 use std::os::unix::fs::PermissionsExt;
1480 fs::set_permissions(&payload, fs::Permissions::from_mode(0o755)).unwrap();
1481 }
1482 let archive = staging.join(archive_name);
1483 let out = std::process::Command::new("tar")
1484 .arg("-czf")
1485 .arg(&archive)
1486 .arg("-C")
1487 .arg(&work)
1488 .arg(inner_path)
1489 .output()
1490 .expect("system tar must be present");
1491 assert!(out.status.success(), "tar failed: {:?}", out);
1492 let bytes = fs::read(&archive).unwrap();
1493 use sha2::{Digest, Sha256};
1494 let mut h = Sha256::new();
1495 h.update(&bytes);
1496 let sha = hex_encode_lower(&h.finalize());
1497 (archive, sha)
1498 }
1499
1500 #[tokio::test(flavor = "current_thread")]
1501 async fn prebuilt_returns_no_matching_asset_when_triple_missing() {
1502 let dir = tempfile::tempdir().unwrap();
1503 let m = manifest_with_prebuilt("bin/ext", "fake-triple-9999", "https://x", "00");
1505 let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1506 assert!(matches!(err, PrebuiltError::NoMatchingAsset), "got: {err:?}");
1507 }
1508
1509 #[tokio::test(flavor = "current_thread")]
1510 async fn prebuilt_rejects_non_https_url_in_production_builds() {
1511 let dir = tempfile::tempdir().unwrap();
1513 let triple = host_triple().expect("supported host");
1514 let m = manifest_with_prebuilt("bin/ext", triple, "http://example.com/x.tar.gz", "00");
1515 let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1516 assert!(matches!(err, PrebuiltError::UnsafeUrl { .. }), "got: {err:?}");
1517 }
1518
1519 #[tokio::test(flavor = "current_thread")]
1520 async fn prebuilt_succeeds_with_valid_tarball_and_checksum() {
1521 let staging = tempfile::tempdir().unwrap();
1522 let plugin = tempfile::tempdir().unwrap();
1523 let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1524 let url = format!("file://{}", archive.display());
1525 let triple = host_triple().expect("supported host");
1526 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1527 let resolved = try_install_from_prebuilt(&m, plugin.path()).await.unwrap();
1528 assert!(resolved.exists(), "extracted binary should exist at {}", resolved.display());
1529 let leftover = plugin.path().join(format!(".prebuilt-{}-download", triple));
1531 assert!(!leftover.exists(), "temp archive should be removed");
1532 }
1533
1534 #[tokio::test(flavor = "current_thread")]
1535 async fn prebuilt_aborts_on_checksum_mismatch_without_extracting() {
1536 let staging = tempfile::tempdir().unwrap();
1537 let plugin = tempfile::tempdir().unwrap();
1538 let (archive, _real_sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1539 let url = format!("file://{}", archive.display());
1540 let triple = host_triple().expect("supported host");
1541 let bad_sha = "0".repeat(64);
1542 let m = manifest_with_prebuilt("bin/ext", triple, &url, &bad_sha);
1543 let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
1544 match err {
1545 PrebuiltError::ChecksumMismatch { expected, actual } => {
1546 assert_eq!(expected, bad_sha);
1547 assert_eq!(actual.len(), 64, "actual sha should be lowercase hex");
1548 }
1549 other => panic!("expected ChecksumMismatch, got {other:?}"),
1550 }
1551 assert!(!plugin.path().join("bin/ext").exists());
1553 }
1554
1555 #[tokio::test(flavor = "current_thread")]
1556 async fn prebuilt_rejects_unsupported_archive_suffix() {
1557 let staging = tempfile::tempdir().unwrap();
1558 let plugin = tempfile::tempdir().unwrap();
1559 let archive = staging.path().join("ext.rar");
1561 fs::write(&archive, b"not really a rar").unwrap();
1562 let bytes = fs::read(&archive).unwrap();
1563 use sha2::{Digest, Sha256};
1564 let mut h = Sha256::new();
1565 h.update(&bytes);
1566 let sha = hex_encode_lower(&h.finalize());
1567 let url = format!("file://{}", archive.display());
1568 let triple = host_triple().expect("supported host");
1569 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1570 let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
1571 assert!(matches!(err, PrebuiltError::UnsupportedArchive { .. }), "got: {err:?}");
1572 }
1573
1574 #[tokio::test(flavor = "current_thread")]
1575 async fn prebuilt_fails_verify_when_archive_does_not_contain_declared_command() {
1576 let staging = tempfile::tempdir().unwrap();
1577 let plugin = tempfile::tempdir().unwrap();
1578 let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/wrong-name");
1580 let url = format!("file://{}", archive.display());
1581 let triple = host_triple().expect("supported host");
1582 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1583 let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
1584 assert!(matches!(err, PrebuiltError::Verify(_)), "got: {err:?}");
1585 }
1586}