1use std::path::{Component, Path, PathBuf};
35use std::time::Duration;
36
37use crate::skills::manifest::PluginManifest;
38
39pub const MAX_PREBUILT_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024;
53
54const PREBUILT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
55const PREBUILT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
56
57pub fn safe_name_fragment(input: &str) -> String {
61 let mut out = String::with_capacity(input.len().min(80));
62 for ch in input.chars().take(80) {
63 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
64 out.push(ch);
65 } else {
66 out.push('_');
67 }
68 }
69 let trimmed = out.trim_matches('.').trim_matches('_').to_string();
70 if trimmed.is_empty() || trimmed == ".." {
71 "plugin".to_string()
72 } else {
73 trimmed
74 }
75}
76
77pub fn normalize_sha256(value: &str) -> Option<String> {
80 let trimmed = value.trim();
81 if trimmed.len() == 64 && trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
82 Some(trimmed.to_ascii_lowercase())
83 } else {
84 None
85 }
86}
87
88fn prebuilt_url_allowed(url: &str) -> bool {
89 if url.starts_with("https://") {
90 return true;
91 }
92 #[cfg(test)]
93 {
94 if url.starts_with("file://") {
95 return true;
96 }
97 }
98 false
99}
100
101fn archive_suffix(url_for_suffix: &str) -> Result<&'static str, PrebuiltError> {
102 let url_clean = url_for_suffix
103 .split(['?', '#'])
104 .next()
105 .unwrap_or(url_for_suffix)
106 .to_ascii_lowercase();
107 if url_clean.ends_with(".tar.gz") || url_clean.ends_with(".tgz") {
108 Ok("tar.gz")
109 } else if url_clean.ends_with(".zip") {
110 Ok("zip")
111 } else if url_clean.ends_with(".tar.xz") || url_clean.ends_with(".tar.bz2") {
112 Err(PrebuiltError::UnsupportedArchive {
113 url: format!("{url_for_suffix} (xz/bz2 prebuilt archives are not supported by the hardened extractor; use .tar.gz or .zip)"),
114 })
115 } else {
116 Err(PrebuiltError::UnsupportedArchive {
117 url: url_for_suffix.to_string(),
118 })
119 }
120}
121
122fn validate_archive_relative_path(path: &Path) -> Result<(), PrebuiltError> {
123 if path.is_absolute() {
124 return Err(PrebuiltError::Extract(format!(
125 "archive entry '{}' is absolute",
126 path.display()
127 )));
128 }
129 if path
130 .components()
131 .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
132 {
133 return Err(PrebuiltError::Extract(format!(
134 "archive entry '{}' escapes extraction directory",
135 path.display()
136 )));
137 }
138 Ok(())
139}
140
141fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
142 for entry in std::fs::read_dir(src)? {
143 let entry = entry?;
144 let from = entry.path();
145 let to = dest.join(entry.file_name());
146 let meta = entry.file_type()?;
147 if meta.is_dir() {
148 std::fs::create_dir_all(&to)?;
149 copy_dir_contents(&from, &to)?;
150 } else if meta.is_file() {
151 if let Some(parent) = to.parent() {
152 std::fs::create_dir_all(parent)?;
153 }
154 std::fs::copy(&from, &to)?;
155 #[cfg(unix)]
156 {
157 use std::os::unix::fs::PermissionsExt;
158 let mode = std::fs::metadata(&from)?.permissions().mode();
159 std::fs::set_permissions(&to, std::fs::Permissions::from_mode(mode))?;
160 }
161 }
162 }
163 Ok(())
164}
165
166pub fn host_triple() -> Option<&'static str> {
167 let os = if cfg!(target_os = "linux") {
168 "linux"
169 } else if cfg!(target_os = "macos") {
170 "darwin"
171 } else if cfg!(target_os = "windows") {
172 "windows"
173 } else {
174 return None;
175 };
176 let arch = if cfg!(target_arch = "x86_64") {
177 "x86_64"
178 } else if cfg!(target_arch = "aarch64") {
179 "arm64"
180 } else {
181 return None;
182 };
183 Some(match (os, arch) {
185 ("linux", "x86_64") => "linux-x86_64",
186 ("linux", "arm64") => "linux-arm64",
187 ("darwin", "x86_64") => "darwin-x86_64",
188 ("darwin", "arm64") => "darwin-arm64",
189 ("windows", "x86_64") => "windows-x86_64",
190 ("windows", "arm64") => "windows-arm64",
191 _ => return None,
192 })
193}
194
195pub const SETUP_TIMEOUT: Duration = Duration::from_secs(600);
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct SetupOutcome {
204 pub log_path: PathBuf,
206 pub exit_code: i32,
208}
209
210#[derive(Debug, thiserror::Error)]
212pub enum SetupError {
213 #[error("setup script path '{path}' escapes plugin directory")]
216 EscapesPluginDir { path: String },
217
218 #[error("setup script '{path}' not found in plugin directory")]
221 NotFound { path: String },
222
223 #[error("setup script exited with code {exit_code}; see {}", log_path.display())]
226 NonZeroExit { exit_code: i32, log_path: PathBuf },
227
228 #[error("setup script timed out after {secs}s; see {}", log_path.display())]
230 Timeout { secs: u64, log_path: PathBuf },
231
232 #[error("setup script io: {0}")]
234 Io(#[from] std::io::Error),
235}
236
237#[derive(Debug, thiserror::Error, PartialEq, Eq)]
242pub enum CommandVerifyError {
243 #[error("extension command path '{path}' escapes plugin directory")]
246 EscapesPluginDir { path: String },
247
248 #[error("extension command '{path}' does not exist (resolved to {})", resolved.display())]
252 Missing { path: String, resolved: PathBuf },
253
254 #[cfg(unix)]
258 #[error("extension command '{path}' exists but is not executable (mode {mode:o})")]
259 NotExecutable { path: String, mode: u32 },
260
261 #[error("extension command '{path}' is a directory, not a file")]
263 NotAFile { path: String },
264}
265
266pub fn verify_extension_command(
287 manifest: &PluginManifest,
288 plugin_dir: &Path,
289) -> Result<Option<PathBuf>, CommandVerifyError> {
290 let Some(ext) = manifest.extension.as_ref() else {
291 return Ok(None);
292 };
293 let cmd = &ext.command;
294 let cmd_path = Path::new(cmd);
295
296 if !cmd.contains(std::path::MAIN_SEPARATOR) && !cmd.contains('/') {
298 return Ok(None);
299 }
300
301 let resolved = if cmd_path.is_absolute() {
302 return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
303 } else {
304 if cmd_path
306 .components()
307 .any(|c| matches!(c, std::path::Component::ParentDir))
308 {
309 return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
310 }
311 let joined = plugin_dir.join(cmd_path);
312 match joined.canonicalize() {
313 Ok(p) => {
314 let canonical_dir = plugin_dir
315 .canonicalize()
316 .unwrap_or_else(|_| plugin_dir.to_path_buf());
317 if !p.starts_with(&canonical_dir) {
318 return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
319 }
320 p
321 }
322 Err(_) => {
323 return Err(CommandVerifyError::Missing {
324 path: cmd.clone(),
325 resolved: joined,
326 });
327 }
328 }
329 };
330
331 if !resolved.exists() {
332 return Err(CommandVerifyError::Missing {
333 path: cmd.clone(),
334 resolved,
335 });
336 }
337 let meta = std::fs::metadata(&resolved).map_err(|_| CommandVerifyError::Missing {
338 path: cmd.clone(),
339 resolved: resolved.clone(),
340 })?;
341 if meta.is_dir() {
342 return Err(CommandVerifyError::NotAFile { path: cmd.clone() });
343 }
344 #[cfg(unix)]
345 {
346 use std::os::unix::fs::PermissionsExt;
347 let mode = meta.permissions().mode();
348 if mode & 0o111 == 0 {
350 return Err(CommandVerifyError::NotExecutable {
351 path: cmd.clone(),
352 mode,
353 });
354 }
355 }
356 Ok(Some(resolved))
357}
358
359#[derive(Debug, thiserror::Error)]
365pub enum PrebuiltError {
366 #[error("no prebuilt asset declared for this host")]
370 NoMatchingAsset,
371
372 #[error("download failed: {0}")]
374 Download(String),
375
376 #[error("checksum mismatch: expected {expected}, got {actual}")]
380 ChecksumMismatch { expected: String, actual: String },
381
382 #[error("archive extraction failed: {0}")]
384 Extract(String),
385
386 #[error("unsupported archive type for url '{url}'")]
389 UnsupportedArchive { url: String },
390
391 #[error("refusing non-https prebuilt url '{url}'")]
393 UnsafeUrl { url: String },
394
395 #[error("invalid sha256 '{sha256}'; expected exactly 64 hex characters")]
397 InvalidSha256 { sha256: String },
398
399 #[error("prebuilt archive exceeds maximum size of {max} bytes")]
401 TooLarge { max: u64 },
402
403 #[error("io: {0}")]
405 Io(#[from] std::io::Error),
406
407 #[error("prebuilt extracted but extension command not found: {0}")]
410 Verify(#[from] CommandVerifyError),
411}
412
413fn hex_encode_lower(bytes: &[u8]) -> String {
416 let mut out = String::with_capacity(bytes.len() * 2);
417 for b in bytes {
418 out.push(char::from_digit((*b >> 4) as u32, 16).unwrap());
419 out.push(char::from_digit((*b & 0x0f) as u32, 16).unwrap());
420 }
421 out
422}
423
424pub async fn try_install_from_prebuilt(
440 manifest: &PluginManifest,
441 plugin_dir: &Path,
442) -> Result<PathBuf, PrebuiltError> {
443 let Some(ext) = manifest.extension.as_ref() else {
444 return Err(PrebuiltError::NoMatchingAsset);
445 };
446 let Some(triple) = host_triple() else {
447 return Err(PrebuiltError::NoMatchingAsset);
448 };
449 let Some(asset) = ext.prebuilt.get(triple) else {
450 return Err(PrebuiltError::NoMatchingAsset);
451 };
452
453 if !prebuilt_url_allowed(&asset.url) {
454 return Err(PrebuiltError::UnsafeUrl {
455 url: asset.url.clone(),
456 });
457 }
458 let expected_sha =
459 normalize_sha256(&asset.sha256).ok_or_else(|| PrebuiltError::InvalidSha256 {
460 sha256: asset.sha256.clone(),
461 })?;
462
463 let tmp_archive = plugin_dir.join(format!(".prebuilt-{triple}-download"));
464 match std::fs::remove_file(&tmp_archive) {
465 Ok(()) => {}
466 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
467 Err(e) => return Err(PrebuiltError::Io(e)),
468 }
469
470 let download_res =
471 download_prebuilt_to_file(&asset.url, &tmp_archive, MAX_PREBUILT_ARCHIVE_BYTES).await;
472 let actual = match download_res {
473 Ok(sha) => sha,
474 Err(e) => {
475 let _ = std::fs::remove_file(&tmp_archive);
476 return Err(e);
477 }
478 };
479 if actual != expected_sha {
480 let _ = std::fs::remove_file(&tmp_archive);
481 return Err(PrebuiltError::ChecksumMismatch {
482 expected: expected_sha,
483 actual,
484 });
485 }
486
487 let archive = tmp_archive.clone();
488 let dest = plugin_dir.to_path_buf();
489 let url = asset.url.clone();
490 let extract_res = tokio::task::spawn_blocking(move || extract_archive(&archive, &dest, &url))
491 .await
492 .map_err(|e| PrebuiltError::Extract(format!("extract task join error: {e}")))?;
493 let _ = std::fs::remove_file(&tmp_archive);
494 extract_res?;
495
496 let resolved = verify_extension_command(manifest, plugin_dir)?.ok_or_else(|| {
498 PrebuiltError::Verify(CommandVerifyError::Missing {
499 path: ext.command.clone(),
500 resolved: plugin_dir.join(&ext.command),
501 })
502 })?;
503 Ok(resolved)
504}
505
506async fn download_prebuilt_to_file(
507 url: &str,
508 tmp_archive: &Path,
509 max_bytes: u64,
510) -> Result<String, PrebuiltError> {
511 use sha2::{Digest, Sha256};
512
513 let mut hasher = Sha256::new();
514 let mut written: u64 = 0;
515
516 if let Some(path) = url.strip_prefix("file://") {
517 #[cfg(not(test))]
518 {
519 let _ = path;
520 return Err(PrebuiltError::UnsafeUrl {
521 url: url.to_string(),
522 });
523 }
524 #[cfg(test)]
525 {
526 let mut input = std::fs::File::open(path)
527 .map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
528 let mut output = std::fs::File::create(tmp_archive)?;
529 let mut buf = [0u8; 8192];
530 loop {
531 let n = std::io::Read::read(&mut input, &mut buf)
532 .map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
533 if n == 0 {
534 break;
535 }
536 written += n as u64;
537 if written > max_bytes {
538 return Err(PrebuiltError::TooLarge { max: max_bytes });
539 }
540 hasher.update(&buf[..n]);
541 std::io::Write::write_all(&mut output, &buf[..n])?;
542 }
543 return Ok(hex_encode_lower(&hasher.finalize()));
544 }
545 }
546
547 let client = reqwest::Client::builder()
548 .tls_built_in_webpki_certs(true)
549 .tls_built_in_native_certs(true)
550 .connect_timeout(PREBUILT_CONNECT_TIMEOUT)
551 .timeout(PREBUILT_REQUEST_TIMEOUT)
552 .build()
553 .map_err(|e| PrebuiltError::Download(e.to_string()))?;
554 let mut response = client
555 .get(url)
556 .send()
557 .await
558 .map_err(|e| PrebuiltError::Download(e.to_string()))?;
559 if !response.status().is_success() {
560 return Err(PrebuiltError::Download(format!(
561 "HTTP {}",
562 response.status()
563 )));
564 }
565 if let Some(len) = response.content_length() {
566 if len > max_bytes {
567 return Err(PrebuiltError::TooLarge { max: max_bytes });
568 }
569 }
570
571 let mut output = tokio::fs::File::create(tmp_archive).await?;
572 while let Some(chunk) = response
573 .chunk()
574 .await
575 .map_err(|e| PrebuiltError::Download(e.to_string()))?
576 {
577 written += chunk.len() as u64;
578 if written > max_bytes {
579 return Err(PrebuiltError::TooLarge { max: max_bytes });
580 }
581 hasher.update(&chunk);
582 tokio::io::AsyncWriteExt::write_all(&mut output, &chunk).await?;
583 }
584 tokio::io::AsyncWriteExt::flush(&mut output).await?;
585 Ok(hex_encode_lower(&hasher.finalize()))
586}
587
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!(
709 "symlink '{}' cannot be resolved: {e}",
710 p.display()
711 ))
712 })?;
713 if !target.starts_with(root) {
714 return Err(PrebuiltError::Extract(format!(
715 "symlink '{}' escapes extraction directory",
716 p.display()
717 )));
718 }
719 } else if ty.is_dir() {
720 let c = p.canonicalize()?;
721 if !c.starts_with(root) {
722 return Err(PrebuiltError::Extract(format!(
723 "directory '{}' escapes extraction directory",
724 p.display()
725 )));
726 }
727 walk(&p, root)?;
728 } else if ty.is_file() {
729 let c = p.canonicalize()?;
730 if !c.starts_with(root) {
731 return Err(PrebuiltError::Extract(format!(
732 "file '{}' escapes extraction directory",
733 p.display()
734 )));
735 }
736 } else {
737 return Err(PrebuiltError::Extract(format!(
738 "unsupported archive entry type '{}'",
739 p.display()
740 )));
741 }
742 }
743 Ok(())
744 }
745 walk(&canonical_root, &canonical_root)
746}
747
748pub fn resolve_setup_script(
759 manifest: &PluginManifest,
760 plugin_dir: &Path,
761) -> Result<Option<PathBuf>, SetupError> {
762 if let Some(ext) = manifest.extension.as_ref() {
763 if let Some(setup) = ext.setup.as_deref() {
764 return validate_setup_path(setup, plugin_dir).map(Some);
765 }
766 }
767 Ok(None)
768}
769
770fn validate_setup_path(setup: &str, plugin_dir: &Path) -> Result<PathBuf, SetupError> {
774 let setup_path = Path::new(setup);
775 if setup_path.is_absolute()
776 || setup_path
777 .components()
778 .any(|c| matches!(c, std::path::Component::ParentDir))
779 {
780 return Err(SetupError::EscapesPluginDir {
781 path: setup.to_string(),
782 });
783 }
784 let joined = plugin_dir.join(setup_path);
785 let canonical = match joined.canonicalize() {
786 Ok(p) => p,
787 Err(_) => {
788 return Err(SetupError::NotFound {
789 path: setup.to_string(),
790 });
791 }
792 };
793 let canonical_dir = plugin_dir
794 .canonicalize()
795 .unwrap_or_else(|_| plugin_dir.to_path_buf());
796 if !canonical.starts_with(&canonical_dir) {
797 return Err(SetupError::EscapesPluginDir {
798 path: setup.to_string(),
799 });
800 }
801 Ok(canonical)
802}
803
804pub fn install_log_path(logs_root: &Path, plugin_name: &str, now_rfc3339: &str) -> PathBuf {
812 let safe_ts = safe_name_fragment(&now_rfc3339.replace(':', "-"));
813 let safe_plugin = safe_name_fragment(plugin_name);
814 logs_root
815 .join("install")
816 .join(format!("{safe_plugin}-{safe_ts}.log"))
817}
818
819pub async fn run_setup_script(
830 script: &Path,
831 plugin_dir: &Path,
832 log_path: &Path,
833 timeout: Duration,
834) -> Result<SetupOutcome, SetupError> {
835 use std::process::Stdio;
836 use tokio::io::AsyncWriteExt;
837 use tokio::process::Command;
838
839 if let Some(parent) = log_path.parent() {
840 std::fs::create_dir_all(parent)?;
841 }
842 let mut log_file = tokio::fs::File::create(log_path).await?;
843 let header = format!(
844 "$ bash {} (cwd: {})\n",
845 script.display(),
846 plugin_dir.display()
847 );
848 log_file.write_all(header.as_bytes()).await?;
849
850 if cfg!(windows) {
851 return Err(SetupError::Io(std::io::Error::new(
852 std::io::ErrorKind::Unsupported,
853 "setup scripts require bash and are not supported on Windows in this release",
854 )));
855 }
856
857 let mut cmd = Command::new("bash");
858 cmd.arg(script)
859 .current_dir(plugin_dir)
860 .env_clear()
861 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
862 .env("HOME", std::env::var_os("HOME").unwrap_or_default())
863 .env("USER", std::env::var_os("USER").unwrap_or_default())
864 .env("SHELL", std::env::var_os("SHELL").unwrap_or_default())
865 .env("SYNAPS_PLUGIN_DIR", plugin_dir)
866 .stdin(Stdio::null())
867 .stdout(Stdio::piped())
868 .stderr(Stdio::piped())
869 .kill_on_drop(true);
870
871 let mut child = cmd.spawn()?;
872 let mut stdout = child.stdout.take().expect("piped stdout");
873 let mut stderr = child.stderr.take().expect("piped stderr");
874
875 let copy_out = async {
876 tokio::io::copy(&mut stdout, &mut log_file).await?;
877 log_file.flush().await?;
878 Ok::<_, std::io::Error>(log_file)
879 };
880 let collect_err = async {
881 let mut buf = Vec::new();
882 tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut buf).await?;
883 Ok::<_, std::io::Error>(buf)
884 };
885
886 let wait = async {
887 let (out_res, err_res, status) = tokio::join!(copy_out, collect_err, child.wait());
888 let mut log_file = out_res?;
889 let err_buf = err_res?;
890 if !err_buf.is_empty() {
891 log_file.write_all(b"\n--- stderr ---\n").await?;
892 log_file.write_all(&err_buf).await?;
893 log_file.flush().await?;
894 }
895 #[allow(clippy::needless_question_mark)]
896 Ok::<_, std::io::Error>(status?)
897 };
898
899 let status = match tokio::time::timeout(timeout, wait).await {
900 Ok(res) => res?,
901 Err(_) => {
902 return Err(SetupError::Timeout {
903 secs: timeout.as_secs(),
904 log_path: log_path.to_path_buf(),
905 });
906 }
907 };
908
909 let exit_code = status.code().unwrap_or(-1);
910 if status.success() {
911 Ok(SetupOutcome {
912 log_path: log_path.to_path_buf(),
913 exit_code,
914 })
915 } else {
916 Err(SetupError::NonZeroExit {
917 exit_code,
918 log_path: log_path.to_path_buf(),
919 })
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use crate::extensions::manifest::{ExtensionManifest, ExtensionRuntime};
927 use std::fs;
928
929 #[test]
930 fn host_triple_matches_compiled_target_when_supported() {
931 let known = [
934 "linux-x86_64",
935 "linux-arm64",
936 "darwin-x86_64",
937 "darwin-arm64",
938 "windows-x86_64",
939 "windows-arm64",
940 ];
941 let got = host_triple();
942 if cfg!(any(
943 target_os = "linux",
944 target_os = "macos",
945 target_os = "windows"
946 )) && cfg!(any(target_arch = "x86_64", target_arch = "aarch64"))
947 {
948 let s = got.expect("supported host should yield a triple");
949 assert!(known.contains(&s), "unexpected triple: {}", s);
950 }
951 }
952
953 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
954 #[test]
955 fn host_triple_is_linux_x86_64_on_this_box() {
956 assert_eq!(host_triple(), Some("linux-x86_64"));
959 }
960
961 fn manifest_with_setup(setup: Option<&str>) -> PluginManifest {
962 manifest_with_extension_setup(setup)
963 }
964
965 fn manifest_with_extension_setup(setup: Option<&str>) -> PluginManifest {
967 PluginManifest {
968 name: "test-plugin".to_string(),
969 version: None,
970 description: None,
971 keybinds: vec![],
972 compatibility: None,
973 commands: vec![],
974 extension: Some(ExtensionManifest {
975 theme_tokens: Default::default(),
976 deferred: None,
977 protocol_version: 1,
978 runtime: ExtensionRuntime::Process,
979 command: "bin/ext".to_string(),
980 setup: setup.map(|s| s.to_string()),
981 prebuilt: ::std::collections::HashMap::new(),
982 args: vec![],
983 permissions: vec![],
984 hooks: vec![],
985 config: vec![],
986 }),
987 help_entries: vec![],
988 settings: None,
989 }
990 }
991
992 #[test]
993 fn resolve_returns_none_when_no_setup_declared() {
994 let m = manifest_with_setup(None);
995 let dir = tempfile::tempdir().unwrap();
996 let res = resolve_setup_script(&m, dir.path()).unwrap();
997 assert!(res.is_none());
998 }
999
1000 #[test]
1001 fn resolve_resolves_relative_path_inside_plugin_dir() {
1002 let dir = tempfile::tempdir().unwrap();
1003 let scripts = dir.path().join("scripts");
1004 fs::create_dir(&scripts).unwrap();
1005 fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
1006 let m = manifest_with_setup(Some("scripts/setup.sh"));
1007 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1008 assert!(resolved.ends_with("scripts/setup.sh"));
1009 assert!(resolved.is_absolute());
1010 }
1011
1012 #[test]
1013 fn resolve_rejects_absolute_path() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let m = manifest_with_setup(Some("/etc/passwd"));
1016 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1017 assert!(
1018 matches!(err, SetupError::EscapesPluginDir { .. }),
1019 "got {err:?}"
1020 );
1021 }
1022
1023 #[test]
1024 fn resolve_rejects_parent_dir_traversal() {
1025 let dir = tempfile::tempdir().unwrap();
1026 let m = manifest_with_setup(Some("../escape.sh"));
1027 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1028 assert!(
1029 matches!(err, SetupError::EscapesPluginDir { .. }),
1030 "got {err:?}"
1031 );
1032 }
1033
1034 #[test]
1035 fn resolve_rejects_embedded_parent_dir() {
1036 let dir = tempfile::tempdir().unwrap();
1037 let m = manifest_with_setup(Some("scripts/../../etc/passwd"));
1038 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1039 assert!(
1040 matches!(err, SetupError::EscapesPluginDir { .. }),
1041 "got {err:?}"
1042 );
1043 }
1044
1045 #[test]
1046 fn resolve_returns_not_found_when_script_missing() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let m = manifest_with_setup(Some("scripts/missing.sh"));
1049 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1050 assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
1051 }
1052
1053 #[cfg(unix)]
1054 #[test]
1055 fn resolve_rejects_symlink_pointing_outside_plugin_dir() {
1056 let outer = tempfile::tempdir().unwrap();
1059 let dir = tempfile::tempdir().unwrap();
1060 let target = outer.path().join("escape.sh");
1061 fs::write(&target, "#!/bin/bash").unwrap();
1062 let scripts = dir.path().join("scripts");
1063 fs::create_dir(&scripts).unwrap();
1064 let link = scripts.join("setup.sh");
1065 std::os::unix::fs::symlink(&target, &link).unwrap();
1066 let m = manifest_with_setup(Some("scripts/setup.sh"));
1067 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1068 assert!(
1069 matches!(err, SetupError::EscapesPluginDir { .. }),
1070 "got {err:?}"
1071 );
1072 }
1073
1074 #[test]
1075 fn install_log_path_substitutes_colons() {
1076 let path = install_log_path(
1077 Path::new("/tmp/logs"),
1078 "sample-plugin",
1079 "2026-05-02T19:30:45-04:00",
1080 );
1081 assert_eq!(
1082 path,
1083 PathBuf::from("/tmp/logs/install/sample-plugin-2026-05-02T19-30-45-04-00.log")
1084 );
1085 }
1086
1087 #[cfg(unix)]
1088 #[tokio::test]
1089 async fn run_setup_succeeds_for_simple_script() {
1090 let dir = tempfile::tempdir().unwrap();
1091 let scripts = dir.path().join("scripts");
1092 fs::create_dir(&scripts).unwrap();
1093 let script = scripts.join("setup.sh");
1094 fs::write(
1095 &script,
1096 "#!/bin/bash\necho hello-from-setup\necho 'on stderr' >&2\n",
1097 )
1098 .unwrap();
1099 use std::os::unix::fs::PermissionsExt;
1100 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1101 let log = dir.path().join("install.log");
1102 let outcome = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1103 .await
1104 .unwrap();
1105 assert_eq!(outcome.exit_code, 0);
1106 assert_eq!(outcome.log_path, log);
1107 let captured = fs::read_to_string(&log).unwrap();
1108 assert!(captured.contains("hello-from-setup"));
1109 assert!(captured.contains("on stderr"));
1110 }
1111
1112 #[tokio::test]
1113 async fn run_setup_returns_non_zero_exit() {
1114 let dir = tempfile::tempdir().unwrap();
1115 let script = dir.path().join("fail.sh");
1116 fs::write(&script, "#!/bin/bash\necho boom\nexit 7\n").unwrap();
1117 let log = dir.path().join("install.log");
1118 let err = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1119 .await
1120 .unwrap_err();
1121 match err {
1122 SetupError::NonZeroExit {
1123 exit_code,
1124 log_path,
1125 } => {
1126 assert_eq!(exit_code, 7);
1127 assert_eq!(log_path, log);
1128 let captured = fs::read_to_string(&log).unwrap();
1129 assert!(captured.contains("boom"));
1130 }
1131 other => panic!("expected NonZeroExit, got {other:?}"),
1132 }
1133 }
1134
1135 #[tokio::test]
1136 async fn run_setup_times_out() {
1137 let dir = tempfile::tempdir().unwrap();
1138 let script = dir.path().join("loop.sh");
1139 fs::write(&script, "#!/bin/bash\nsleep 5\n").unwrap();
1140 let log = dir.path().join("install.log");
1141 let err = run_setup_script(&script, dir.path(), &log, Duration::from_millis(200))
1142 .await
1143 .unwrap_err();
1144 assert!(matches!(err, SetupError::Timeout { .. }), "got {err:?}");
1145 }
1146
1147 #[test]
1150 fn resolve_resolves_extension_setup_path() {
1151 let dir = tempfile::tempdir().unwrap();
1152 let scripts = dir.path().join("scripts");
1153 fs::create_dir(&scripts).unwrap();
1154 fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
1155 let m = manifest_with_extension_setup(Some("scripts/setup.sh"));
1156 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1157 assert!(resolved.ends_with("scripts/setup.sh"));
1158 assert!(resolved.is_absolute());
1159 }
1160
1161 #[test]
1162 fn resolve_returns_none_when_extension_has_no_setup() {
1163 let m = manifest_with_extension_setup(None);
1164 let dir = tempfile::tempdir().unwrap();
1165 assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1166 }
1167
1168 #[test]
1169 fn resolve_rejects_extension_setup_with_parent_dir() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let m = manifest_with_extension_setup(Some("../escape.sh"));
1172 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1173 assert!(
1174 matches!(err, SetupError::EscapesPluginDir { .. }),
1175 "got {err:?}"
1176 );
1177 }
1178
1179 #[test]
1180 fn resolve_rejects_extension_setup_when_absolute() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let m = manifest_with_extension_setup(Some("/etc/passwd"));
1183 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1184 assert!(
1185 matches!(err, SetupError::EscapesPluginDir { .. }),
1186 "got {err:?}"
1187 );
1188 }
1189
1190 #[test]
1191 fn resolve_returns_not_found_for_missing_extension_setup() {
1192 let dir = tempfile::tempdir().unwrap();
1193 let m = manifest_with_extension_setup(Some("scripts/missing.sh"));
1194 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1195 assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
1196 }
1197
1198 fn manifest_with_extension_command(command: &str) -> PluginManifest {
1202 PluginManifest {
1203 name: "test-plugin".to_string(),
1204 version: None,
1205 description: None,
1206 keybinds: vec![],
1207 compatibility: None,
1208 commands: vec![],
1209 extension: Some(ExtensionManifest {
1210 theme_tokens: Default::default(),
1211 deferred: None,
1212 protocol_version: 1,
1213 runtime: ExtensionRuntime::Process,
1214 command: command.to_string(),
1215 setup: None,
1216 prebuilt: ::std::collections::HashMap::new(),
1217 args: vec![],
1218 permissions: vec![],
1219 hooks: vec![],
1220 config: vec![],
1221 }),
1222 help_entries: vec![],
1223 settings: None,
1224 }
1225 }
1226
1227 #[test]
1228 fn verify_returns_ok_none_when_no_extension() {
1229 let dir = tempfile::tempdir().unwrap();
1230 let mut m = manifest_with_setup(None);
1231 m.extension = None;
1232 assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1233 }
1234
1235 #[test]
1236 fn verify_returns_ok_none_for_bare_command_name() {
1237 let dir = tempfile::tempdir().unwrap();
1239 let m = manifest_with_extension_command("python3");
1240 assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1241 }
1242
1243 #[test]
1244 fn verify_succeeds_when_relative_binary_exists_and_is_executable() {
1245 let dir = tempfile::tempdir().unwrap();
1246 let bin = dir.path().join("bin/ext");
1247 fs::create_dir_all(bin.parent().unwrap()).unwrap();
1248 fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
1249 #[cfg(unix)]
1250 {
1251 use std::os::unix::fs::PermissionsExt;
1252 fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
1253 }
1254 let m = manifest_with_extension_command("bin/ext");
1255 let resolved = verify_extension_command(&m, dir.path()).unwrap();
1256 assert!(resolved.is_some(), "should return resolved path");
1257 }
1258
1259 #[test]
1260 fn verify_returns_missing_when_binary_absent() {
1261 let dir = tempfile::tempdir().unwrap();
1262 let m = manifest_with_extension_command("bin/ext");
1263 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1264 assert!(
1265 matches!(err, CommandVerifyError::Missing { .. }),
1266 "got: {err:?}"
1267 );
1268 }
1269
1270 #[cfg(unix)]
1271 #[test]
1272 fn verify_returns_not_executable_when_bit_missing() {
1273 let dir = tempfile::tempdir().unwrap();
1274 let bin = dir.path().join("bin/ext");
1275 fs::create_dir_all(bin.parent().unwrap()).unwrap();
1276 fs::write(&bin, "data").unwrap();
1277 use std::os::unix::fs::PermissionsExt;
1278 fs::set_permissions(&bin, fs::Permissions::from_mode(0o644)).unwrap();
1279 let m = manifest_with_extension_command("bin/ext");
1280 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1281 assert!(
1282 matches!(err, CommandVerifyError::NotExecutable { .. }),
1283 "got: {err:?}"
1284 );
1285 }
1286
1287 #[test]
1288 fn verify_returns_not_a_file_when_path_is_directory() {
1289 let dir = tempfile::tempdir().unwrap();
1290 let bin = dir.path().join("bin/ext");
1291 fs::create_dir_all(&bin).unwrap();
1292 let m = manifest_with_extension_command("bin/ext");
1293 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1294 assert!(
1295 matches!(err, CommandVerifyError::NotAFile { .. }),
1296 "got: {err:?}"
1297 );
1298 }
1299
1300 #[test]
1301 fn verify_rejects_parent_dir_traversal_in_command() {
1302 let dir = tempfile::tempdir().unwrap();
1303 let m = manifest_with_extension_command("../escape/bin");
1304 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1305 assert!(
1306 matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
1307 "got: {err:?}"
1308 );
1309 }
1310
1311 #[cfg(unix)]
1312 #[test]
1313 fn verify_rejects_symlink_pointing_outside_plugin_dir() {
1314 let outer = tempfile::tempdir().unwrap();
1315 let plugin = tempfile::tempdir().unwrap();
1316 let target = outer.path().join("real-bin");
1318 fs::write(&target, "x").unwrap();
1319 use std::os::unix::fs::PermissionsExt;
1320 fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
1321 let link = plugin.path().join("bin/ext");
1323 fs::create_dir_all(link.parent().unwrap()).unwrap();
1324 std::os::unix::fs::symlink(&target, &link).unwrap();
1325 let m = manifest_with_extension_command("bin/ext");
1326 let err = verify_extension_command(&m, plugin.path()).unwrap_err();
1327 assert!(
1328 matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
1329 "got: {err:?}"
1330 );
1331 }
1332
1333 #[test]
1334 fn verify_rejects_absolute_extension_command() {
1335 let dir = tempfile::tempdir().unwrap();
1336 let m = manifest_with_extension_command("/tmp/ext");
1337 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1338 assert!(
1339 matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
1340 "got: {err:?}"
1341 );
1342 }
1343
1344 #[test]
1345 fn policy_normalizes_sha256_and_sanitizes_names() {
1346 assert_eq!(normalize_sha256(&"A".repeat(64)).unwrap(), "a".repeat(64));
1347 assert!(normalize_sha256("not-a-sha").is_none());
1348 assert_eq!(safe_name_fragment("../bad\nname"), "bad_name");
1349 assert_eq!(safe_name_fragment("normal-name_1.2"), "normal-name_1.2");
1350 }
1351
1352 fn manifest_with_prebuilt(
1355 command: &str,
1356 triple: &str,
1357 url: &str,
1358 sha256: &str,
1359 ) -> PluginManifest {
1360 let mut prebuilt = std::collections::HashMap::new();
1361 prebuilt.insert(
1362 triple.to_string(),
1363 crate::extensions::manifest::PrebuiltAsset {
1364 url: url.to_string(),
1365 sha256: sha256.to_string(),
1366 },
1367 );
1368 PluginManifest {
1369 name: "test-plugin".to_string(),
1370 version: None,
1371 description: None,
1372 keybinds: vec![],
1373 compatibility: None,
1374 commands: vec![],
1375 extension: Some(ExtensionManifest {
1376 theme_tokens: Default::default(),
1377 deferred: None,
1378 protocol_version: 1,
1379 runtime: ExtensionRuntime::Process,
1380 command: command.to_string(),
1381 setup: None,
1382 prebuilt,
1383 args: vec![],
1384 permissions: vec![],
1385 hooks: vec![],
1386 config: vec![],
1387 }),
1388 help_entries: vec![],
1389 settings: None,
1390 }
1391 }
1392
1393 fn mk_tarball(staging: &Path, archive_name: &str, inner_path: &str) -> (PathBuf, String) {
1397 let work = staging.join("staging");
1398 fs::create_dir_all(&work).unwrap();
1399 let payload = work.join(inner_path);
1400 fs::create_dir_all(payload.parent().unwrap()).unwrap();
1401 fs::write(&payload, "#!/bin/sh\necho prebuilt-bin\n").unwrap();
1402 #[cfg(unix)]
1403 {
1404 use std::os::unix::fs::PermissionsExt;
1405 fs::set_permissions(&payload, fs::Permissions::from_mode(0o755)).unwrap();
1406 }
1407 let archive = staging.join(archive_name);
1408 let out = std::process::Command::new("tar")
1409 .arg("-czf")
1410 .arg(&archive)
1411 .arg("-C")
1412 .arg(&work)
1413 .arg(inner_path)
1414 .output()
1415 .expect("system tar must be present");
1416 assert!(out.status.success(), "tar failed: {:?}", out);
1417 let bytes = fs::read(&archive).unwrap();
1418 use sha2::{Digest, Sha256};
1419 let mut h = Sha256::new();
1420 h.update(&bytes);
1421 let sha = hex_encode_lower(&h.finalize());
1422 (archive, sha)
1423 }
1424
1425 #[tokio::test(flavor = "current_thread")]
1426 async fn prebuilt_returns_no_matching_asset_when_triple_missing() {
1427 let dir = tempfile::tempdir().unwrap();
1428 let m = manifest_with_prebuilt("bin/ext", "fake-triple-9999", "https://x", "00");
1430 let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1431 assert!(
1432 matches!(err, PrebuiltError::NoMatchingAsset),
1433 "got: {err:?}"
1434 );
1435 }
1436
1437 #[tokio::test(flavor = "current_thread")]
1438 async fn prebuilt_rejects_non_https_url_in_production_builds() {
1439 let dir = tempfile::tempdir().unwrap();
1441 let triple = host_triple().expect("supported host");
1442 let m = manifest_with_prebuilt("bin/ext", triple, "http://example.com/x.tar.gz", "00");
1443 let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1444 assert!(
1445 matches!(err, PrebuiltError::UnsafeUrl { .. }),
1446 "got: {err:?}"
1447 );
1448 }
1449
1450 #[tokio::test(flavor = "current_thread")]
1451 async fn prebuilt_succeeds_with_valid_tarball_and_checksum() {
1452 let staging = tempfile::tempdir().unwrap();
1453 let plugin = tempfile::tempdir().unwrap();
1454 let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1455 let url = format!("file://{}", archive.display());
1456 let triple = host_triple().expect("supported host");
1457 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1458 let resolved = try_install_from_prebuilt(&m, plugin.path()).await.unwrap();
1459 assert!(
1460 resolved.exists(),
1461 "extracted binary should exist at {}",
1462 resolved.display()
1463 );
1464 let leftover = plugin.path().join(format!(".prebuilt-{}-download", triple));
1466 assert!(!leftover.exists(), "temp archive should be removed");
1467 }
1468
1469 #[tokio::test(flavor = "current_thread")]
1470 async fn prebuilt_aborts_on_checksum_mismatch_without_extracting() {
1471 let staging = tempfile::tempdir().unwrap();
1472 let plugin = tempfile::tempdir().unwrap();
1473 let (archive, _real_sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1474 let url = format!("file://{}", archive.display());
1475 let triple = host_triple().expect("supported host");
1476 let bad_sha = "0".repeat(64);
1477 let m = manifest_with_prebuilt("bin/ext", triple, &url, &bad_sha);
1478 let err = try_install_from_prebuilt(&m, plugin.path())
1479 .await
1480 .unwrap_err();
1481 match err {
1482 PrebuiltError::ChecksumMismatch { expected, actual } => {
1483 assert_eq!(expected, bad_sha);
1484 assert_eq!(actual.len(), 64, "actual sha should be lowercase hex");
1485 }
1486 other => panic!("expected ChecksumMismatch, got {other:?}"),
1487 }
1488 assert!(!plugin.path().join("bin/ext").exists());
1490 }
1491
1492 #[tokio::test(flavor = "current_thread")]
1493 async fn prebuilt_rejects_unsupported_archive_suffix() {
1494 let staging = tempfile::tempdir().unwrap();
1495 let plugin = tempfile::tempdir().unwrap();
1496 let archive = staging.path().join("ext.rar");
1498 fs::write(&archive, b"not really a rar").unwrap();
1499 let bytes = fs::read(&archive).unwrap();
1500 use sha2::{Digest, Sha256};
1501 let mut h = Sha256::new();
1502 h.update(&bytes);
1503 let sha = hex_encode_lower(&h.finalize());
1504 let url = format!("file://{}", archive.display());
1505 let triple = host_triple().expect("supported host");
1506 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1507 let err = try_install_from_prebuilt(&m, plugin.path())
1508 .await
1509 .unwrap_err();
1510 assert!(
1511 matches!(err, PrebuiltError::UnsupportedArchive { .. }),
1512 "got: {err:?}"
1513 );
1514 }
1515
1516 #[tokio::test(flavor = "current_thread")]
1517 async fn prebuilt_fails_verify_when_archive_does_not_contain_declared_command() {
1518 let staging = tempfile::tempdir().unwrap();
1519 let plugin = tempfile::tempdir().unwrap();
1520 let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/wrong-name");
1522 let url = format!("file://{}", archive.display());
1523 let triple = host_triple().expect("supported host");
1524 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1525 let err = try_install_from_prebuilt(&m, plugin.path())
1526 .await
1527 .unwrap_err();
1528 assert!(matches!(err, PrebuiltError::Verify(_)), "got: {err:?}");
1529 }
1530}