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