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