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 #[test]
1121 fn resolve_rejects_symlink_pointing_outside_plugin_dir() {
1122 let outer = tempfile::tempdir().unwrap();
1125 let dir = tempfile::tempdir().unwrap();
1126 let target = outer.path().join("escape.sh");
1127 fs::write(&target, "#!/bin/bash").unwrap();
1128 let scripts = dir.path().join("scripts");
1129 fs::create_dir(&scripts).unwrap();
1130 let link = scripts.join("setup.sh");
1131 std::os::unix::fs::symlink(&target, &link).unwrap();
1132 let m = manifest_with_setup(Some("scripts/setup.sh"));
1133 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1134 assert!(
1135 matches!(err, SetupError::EscapesPluginDir { .. }),
1136 "got {err:?}"
1137 );
1138 }
1139
1140 #[test]
1141 fn install_log_path_substitutes_colons() {
1142 let path = install_log_path(
1143 Path::new("/tmp/logs"),
1144 "sample-sidecar",
1145 "2026-05-02T19:30:45-04:00",
1146 );
1147 assert_eq!(
1148 path,
1149 PathBuf::from("/tmp/logs/install/sample-sidecar-2026-05-02T19-30-45-04-00.log")
1150 );
1151 }
1152
1153 #[tokio::test]
1154 async fn run_setup_succeeds_for_simple_script() {
1155 let dir = tempfile::tempdir().unwrap();
1156 let scripts = dir.path().join("scripts");
1157 fs::create_dir(&scripts).unwrap();
1158 let script = scripts.join("setup.sh");
1159 fs::write(
1160 &script,
1161 "#!/bin/bash\necho hello-from-setup\necho 'on stderr' >&2\n",
1162 )
1163 .unwrap();
1164 use std::os::unix::fs::PermissionsExt;
1165 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1166 let log = dir.path().join("install.log");
1167 let outcome = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1168 .await
1169 .unwrap();
1170 assert_eq!(outcome.exit_code, 0);
1171 assert_eq!(outcome.log_path, log);
1172 let captured = fs::read_to_string(&log).unwrap();
1173 assert!(captured.contains("hello-from-setup"));
1174 assert!(captured.contains("on stderr"));
1175 }
1176
1177 #[tokio::test]
1178 async fn run_setup_returns_non_zero_exit() {
1179 let dir = tempfile::tempdir().unwrap();
1180 let script = dir.path().join("fail.sh");
1181 fs::write(&script, "#!/bin/bash\necho boom\nexit 7\n").unwrap();
1182 let log = dir.path().join("install.log");
1183 let err = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1184 .await
1185 .unwrap_err();
1186 match err {
1187 SetupError::NonZeroExit {
1188 exit_code,
1189 log_path,
1190 } => {
1191 assert_eq!(exit_code, 7);
1192 assert_eq!(log_path, log);
1193 let captured = fs::read_to_string(&log).unwrap();
1194 assert!(captured.contains("boom"));
1195 }
1196 other => panic!("expected NonZeroExit, got {other:?}"),
1197 }
1198 }
1199
1200 #[tokio::test]
1201 async fn run_setup_times_out() {
1202 let dir = tempfile::tempdir().unwrap();
1203 let script = dir.path().join("loop.sh");
1204 fs::write(&script, "#!/bin/bash\nsleep 5\n").unwrap();
1205 let log = dir.path().join("install.log");
1206 let err = run_setup_script(&script, dir.path(), &log, Duration::from_millis(200))
1207 .await
1208 .unwrap_err();
1209 assert!(matches!(err, SetupError::Timeout { .. }), "got {err:?}");
1210 }
1211
1212 #[test]
1215 fn resolve_resolves_extension_setup_path() {
1216 let dir = tempfile::tempdir().unwrap();
1217 let scripts = dir.path().join("scripts");
1218 fs::create_dir(&scripts).unwrap();
1219 fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
1220 let m = manifest_with_extension_setup(Some("scripts/setup.sh"));
1221 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1222 assert!(resolved.ends_with("scripts/setup.sh"));
1223 assert!(resolved.is_absolute());
1224 }
1225
1226 #[test]
1227 fn resolve_returns_none_when_extension_has_no_setup() {
1228 let m = manifest_with_extension_setup(None);
1229 let dir = tempfile::tempdir().unwrap();
1230 assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1231 }
1232
1233 #[test]
1234 fn resolve_rejects_extension_setup_with_parent_dir() {
1235 let dir = tempfile::tempdir().unwrap();
1236 let m = manifest_with_extension_setup(Some("../escape.sh"));
1237 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1238 assert!(
1239 matches!(err, SetupError::EscapesPluginDir { .. }),
1240 "got {err:?}"
1241 );
1242 }
1243
1244 #[test]
1245 fn resolve_rejects_extension_setup_when_absolute() {
1246 let dir = tempfile::tempdir().unwrap();
1247 let m = manifest_with_extension_setup(Some("/etc/passwd"));
1248 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1249 assert!(
1250 matches!(err, SetupError::EscapesPluginDir { .. }),
1251 "got {err:?}"
1252 );
1253 }
1254
1255 #[test]
1256 fn resolve_returns_not_found_for_missing_extension_setup() {
1257 let dir = tempfile::tempdir().unwrap();
1258 let m = manifest_with_extension_setup(Some("scripts/missing.sh"));
1259 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1260 assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
1261 }
1262
1263 #[test]
1264 fn resolve_prefers_extension_setup_over_sidecar_setup() {
1265 let dir = tempfile::tempdir().unwrap();
1269 let scripts = dir.path().join("scripts");
1270 fs::create_dir(&scripts).unwrap();
1271 fs::write(scripts.join("ext.sh"), "#!/bin/bash\necho ext").unwrap();
1272 fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
1273 let m = manifest_with_both_setup("scripts/ext.sh", "scripts/side.sh");
1274 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1275 assert!(
1276 resolved.ends_with("scripts/ext.sh"),
1277 "expected extension setup to win, got {resolved:?}"
1278 );
1279 }
1280
1281 #[test]
1282 fn resolve_falls_back_to_sidecar_when_extension_has_no_setup() {
1283 let dir = tempfile::tempdir().unwrap();
1287 let scripts = dir.path().join("scripts");
1288 fs::create_dir(&scripts).unwrap();
1289 fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
1290 let mut m = manifest_with_extension_setup(None); m.provides = Some(PluginProvides {
1292 sidecar: Some(SidecarManifest {
1293 command: "bin/sidecar".to_string(),
1294 setup: Some("scripts/side.sh".to_string()),
1295 protocol_version: 1,
1296 model: None,
1297 lifecycle: None,
1298 }),
1299 });
1300 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1301 assert!(resolved.ends_with("scripts/side.sh"));
1302 }
1303
1304 #[test]
1305 fn resolve_returns_none_when_neither_slot_has_setup() {
1306 let dir = tempfile::tempdir().unwrap();
1309 let mut m = manifest_with_extension_setup(None);
1310 m.provides = Some(PluginProvides {
1311 sidecar: Some(SidecarManifest {
1312 command: "bin/sidecar".to_string(),
1313 setup: None,
1314 protocol_version: 1,
1315 model: None,
1316 lifecycle: None,
1317 }),
1318 });
1319 assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1320 }
1321
1322 fn manifest_with_extension_command(command: &str) -> PluginManifest {
1326 PluginManifest {
1327 name: "test-plugin".to_string(),
1328 version: None,
1329 description: None,
1330 keybinds: vec![],
1331 compatibility: None,
1332 commands: vec![],
1333 extension: Some(ExtensionManifest {
1334 theme_tokens: Default::default(),
1335 deferred: None,
1336 protocol_version: 1,
1337 runtime: ExtensionRuntime::Process,
1338 command: command.to_string(),
1339 setup: None,
1340 prebuilt: ::std::collections::HashMap::new(),
1341 args: vec![],
1342 permissions: vec![],
1343 hooks: vec![],
1344 config: vec![],
1345 }),
1346 help_entries: vec![],
1347 provides: None,
1348 settings: None,
1349 }
1350 }
1351
1352 #[test]
1353 fn verify_returns_ok_none_when_no_extension() {
1354 let dir = tempfile::tempdir().unwrap();
1355 let m = manifest_with_setup(None); assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1357 }
1358
1359 #[test]
1360 fn verify_returns_ok_none_for_bare_command_name() {
1361 let dir = tempfile::tempdir().unwrap();
1363 let m = manifest_with_extension_command("python3");
1364 assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1365 }
1366
1367 #[test]
1368 fn verify_succeeds_when_relative_binary_exists_and_is_executable() {
1369 let dir = tempfile::tempdir().unwrap();
1370 let bin = dir.path().join("bin/ext");
1371 fs::create_dir_all(bin.parent().unwrap()).unwrap();
1372 fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
1373 #[cfg(unix)]
1374 {
1375 use std::os::unix::fs::PermissionsExt;
1376 fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
1377 }
1378 let m = manifest_with_extension_command("bin/ext");
1379 let resolved = verify_extension_command(&m, dir.path()).unwrap();
1380 assert!(resolved.is_some(), "should return resolved path");
1381 }
1382
1383 #[test]
1384 fn verify_returns_missing_when_binary_absent() {
1385 let dir = tempfile::tempdir().unwrap();
1386 let m = manifest_with_extension_command("bin/ext");
1387 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1388 assert!(
1389 matches!(err, CommandVerifyError::Missing { .. }),
1390 "got: {err:?}"
1391 );
1392 }
1393
1394 #[cfg(unix)]
1395 #[test]
1396 fn verify_returns_not_executable_when_bit_missing() {
1397 let dir = tempfile::tempdir().unwrap();
1398 let bin = dir.path().join("bin/ext");
1399 fs::create_dir_all(bin.parent().unwrap()).unwrap();
1400 fs::write(&bin, "data").unwrap();
1401 use std::os::unix::fs::PermissionsExt;
1402 fs::set_permissions(&bin, fs::Permissions::from_mode(0o644)).unwrap();
1403 let m = manifest_with_extension_command("bin/ext");
1404 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1405 assert!(
1406 matches!(err, CommandVerifyError::NotExecutable { .. }),
1407 "got: {err:?}"
1408 );
1409 }
1410
1411 #[test]
1412 fn verify_returns_not_a_file_when_path_is_directory() {
1413 let dir = tempfile::tempdir().unwrap();
1414 let bin = dir.path().join("bin/ext");
1415 fs::create_dir_all(&bin).unwrap();
1416 let m = manifest_with_extension_command("bin/ext");
1417 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1418 assert!(
1419 matches!(err, CommandVerifyError::NotAFile { .. }),
1420 "got: {err:?}"
1421 );
1422 }
1423
1424 #[test]
1425 fn verify_rejects_parent_dir_traversal_in_command() {
1426 let dir = tempfile::tempdir().unwrap();
1427 let m = manifest_with_extension_command("../escape/bin");
1428 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1429 assert!(
1430 matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
1431 "got: {err:?}"
1432 );
1433 }
1434
1435 #[cfg(unix)]
1436 #[test]
1437 fn verify_rejects_symlink_pointing_outside_plugin_dir() {
1438 let outer = tempfile::tempdir().unwrap();
1439 let plugin = tempfile::tempdir().unwrap();
1440 let target = outer.path().join("real-bin");
1442 fs::write(&target, "x").unwrap();
1443 use std::os::unix::fs::PermissionsExt;
1444 fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
1445 let link = plugin.path().join("bin/ext");
1447 fs::create_dir_all(link.parent().unwrap()).unwrap();
1448 std::os::unix::fs::symlink(&target, &link).unwrap();
1449 let m = manifest_with_extension_command("bin/ext");
1450 let err = verify_extension_command(&m, plugin.path()).unwrap_err();
1451 assert!(
1452 matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
1453 "got: {err:?}"
1454 );
1455 }
1456
1457 #[test]
1458 fn verify_rejects_absolute_extension_command() {
1459 let dir = tempfile::tempdir().unwrap();
1460 let m = manifest_with_extension_command("/tmp/ext");
1461 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1462 assert!(
1463 matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
1464 "got: {err:?}"
1465 );
1466 }
1467
1468 #[test]
1469 fn policy_normalizes_sha256_and_sanitizes_names() {
1470 assert_eq!(normalize_sha256(&"A".repeat(64)).unwrap(), "a".repeat(64));
1471 assert!(normalize_sha256("not-a-sha").is_none());
1472 assert_eq!(safe_name_fragment("../bad\nname"), "bad_name");
1473 assert_eq!(safe_name_fragment("normal-name_1.2"), "normal-name_1.2");
1474 }
1475
1476 fn manifest_with_prebuilt(
1479 command: &str,
1480 triple: &str,
1481 url: &str,
1482 sha256: &str,
1483 ) -> PluginManifest {
1484 let mut prebuilt = std::collections::HashMap::new();
1485 prebuilt.insert(
1486 triple.to_string(),
1487 crate::extensions::manifest::PrebuiltAsset {
1488 url: url.to_string(),
1489 sha256: sha256.to_string(),
1490 },
1491 );
1492 PluginManifest {
1493 name: "test-plugin".to_string(),
1494 version: None,
1495 description: None,
1496 keybinds: vec![],
1497 compatibility: None,
1498 commands: vec![],
1499 extension: Some(ExtensionManifest {
1500 theme_tokens: Default::default(),
1501 deferred: None,
1502 protocol_version: 1,
1503 runtime: ExtensionRuntime::Process,
1504 command: command.to_string(),
1505 setup: None,
1506 prebuilt,
1507 args: vec![],
1508 permissions: vec![],
1509 hooks: vec![],
1510 config: vec![],
1511 }),
1512 help_entries: vec![],
1513 provides: None,
1514 settings: None,
1515 }
1516 }
1517
1518 fn mk_tarball(staging: &Path, archive_name: &str, inner_path: &str) -> (PathBuf, String) {
1522 let work = staging.join("staging");
1523 fs::create_dir_all(&work).unwrap();
1524 let payload = work.join(inner_path);
1525 fs::create_dir_all(payload.parent().unwrap()).unwrap();
1526 fs::write(&payload, "#!/bin/sh\necho prebuilt-bin\n").unwrap();
1527 #[cfg(unix)]
1528 {
1529 use std::os::unix::fs::PermissionsExt;
1530 fs::set_permissions(&payload, fs::Permissions::from_mode(0o755)).unwrap();
1531 }
1532 let archive = staging.join(archive_name);
1533 let out = std::process::Command::new("tar")
1534 .arg("-czf")
1535 .arg(&archive)
1536 .arg("-C")
1537 .arg(&work)
1538 .arg(inner_path)
1539 .output()
1540 .expect("system tar must be present");
1541 assert!(out.status.success(), "tar failed: {:?}", out);
1542 let bytes = fs::read(&archive).unwrap();
1543 use sha2::{Digest, Sha256};
1544 let mut h = Sha256::new();
1545 h.update(&bytes);
1546 let sha = hex_encode_lower(&h.finalize());
1547 (archive, sha)
1548 }
1549
1550 #[tokio::test(flavor = "current_thread")]
1551 async fn prebuilt_returns_no_matching_asset_when_triple_missing() {
1552 let dir = tempfile::tempdir().unwrap();
1553 let m = manifest_with_prebuilt("bin/ext", "fake-triple-9999", "https://x", "00");
1555 let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1556 assert!(
1557 matches!(err, PrebuiltError::NoMatchingAsset),
1558 "got: {err:?}"
1559 );
1560 }
1561
1562 #[tokio::test(flavor = "current_thread")]
1563 async fn prebuilt_rejects_non_https_url_in_production_builds() {
1564 let dir = tempfile::tempdir().unwrap();
1566 let triple = host_triple().expect("supported host");
1567 let m = manifest_with_prebuilt("bin/ext", triple, "http://example.com/x.tar.gz", "00");
1568 let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1569 assert!(
1570 matches!(err, PrebuiltError::UnsafeUrl { .. }),
1571 "got: {err:?}"
1572 );
1573 }
1574
1575 #[tokio::test(flavor = "current_thread")]
1576 async fn prebuilt_succeeds_with_valid_tarball_and_checksum() {
1577 let staging = tempfile::tempdir().unwrap();
1578 let plugin = tempfile::tempdir().unwrap();
1579 let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1580 let url = format!("file://{}", archive.display());
1581 let triple = host_triple().expect("supported host");
1582 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1583 let resolved = try_install_from_prebuilt(&m, plugin.path()).await.unwrap();
1584 assert!(
1585 resolved.exists(),
1586 "extracted binary should exist at {}",
1587 resolved.display()
1588 );
1589 let leftover = plugin.path().join(format!(".prebuilt-{}-download", triple));
1591 assert!(!leftover.exists(), "temp archive should be removed");
1592 }
1593
1594 #[tokio::test(flavor = "current_thread")]
1595 async fn prebuilt_aborts_on_checksum_mismatch_without_extracting() {
1596 let staging = tempfile::tempdir().unwrap();
1597 let plugin = tempfile::tempdir().unwrap();
1598 let (archive, _real_sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1599 let url = format!("file://{}", archive.display());
1600 let triple = host_triple().expect("supported host");
1601 let bad_sha = "0".repeat(64);
1602 let m = manifest_with_prebuilt("bin/ext", triple, &url, &bad_sha);
1603 let err = try_install_from_prebuilt(&m, plugin.path())
1604 .await
1605 .unwrap_err();
1606 match err {
1607 PrebuiltError::ChecksumMismatch { expected, actual } => {
1608 assert_eq!(expected, bad_sha);
1609 assert_eq!(actual.len(), 64, "actual sha should be lowercase hex");
1610 }
1611 other => panic!("expected ChecksumMismatch, got {other:?}"),
1612 }
1613 assert!(!plugin.path().join("bin/ext").exists());
1615 }
1616
1617 #[tokio::test(flavor = "current_thread")]
1618 async fn prebuilt_rejects_unsupported_archive_suffix() {
1619 let staging = tempfile::tempdir().unwrap();
1620 let plugin = tempfile::tempdir().unwrap();
1621 let archive = staging.path().join("ext.rar");
1623 fs::write(&archive, b"not really a rar").unwrap();
1624 let bytes = fs::read(&archive).unwrap();
1625 use sha2::{Digest, Sha256};
1626 let mut h = Sha256::new();
1627 h.update(&bytes);
1628 let sha = hex_encode_lower(&h.finalize());
1629 let url = format!("file://{}", archive.display());
1630 let triple = host_triple().expect("supported host");
1631 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1632 let err = try_install_from_prebuilt(&m, plugin.path())
1633 .await
1634 .unwrap_err();
1635 assert!(
1636 matches!(err, PrebuiltError::UnsupportedArchive { .. }),
1637 "got: {err:?}"
1638 );
1639 }
1640
1641 #[tokio::test(flavor = "current_thread")]
1642 async fn prebuilt_fails_verify_when_archive_does_not_contain_declared_command() {
1643 let staging = tempfile::tempdir().unwrap();
1644 let plugin = tempfile::tempdir().unwrap();
1645 let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/wrong-name");
1647 let url = format!("file://{}", archive.display());
1648 let triple = host_triple().expect("supported host");
1649 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1650 let err = try_install_from_prebuilt(&m, plugin.path())
1651 .await
1652 .unwrap_err();
1653 assert!(matches!(err, PrebuiltError::Verify(_)), "got: {err:?}");
1654 }
1655}