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 .connect_timeout(PREBUILT_CONNECT_TIMEOUT)
552 .timeout(PREBUILT_REQUEST_TIMEOUT)
553 .build()
554 .map_err(|e| PrebuiltError::Download(e.to_string()))?;
555 let mut response = client
556 .get(url)
557 .send()
558 .await
559 .map_err(|e| PrebuiltError::Download(e.to_string()))?;
560 if !response.status().is_success() {
561 return Err(PrebuiltError::Download(format!("HTTP {}", response.status())));
562 }
563 if let Some(len) = response.content_length() {
564 if len > max_bytes {
565 return Err(PrebuiltError::TooLarge { max: max_bytes });
566 }
567 }
568
569 let mut output = tokio::fs::File::create(tmp_archive).await?;
570 while let Some(chunk) = response
571 .chunk()
572 .await
573 .map_err(|e| PrebuiltError::Download(e.to_string()))?
574 {
575 written += chunk.len() as u64;
576 if written > max_bytes {
577 return Err(PrebuiltError::TooLarge { max: max_bytes });
578 }
579 hasher.update(&chunk);
580 tokio::io::AsyncWriteExt::write_all(&mut output, &chunk).await?;
581 }
582 tokio::io::AsyncWriteExt::flush(&mut output).await?;
583 Ok(hex_encode_lower(&hasher.finalize()))
584}
585
586fn extract_archive(
591 archive: &Path,
592 dest_dir: &Path,
593 url_for_suffix: &str,
594) -> Result<(), PrebuiltError> {
595 let kind = archive_suffix(url_for_suffix)?;
596 let extract_root = dest_dir.join(format!(
597 ".prebuilt-extract-{}",
598 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
599 ));
600 match std::fs::remove_dir_all(&extract_root) {
601 Ok(()) => {}
602 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
603 Err(e) => return Err(PrebuiltError::Io(e)),
604 }
605 std::fs::create_dir_all(&extract_root)?;
606 let result = match kind {
607 "tar.gz" => extract_tar_gz_safe(archive, &extract_root),
608 "zip" => extract_zip_safe(archive, &extract_root),
609 _ => unreachable!(),
610 }
611 .and_then(|_| copy_dir_contents(&extract_root, dest_dir).map_err(PrebuiltError::Io));
612 let cleanup = std::fs::remove_dir_all(&extract_root);
613 match (result, cleanup) {
614 (Ok(()), Ok(())) => Ok(()),
615 (Ok(()), Err(e)) => Err(PrebuiltError::Extract(format!(
616 "failed to clean extraction directory {}: {e}",
617 extract_root.display()
618 ))),
619 (Err(e), _) => Err(e),
620 }
621}
622
623fn extract_tar_gz_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
624 use std::process::{Command, Stdio};
625
626 let list = Command::new("tar")
627 .arg("-tzf")
628 .arg(archive)
629 .output()
630 .map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
631 if !list.status.success() {
632 return Err(PrebuiltError::Extract(format!(
633 "tar list exited {}: {}",
634 list.status,
635 String::from_utf8_lossy(&list.stderr).trim()
636 )));
637 }
638 for line in String::from_utf8_lossy(&list.stdout).lines() {
639 validate_archive_relative_path(Path::new(line))?;
640 }
641 let out = Command::new("tar")
642 .arg("--no-same-owner")
643 .arg("--no-same-permissions")
644 .arg("-xzf")
645 .arg(archive)
646 .arg("-C")
647 .arg(root)
648 .stdin(Stdio::null())
649 .output()
650 .map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
651 if !out.status.success() {
652 return Err(PrebuiltError::Extract(format!(
653 "tar exited {}: {}",
654 out.status,
655 String::from_utf8_lossy(&out.stderr).trim()
656 )));
657 }
658 validate_extracted_tree(root)
659}
660
661fn extract_zip_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
662 use std::process::{Command, Stdio};
663
664 let list = Command::new("unzip")
665 .arg("-Z1")
666 .arg(archive)
667 .output()
668 .map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
669 if !list.status.success() {
670 return Err(PrebuiltError::Extract(format!(
671 "unzip list exited {}: {}",
672 list.status,
673 String::from_utf8_lossy(&list.stderr).trim()
674 )));
675 }
676 for line in String::from_utf8_lossy(&list.stdout).lines() {
677 validate_archive_relative_path(Path::new(line))?;
678 }
679 let out = Command::new("unzip")
680 .arg("-q")
681 .arg(archive)
682 .arg("-d")
683 .arg(root)
684 .stdin(Stdio::null())
685 .output()
686 .map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
687 if !out.status.success() {
688 return Err(PrebuiltError::Extract(format!(
689 "unzip exited {}: {}",
690 out.status,
691 String::from_utf8_lossy(&out.stderr).trim()
692 )));
693 }
694 validate_extracted_tree(root)
695}
696
697fn validate_extracted_tree(root: &Path) -> Result<(), PrebuiltError> {
698 let canonical_root = root.canonicalize()?;
699 fn walk(path: &Path, root: &Path) -> Result<(), PrebuiltError> {
700 for entry in std::fs::read_dir(path)? {
701 let entry = entry?;
702 let ty = entry.file_type()?;
703 let p = entry.path();
704 if ty.is_symlink() {
705 let target = std::fs::canonicalize(&p).map_err(|e| {
706 PrebuiltError::Extract(format!("symlink '{}' cannot be resolved: {e}", p.display()))
707 })?;
708 if !target.starts_with(root) {
709 return Err(PrebuiltError::Extract(format!(
710 "symlink '{}' escapes extraction directory",
711 p.display()
712 )));
713 }
714 } else if ty.is_dir() {
715 let c = p.canonicalize()?;
716 if !c.starts_with(root) {
717 return Err(PrebuiltError::Extract(format!(
718 "directory '{}' escapes extraction directory",
719 p.display()
720 )));
721 }
722 walk(&p, root)?;
723 } else if ty.is_file() {
724 let c = p.canonicalize()?;
725 if !c.starts_with(root) {
726 return Err(PrebuiltError::Extract(format!(
727 "file '{}' escapes extraction directory",
728 p.display()
729 )));
730 }
731 } else {
732 return Err(PrebuiltError::Extract(format!(
733 "unsupported archive entry type '{}'",
734 p.display()
735 )));
736 }
737 }
738 Ok(())
739 }
740 walk(&canonical_root, &canonical_root)
741}
742
743pub fn resolve_setup_script(
754 manifest: &PluginManifest,
755 plugin_dir: &Path,
756) -> Result<Option<PathBuf>, SetupError> {
757 if let Some(ext) = manifest.extension.as_ref() {
761 if let Some(setup) = ext.setup.as_deref() {
762 return validate_setup_path(setup, plugin_dir).map(Some);
763 }
764 }
765 if let Some(provides) = manifest.provides.as_ref() {
767 if let Some(sidecar) = provides.sidecar.as_ref() {
768 if let Some(setup) = sidecar.setup.as_deref() {
769 return validate_setup_path(setup, plugin_dir).map(Some);
770 }
771 }
772 }
773 Ok(None)
774}
775
776fn validate_setup_path(setup: &str, plugin_dir: &Path) -> Result<PathBuf, SetupError> {
782 let setup_path = Path::new(setup);
783 if setup_path.is_absolute()
784 || setup_path
785 .components()
786 .any(|c| matches!(c, std::path::Component::ParentDir))
787 {
788 return Err(SetupError::EscapesPluginDir {
789 path: setup.to_string(),
790 });
791 }
792 let joined = plugin_dir.join(setup_path);
793 let canonical = match joined.canonicalize() {
794 Ok(p) => p,
795 Err(_) => {
796 return Err(SetupError::NotFound {
797 path: setup.to_string(),
798 });
799 }
800 };
801 let canonical_dir = plugin_dir
802 .canonicalize()
803 .unwrap_or_else(|_| plugin_dir.to_path_buf());
804 if !canonical.starts_with(&canonical_dir) {
805 return Err(SetupError::EscapesPluginDir {
806 path: setup.to_string(),
807 });
808 }
809 Ok(canonical)
810}
811
812pub fn install_log_path(logs_root: &Path, plugin_name: &str, now_rfc3339: &str) -> PathBuf {
820 let safe_ts = safe_name_fragment(&now_rfc3339.replace(':', "-"));
821 let safe_plugin = safe_name_fragment(plugin_name);
822 logs_root
823 .join("install")
824 .join(format!("{safe_plugin}-{safe_ts}.log"))
825}
826
827pub async fn run_setup_script(
838 script: &Path,
839 plugin_dir: &Path,
840 log_path: &Path,
841 timeout: Duration,
842) -> Result<SetupOutcome, SetupError> {
843 use std::process::Stdio;
844 use tokio::io::AsyncWriteExt;
845 use tokio::process::Command;
846
847 if let Some(parent) = log_path.parent() {
848 std::fs::create_dir_all(parent)?;
849 }
850 let mut log_file = tokio::fs::File::create(log_path).await?;
851 let header = format!(
852 "$ bash {} (cwd: {})\n",
853 script.display(),
854 plugin_dir.display()
855 );
856 log_file.write_all(header.as_bytes()).await?;
857
858 if cfg!(windows) {
859 return Err(SetupError::Io(std::io::Error::new(
860 std::io::ErrorKind::Unsupported,
861 "setup scripts require bash and are not supported on Windows in this release",
862 )));
863 }
864
865 let mut cmd = Command::new("bash");
866 cmd.arg(script)
867 .current_dir(plugin_dir)
868 .env_clear()
869 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
870 .env("HOME", std::env::var_os("HOME").unwrap_or_default())
871 .env("USER", std::env::var_os("USER").unwrap_or_default())
872 .env("SHELL", std::env::var_os("SHELL").unwrap_or_default())
873 .env("SYNAPS_PLUGIN_DIR", plugin_dir)
874 .stdin(Stdio::null())
875 .stdout(Stdio::piped())
876 .stderr(Stdio::piped())
877 .kill_on_drop(true);
878
879 let mut child = cmd.spawn()?;
880 let mut stdout = child.stdout.take().expect("piped stdout");
881 let mut stderr = child.stderr.take().expect("piped stderr");
882
883 let copy_out = async {
884 tokio::io::copy(&mut stdout, &mut log_file).await?;
885 log_file.flush().await?;
886 Ok::<_, std::io::Error>(log_file)
887 };
888 let collect_err = async {
889 let mut buf = Vec::new();
890 tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut buf).await?;
891 Ok::<_, std::io::Error>(buf)
892 };
893
894 let wait = async {
895 let (out_res, err_res, status) = tokio::join!(copy_out, collect_err, child.wait());
896 let mut log_file = out_res?;
897 let err_buf = err_res?;
898 if !err_buf.is_empty() {
899 log_file.write_all(b"\n--- stderr ---\n").await?;
900 log_file.write_all(&err_buf).await?;
901 log_file.flush().await?;
902 }
903 #[allow(clippy::needless_question_mark)]
904 Ok::<_, std::io::Error>(status?)
905 };
906
907 let status = match tokio::time::timeout(timeout, wait).await {
908 Ok(res) => res?,
909 Err(_) => {
910 return Err(SetupError::Timeout {
911 secs: timeout.as_secs(),
912 log_path: log_path.to_path_buf(),
913 });
914 }
915 };
916
917 let exit_code = status.code().unwrap_or(-1);
918 if status.success() {
919 Ok(SetupOutcome {
920 log_path: log_path.to_path_buf(),
921 exit_code,
922 })
923 } else {
924 Err(SetupError::NonZeroExit {
925 exit_code,
926 log_path: log_path.to_path_buf(),
927 })
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934 use crate::extensions::manifest::{ExtensionManifest, ExtensionRuntime};
935 use crate::skills::manifest::{PluginProvides, SidecarManifest};
936 use std::fs;
937
938 #[test]
939 fn host_triple_matches_compiled_target_when_supported() {
940 let known = [
943 "linux-x86_64", "linux-arm64",
944 "darwin-x86_64", "darwin-arm64",
945 "windows-x86_64", "windows-arm64",
946 ];
947 let got = host_triple();
948 if cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows"))
949 && cfg!(any(target_arch = "x86_64", target_arch = "aarch64"))
950 {
951 let s = got.expect("supported host should yield a triple");
952 assert!(known.contains(&s), "unexpected triple: {}", s);
953 }
954 }
955
956 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
957 #[test]
958 fn host_triple_is_linux_x86_64_on_this_box() {
959 assert_eq!(host_triple(), Some("linux-x86_64"));
962 }
963
964 fn manifest_with_setup(setup: Option<&str>) -> PluginManifest {
965 PluginManifest {
966 name: "test-plugin".to_string(),
967 version: None,
968 description: None,
969 keybinds: vec![],
970 compatibility: None,
971 commands: vec![],
972 extension: None,
973 help_entries: vec![],
974 provides: Some(PluginProvides {
975 sidecar: Some(SidecarManifest {
976 command: "bin/sidecar".to_string(),
977 setup: setup.map(|s| s.to_string()),
978 protocol_version: 1,
979 model: None,
980 lifecycle: None,
981 }),
982 }),
983 settings: None,
984 }
985 }
986
987 fn manifest_with_extension_setup(setup: Option<&str>) -> PluginManifest {
989 PluginManifest {
990 name: "test-plugin".to_string(),
991 version: None,
992 description: None,
993 keybinds: vec![],
994 compatibility: None,
995 commands: vec![],
996 extension: Some(ExtensionManifest {
997 protocol_version: 1,
998 runtime: ExtensionRuntime::Process,
999 command: "bin/ext".to_string(),
1000 setup: setup.map(|s| s.to_string()),
1001 prebuilt: ::std::collections::HashMap::new(),
1002 args: vec![],
1003 permissions: vec![],
1004 hooks: vec![],
1005 config: vec![],
1006 }),
1007 help_entries: vec![],
1008 provides: None,
1009 settings: None,
1010 }
1011 }
1012
1013 fn manifest_with_both_setup(ext_setup: &str, side_setup: &str) -> PluginManifest {
1016 let mut m = manifest_with_extension_setup(Some(ext_setup));
1017 m.provides = Some(PluginProvides {
1018 sidecar: Some(SidecarManifest {
1019 command: "bin/sidecar".to_string(),
1020 setup: Some(side_setup.to_string()),
1021 protocol_version: 1,
1022 model: None,
1023 lifecycle: None,
1024 }),
1025 });
1026 m
1027 }
1028
1029 #[test]
1030 fn resolve_returns_none_when_no_setup_declared() {
1031 let m = manifest_with_setup(None);
1032 let dir = tempfile::tempdir().unwrap();
1033 let res = resolve_setup_script(&m, dir.path()).unwrap();
1034 assert!(res.is_none());
1035 }
1036
1037 #[test]
1038 fn resolve_returns_none_when_no_provides() {
1039 let mut m = manifest_with_setup(None);
1040 m.provides = None;
1041 let dir = tempfile::tempdir().unwrap();
1042 assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1043 }
1044
1045 #[test]
1046 fn resolve_resolves_relative_path_inside_plugin_dir() {
1047 let dir = tempfile::tempdir().unwrap();
1048 let scripts = dir.path().join("scripts");
1049 fs::create_dir(&scripts).unwrap();
1050 fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
1051 let m = manifest_with_setup(Some("scripts/setup.sh"));
1052 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1053 assert!(resolved.ends_with("scripts/setup.sh"));
1054 assert!(resolved.is_absolute());
1055 }
1056
1057 #[test]
1058 fn resolve_rejects_absolute_path() {
1059 let dir = tempfile::tempdir().unwrap();
1060 let m = manifest_with_setup(Some("/etc/passwd"));
1061 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1062 assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1063 }
1064
1065 #[test]
1066 fn resolve_rejects_parent_dir_traversal() {
1067 let dir = tempfile::tempdir().unwrap();
1068 let m = manifest_with_setup(Some("../escape.sh"));
1069 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1070 assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1071 }
1072
1073 #[test]
1074 fn resolve_rejects_embedded_parent_dir() {
1075 let dir = tempfile::tempdir().unwrap();
1076 let m = manifest_with_setup(Some("scripts/../../etc/passwd"));
1077 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1078 assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1079 }
1080
1081 #[test]
1082 fn resolve_returns_not_found_when_script_missing() {
1083 let dir = tempfile::tempdir().unwrap();
1084 let m = manifest_with_setup(Some("scripts/missing.sh"));
1085 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1086 assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
1087 }
1088
1089 #[test]
1090 fn resolve_rejects_symlink_pointing_outside_plugin_dir() {
1091 let outer = tempfile::tempdir().unwrap();
1094 let dir = tempfile::tempdir().unwrap();
1095 let target = outer.path().join("escape.sh");
1096 fs::write(&target, "#!/bin/bash").unwrap();
1097 let scripts = dir.path().join("scripts");
1098 fs::create_dir(&scripts).unwrap();
1099 let link = scripts.join("setup.sh");
1100 std::os::unix::fs::symlink(&target, &link).unwrap();
1101 let m = manifest_with_setup(Some("scripts/setup.sh"));
1102 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1103 assert!(matches!(err, SetupError::EscapesPluginDir { .. }), "got {err:?}");
1104 }
1105
1106 #[test]
1107 fn install_log_path_substitutes_colons() {
1108 let path = install_log_path(
1109 Path::new("/tmp/logs"),
1110 "sample-sidecar",
1111 "2026-05-02T19:30:45-04:00",
1112 );
1113 assert_eq!(
1114 path,
1115 PathBuf::from("/tmp/logs/install/sample-sidecar-2026-05-02T19-30-45-04-00.log")
1116 );
1117 }
1118
1119 #[tokio::test]
1120 async fn run_setup_succeeds_for_simple_script() {
1121 let dir = tempfile::tempdir().unwrap();
1122 let scripts = dir.path().join("scripts");
1123 fs::create_dir(&scripts).unwrap();
1124 let script = scripts.join("setup.sh");
1125 fs::write(
1126 &script,
1127 "#!/bin/bash\necho hello-from-setup\necho 'on stderr' >&2\n",
1128 )
1129 .unwrap();
1130 use std::os::unix::fs::PermissionsExt;
1131 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
1132 let log = dir.path().join("install.log");
1133 let outcome = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1134 .await
1135 .unwrap();
1136 assert_eq!(outcome.exit_code, 0);
1137 assert_eq!(outcome.log_path, log);
1138 let captured = fs::read_to_string(&log).unwrap();
1139 assert!(captured.contains("hello-from-setup"));
1140 assert!(captured.contains("on stderr"));
1141 }
1142
1143 #[tokio::test]
1144 async fn run_setup_returns_non_zero_exit() {
1145 let dir = tempfile::tempdir().unwrap();
1146 let script = dir.path().join("fail.sh");
1147 fs::write(&script, "#!/bin/bash\necho boom\nexit 7\n").unwrap();
1148 let log = dir.path().join("install.log");
1149 let err = run_setup_script(&script, dir.path(), &log, Duration::from_secs(5))
1150 .await
1151 .unwrap_err();
1152 match err {
1153 SetupError::NonZeroExit { exit_code, log_path } => {
1154 assert_eq!(exit_code, 7);
1155 assert_eq!(log_path, log);
1156 let captured = fs::read_to_string(&log).unwrap();
1157 assert!(captured.contains("boom"));
1158 }
1159 other => panic!("expected NonZeroExit, got {other:?}"),
1160 }
1161 }
1162
1163 #[tokio::test]
1164 async fn run_setup_times_out() {
1165 let dir = tempfile::tempdir().unwrap();
1166 let script = dir.path().join("loop.sh");
1167 fs::write(&script, "#!/bin/bash\nsleep 5\n").unwrap();
1168 let log = dir.path().join("install.log");
1169 let err = run_setup_script(&script, dir.path(), &log, Duration::from_millis(200))
1170 .await
1171 .unwrap_err();
1172 assert!(matches!(err, SetupError::Timeout { .. }), "got {err:?}");
1173 }
1174
1175 #[test]
1178 fn resolve_resolves_extension_setup_path() {
1179 let dir = tempfile::tempdir().unwrap();
1180 let scripts = dir.path().join("scripts");
1181 fs::create_dir(&scripts).unwrap();
1182 fs::write(scripts.join("setup.sh"), "#!/bin/bash\necho ok").unwrap();
1183 let m = manifest_with_extension_setup(Some("scripts/setup.sh"));
1184 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1185 assert!(resolved.ends_with("scripts/setup.sh"));
1186 assert!(resolved.is_absolute());
1187 }
1188
1189 #[test]
1190 fn resolve_returns_none_when_extension_has_no_setup() {
1191 let m = manifest_with_extension_setup(None);
1192 let dir = tempfile::tempdir().unwrap();
1193 assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1194 }
1195
1196 #[test]
1197 fn resolve_rejects_extension_setup_with_parent_dir() {
1198 let dir = tempfile::tempdir().unwrap();
1199 let m = manifest_with_extension_setup(Some("../escape.sh"));
1200 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1201 assert!(
1202 matches!(err, SetupError::EscapesPluginDir { .. }),
1203 "got {err:?}"
1204 );
1205 }
1206
1207 #[test]
1208 fn resolve_rejects_extension_setup_when_absolute() {
1209 let dir = tempfile::tempdir().unwrap();
1210 let m = manifest_with_extension_setup(Some("/etc/passwd"));
1211 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1212 assert!(
1213 matches!(err, SetupError::EscapesPluginDir { .. }),
1214 "got {err:?}"
1215 );
1216 }
1217
1218 #[test]
1219 fn resolve_returns_not_found_for_missing_extension_setup() {
1220 let dir = tempfile::tempdir().unwrap();
1221 let m = manifest_with_extension_setup(Some("scripts/missing.sh"));
1222 let err = resolve_setup_script(&m, dir.path()).unwrap_err();
1223 assert!(matches!(err, SetupError::NotFound { .. }), "got {err:?}");
1224 }
1225
1226 #[test]
1227 fn resolve_prefers_extension_setup_over_sidecar_setup() {
1228 let dir = tempfile::tempdir().unwrap();
1232 let scripts = dir.path().join("scripts");
1233 fs::create_dir(&scripts).unwrap();
1234 fs::write(scripts.join("ext.sh"), "#!/bin/bash\necho ext").unwrap();
1235 fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
1236 let m = manifest_with_both_setup("scripts/ext.sh", "scripts/side.sh");
1237 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1238 assert!(
1239 resolved.ends_with("scripts/ext.sh"),
1240 "expected extension setup to win, got {resolved:?}"
1241 );
1242 }
1243
1244 #[test]
1245 fn resolve_falls_back_to_sidecar_when_extension_has_no_setup() {
1246 let dir = tempfile::tempdir().unwrap();
1250 let scripts = dir.path().join("scripts");
1251 fs::create_dir(&scripts).unwrap();
1252 fs::write(scripts.join("side.sh"), "#!/bin/bash\necho side").unwrap();
1253 let mut m = manifest_with_extension_setup(None); m.provides = Some(PluginProvides {
1255 sidecar: Some(SidecarManifest {
1256 command: "bin/sidecar".to_string(),
1257 setup: Some("scripts/side.sh".to_string()),
1258 protocol_version: 1,
1259 model: None,
1260 lifecycle: None,
1261 }),
1262 });
1263 let resolved = resolve_setup_script(&m, dir.path()).unwrap().unwrap();
1264 assert!(resolved.ends_with("scripts/side.sh"));
1265 }
1266
1267 #[test]
1268 fn resolve_returns_none_when_neither_slot_has_setup() {
1269 let dir = tempfile::tempdir().unwrap();
1272 let mut m = manifest_with_extension_setup(None);
1273 m.provides = Some(PluginProvides {
1274 sidecar: Some(SidecarManifest {
1275 command: "bin/sidecar".to_string(),
1276 setup: None,
1277 protocol_version: 1,
1278 model: None,
1279 lifecycle: None,
1280 }),
1281 });
1282 assert!(resolve_setup_script(&m, dir.path()).unwrap().is_none());
1283 }
1284
1285 fn manifest_with_extension_command(command: &str) -> PluginManifest {
1289 PluginManifest {
1290 name: "test-plugin".to_string(),
1291 version: None,
1292 description: None,
1293 keybinds: vec![],
1294 compatibility: None,
1295 commands: vec![],
1296 extension: Some(ExtensionManifest {
1297 protocol_version: 1,
1298 runtime: ExtensionRuntime::Process,
1299 command: command.to_string(),
1300 setup: None,
1301 prebuilt: ::std::collections::HashMap::new(),
1302 args: vec![],
1303 permissions: vec![],
1304 hooks: vec![],
1305 config: vec![],
1306 }),
1307 help_entries: vec![],
1308 provides: None,
1309 settings: None,
1310 }
1311 }
1312
1313 #[test]
1314 fn verify_returns_ok_none_when_no_extension() {
1315 let dir = tempfile::tempdir().unwrap();
1316 let m = manifest_with_setup(None); assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1318 }
1319
1320 #[test]
1321 fn verify_returns_ok_none_for_bare_command_name() {
1322 let dir = tempfile::tempdir().unwrap();
1324 let m = manifest_with_extension_command("python3");
1325 assert_eq!(verify_extension_command(&m, dir.path()).unwrap(), None);
1326 }
1327
1328 #[test]
1329 fn verify_succeeds_when_relative_binary_exists_and_is_executable() {
1330 let dir = tempfile::tempdir().unwrap();
1331 let bin = dir.path().join("bin/ext");
1332 fs::create_dir_all(bin.parent().unwrap()).unwrap();
1333 fs::write(&bin, "#!/bin/sh\necho ok").unwrap();
1334 #[cfg(unix)]
1335 {
1336 use std::os::unix::fs::PermissionsExt;
1337 fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
1338 }
1339 let m = manifest_with_extension_command("bin/ext");
1340 let resolved = verify_extension_command(&m, dir.path()).unwrap();
1341 assert!(resolved.is_some(), "should return resolved path");
1342 }
1343
1344 #[test]
1345 fn verify_returns_missing_when_binary_absent() {
1346 let dir = tempfile::tempdir().unwrap();
1347 let m = manifest_with_extension_command("bin/ext");
1348 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1349 assert!(matches!(err, CommandVerifyError::Missing { .. }), "got: {err:?}");
1350 }
1351
1352 #[cfg(unix)]
1353 #[test]
1354 fn verify_returns_not_executable_when_bit_missing() {
1355 let dir = tempfile::tempdir().unwrap();
1356 let bin = dir.path().join("bin/ext");
1357 fs::create_dir_all(bin.parent().unwrap()).unwrap();
1358 fs::write(&bin, "data").unwrap();
1359 use std::os::unix::fs::PermissionsExt;
1360 fs::set_permissions(&bin, fs::Permissions::from_mode(0o644)).unwrap();
1361 let m = manifest_with_extension_command("bin/ext");
1362 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1363 assert!(matches!(err, CommandVerifyError::NotExecutable { .. }), "got: {err:?}");
1364 }
1365
1366 #[test]
1367 fn verify_returns_not_a_file_when_path_is_directory() {
1368 let dir = tempfile::tempdir().unwrap();
1369 let bin = dir.path().join("bin/ext");
1370 fs::create_dir_all(&bin).unwrap();
1371 let m = manifest_with_extension_command("bin/ext");
1372 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1373 assert!(matches!(err, CommandVerifyError::NotAFile { .. }), "got: {err:?}");
1374 }
1375
1376 #[test]
1377 fn verify_rejects_parent_dir_traversal_in_command() {
1378 let dir = tempfile::tempdir().unwrap();
1379 let m = manifest_with_extension_command("../escape/bin");
1380 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1381 assert!(matches!(err, CommandVerifyError::EscapesPluginDir { .. }), "got: {err:?}");
1382 }
1383
1384 #[cfg(unix)]
1385 #[test]
1386 fn verify_rejects_symlink_pointing_outside_plugin_dir() {
1387 let outer = tempfile::tempdir().unwrap();
1388 let plugin = tempfile::tempdir().unwrap();
1389 let target = outer.path().join("real-bin");
1391 fs::write(&target, "x").unwrap();
1392 use std::os::unix::fs::PermissionsExt;
1393 fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap();
1394 let link = plugin.path().join("bin/ext");
1396 fs::create_dir_all(link.parent().unwrap()).unwrap();
1397 std::os::unix::fs::symlink(&target, &link).unwrap();
1398 let m = manifest_with_extension_command("bin/ext");
1399 let err = verify_extension_command(&m, plugin.path()).unwrap_err();
1400 assert!(
1401 matches!(err, CommandVerifyError::EscapesPluginDir { .. }),
1402 "got: {err:?}"
1403 );
1404 }
1405
1406
1407 #[test]
1408 fn verify_rejects_absolute_extension_command() {
1409 let dir = tempfile::tempdir().unwrap();
1410 let m = manifest_with_extension_command("/tmp/ext");
1411 let err = verify_extension_command(&m, dir.path()).unwrap_err();
1412 assert!(matches!(err, CommandVerifyError::EscapesPluginDir { .. }), "got: {err:?}");
1413 }
1414
1415 #[test]
1416 fn policy_normalizes_sha256_and_sanitizes_names() {
1417 assert_eq!(normalize_sha256(&"A".repeat(64)).unwrap(), "a".repeat(64));
1418 assert!(normalize_sha256("not-a-sha").is_none());
1419 assert_eq!(safe_name_fragment("../bad\nname"), "bad_name");
1420 assert_eq!(safe_name_fragment("normal-name_1.2"), "normal-name_1.2");
1421 }
1422
1423 fn manifest_with_prebuilt(
1426 command: &str,
1427 triple: &str,
1428 url: &str,
1429 sha256: &str,
1430 ) -> PluginManifest {
1431 let mut prebuilt = std::collections::HashMap::new();
1432 prebuilt.insert(
1433 triple.to_string(),
1434 crate::extensions::manifest::PrebuiltAsset {
1435 url: url.to_string(),
1436 sha256: sha256.to_string(),
1437 },
1438 );
1439 PluginManifest {
1440 name: "test-plugin".to_string(),
1441 version: None,
1442 description: None,
1443 keybinds: vec![],
1444 compatibility: None,
1445 commands: vec![],
1446 extension: Some(ExtensionManifest {
1447 protocol_version: 1,
1448 runtime: ExtensionRuntime::Process,
1449 command: command.to_string(),
1450 setup: None,
1451 prebuilt,
1452 args: vec![],
1453 permissions: vec![],
1454 hooks: vec![],
1455 config: vec![],
1456 }),
1457 help_entries: vec![],
1458 provides: None,
1459 settings: None,
1460 }
1461 }
1462
1463 fn mk_tarball(staging: &Path, archive_name: &str, inner_path: &str) -> (PathBuf, String) {
1467 let work = staging.join("staging");
1468 fs::create_dir_all(&work).unwrap();
1469 let payload = work.join(inner_path);
1470 fs::create_dir_all(payload.parent().unwrap()).unwrap();
1471 fs::write(&payload, "#!/bin/sh\necho prebuilt-bin\n").unwrap();
1472 #[cfg(unix)]
1473 {
1474 use std::os::unix::fs::PermissionsExt;
1475 fs::set_permissions(&payload, fs::Permissions::from_mode(0o755)).unwrap();
1476 }
1477 let archive = staging.join(archive_name);
1478 let out = std::process::Command::new("tar")
1479 .arg("-czf")
1480 .arg(&archive)
1481 .arg("-C")
1482 .arg(&work)
1483 .arg(inner_path)
1484 .output()
1485 .expect("system tar must be present");
1486 assert!(out.status.success(), "tar failed: {:?}", out);
1487 let bytes = fs::read(&archive).unwrap();
1488 use sha2::{Digest, Sha256};
1489 let mut h = Sha256::new();
1490 h.update(&bytes);
1491 let sha = hex_encode_lower(&h.finalize());
1492 (archive, sha)
1493 }
1494
1495 #[tokio::test(flavor = "current_thread")]
1496 async fn prebuilt_returns_no_matching_asset_when_triple_missing() {
1497 let dir = tempfile::tempdir().unwrap();
1498 let m = manifest_with_prebuilt("bin/ext", "fake-triple-9999", "https://x", "00");
1500 let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1501 assert!(matches!(err, PrebuiltError::NoMatchingAsset), "got: {err:?}");
1502 }
1503
1504 #[tokio::test(flavor = "current_thread")]
1505 async fn prebuilt_rejects_non_https_url_in_production_builds() {
1506 let dir = tempfile::tempdir().unwrap();
1508 let triple = host_triple().expect("supported host");
1509 let m = manifest_with_prebuilt("bin/ext", triple, "http://example.com/x.tar.gz", "00");
1510 let err = try_install_from_prebuilt(&m, dir.path()).await.unwrap_err();
1511 assert!(matches!(err, PrebuiltError::UnsafeUrl { .. }), "got: {err:?}");
1512 }
1513
1514 #[tokio::test(flavor = "current_thread")]
1515 async fn prebuilt_succeeds_with_valid_tarball_and_checksum() {
1516 let staging = tempfile::tempdir().unwrap();
1517 let plugin = tempfile::tempdir().unwrap();
1518 let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1519 let url = format!("file://{}", archive.display());
1520 let triple = host_triple().expect("supported host");
1521 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1522 let resolved = try_install_from_prebuilt(&m, plugin.path()).await.unwrap();
1523 assert!(resolved.exists(), "extracted binary should exist at {}", resolved.display());
1524 let leftover = plugin.path().join(format!(".prebuilt-{}-download", triple));
1526 assert!(!leftover.exists(), "temp archive should be removed");
1527 }
1528
1529 #[tokio::test(flavor = "current_thread")]
1530 async fn prebuilt_aborts_on_checksum_mismatch_without_extracting() {
1531 let staging = tempfile::tempdir().unwrap();
1532 let plugin = tempfile::tempdir().unwrap();
1533 let (archive, _real_sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/ext");
1534 let url = format!("file://{}", archive.display());
1535 let triple = host_triple().expect("supported host");
1536 let bad_sha = "0".repeat(64);
1537 let m = manifest_with_prebuilt("bin/ext", triple, &url, &bad_sha);
1538 let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
1539 match err {
1540 PrebuiltError::ChecksumMismatch { expected, actual } => {
1541 assert_eq!(expected, bad_sha);
1542 assert_eq!(actual.len(), 64, "actual sha should be lowercase hex");
1543 }
1544 other => panic!("expected ChecksumMismatch, got {other:?}"),
1545 }
1546 assert!(!plugin.path().join("bin/ext").exists());
1548 }
1549
1550 #[tokio::test(flavor = "current_thread")]
1551 async fn prebuilt_rejects_unsupported_archive_suffix() {
1552 let staging = tempfile::tempdir().unwrap();
1553 let plugin = tempfile::tempdir().unwrap();
1554 let archive = staging.path().join("ext.rar");
1556 fs::write(&archive, b"not really a rar").unwrap();
1557 let bytes = fs::read(&archive).unwrap();
1558 use sha2::{Digest, Sha256};
1559 let mut h = Sha256::new();
1560 h.update(&bytes);
1561 let sha = hex_encode_lower(&h.finalize());
1562 let url = format!("file://{}", archive.display());
1563 let triple = host_triple().expect("supported host");
1564 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1565 let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
1566 assert!(matches!(err, PrebuiltError::UnsupportedArchive { .. }), "got: {err:?}");
1567 }
1568
1569 #[tokio::test(flavor = "current_thread")]
1570 async fn prebuilt_fails_verify_when_archive_does_not_contain_declared_command() {
1571 let staging = tempfile::tempdir().unwrap();
1572 let plugin = tempfile::tempdir().unwrap();
1573 let (archive, sha) = mk_tarball(staging.path(), "ext.tar.gz", "bin/wrong-name");
1575 let url = format!("file://{}", archive.display());
1576 let triple = host_triple().expect("supported host");
1577 let m = manifest_with_prebuilt("bin/ext", triple, &url, &sha);
1578 let err = try_install_from_prebuilt(&m, plugin.path()).await.unwrap_err();
1579 assert!(matches!(err, PrebuiltError::Verify(_)), "got: {err:?}");
1580 }
1581}