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;
63
64const PREBUILT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
65const PREBUILT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
66
67pub fn safe_name_fragment(input: &str) -> String {
71 let mut out = String::with_capacity(input.len().min(80));
72 for ch in input.chars().take(80) {
73 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
74 out.push(ch);
75 } else {
76 out.push('_');
77 }
78 }
79 let trimmed = out.trim_matches('.').trim_matches('_').to_string();
80 if trimmed.is_empty() || trimmed == ".." {
81 "plugin".to_string()
82 } else {
83 trimmed
84 }
85}
86
87pub fn normalize_sha256(value: &str) -> Option<String> {
90 let trimmed = value.trim();
91 if trimmed.len() == 64 && trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
92 Some(trimmed.to_ascii_lowercase())
93 } else {
94 None
95 }
96}
97
98fn prebuilt_url_allowed(url: &str) -> bool {
99 if url.starts_with("https://") {
100 return true;
101 }
102 #[cfg(test)]
103 {
104 if url.starts_with("file://") {
105 return true;
106 }
107 }
108 false
109}
110
111fn archive_suffix(url_for_suffix: &str) -> Result<&'static str, PrebuiltError> {
112 let url_clean = url_for_suffix
113 .split(['?', '#'])
114 .next()
115 .unwrap_or(url_for_suffix)
116 .to_ascii_lowercase();
117 if url_clean.ends_with(".tar.gz") || url_clean.ends_with(".tgz") {
118 Ok("tar.gz")
119 } else if url_clean.ends_with(".zip") {
120 Ok("zip")
121 } else if url_clean.ends_with(".tar.xz") || url_clean.ends_with(".tar.bz2") {
122 Err(PrebuiltError::UnsupportedArchive {
123 url: format!("{url_for_suffix} (xz/bz2 prebuilt archives are not supported by the hardened extractor; use .tar.gz or .zip)"),
124 })
125 } else {
126 Err(PrebuiltError::UnsupportedArchive {
127 url: url_for_suffix.to_string(),
128 })
129 }
130}
131
132fn validate_archive_relative_path(path: &Path) -> Result<(), PrebuiltError> {
133 if path.is_absolute() {
134 return Err(PrebuiltError::Extract(format!(
135 "archive entry '{}' is absolute",
136 path.display()
137 )));
138 }
139 if path.components().any(|c| matches!(c, Component::ParentDir | Component::Prefix(_))) {
140 return Err(PrebuiltError::Extract(format!(
141 "archive entry '{}' escapes extraction directory",
142 path.display()
143 )));
144 }
145 Ok(())
146}
147
148fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
149 for entry in std::fs::read_dir(src)? {
150 let entry = entry?;
151 let from = entry.path();
152 let to = dest.join(entry.file_name());
153 let meta = entry.file_type()?;
154 if meta.is_dir() {
155 std::fs::create_dir_all(&to)?;
156 copy_dir_contents(&from, &to)?;
157 } else if meta.is_file() {
158 if let Some(parent) = to.parent() {
159 std::fs::create_dir_all(parent)?;
160 }
161 std::fs::copy(&from, &to)?;
162 #[cfg(unix)]
163 {
164 use std::os::unix::fs::PermissionsExt;
165 let mode = std::fs::metadata(&from)?.permissions().mode();
166 std::fs::set_permissions(&to, std::fs::Permissions::from_mode(mode))?;
167 }
168 }
169 }
170 Ok(())
171}
172
173pub fn host_triple() -> Option<&'static str> {
174 let os = if cfg!(target_os = "linux") {
175 "linux"
176 } else if cfg!(target_os = "macos") {
177 "darwin"
178 } else if cfg!(target_os = "windows") {
179 "windows"
180 } else {
181 return None;
182 };
183 let arch = if cfg!(target_arch = "x86_64") {
184 "x86_64"
185 } else if cfg!(target_arch = "aarch64") {
186 "arm64"
187 } else {
188 return None;
189 };
190 Some(match (os, arch) {
192 ("linux", "x86_64") => "linux-x86_64",
193 ("linux", "arm64") => "linux-arm64",
194 ("darwin", "x86_64") => "darwin-x86_64",
195 ("darwin", "arm64") => "darwin-arm64",
196 ("windows", "x86_64") => "windows-x86_64",
197 ("windows", "arm64") => "windows-arm64",
198 _ => return None,
199 })
200}
201
202pub const SETUP_TIMEOUT: Duration = Duration::from_secs(600);
207
208#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct SetupOutcome {
211 pub log_path: PathBuf,
213 pub exit_code: i32,
215}
216
217#[derive(Debug, thiserror::Error)]
219pub enum SetupError {
220 #[error("setup script path '{path}' escapes plugin directory")]
223 EscapesPluginDir { path: String },
224
225 #[error("setup script '{path}' not found in plugin directory")]
228 NotFound { path: String },
229
230 #[error("setup script exited with code {exit_code}; see {}", log_path.display())]
233 NonZeroExit { exit_code: i32, log_path: PathBuf },
234
235 #[error("setup script timed out after {secs}s; see {}", log_path.display())]
237 Timeout { secs: u64, log_path: PathBuf },
238
239 #[error("setup script io: {0}")]
241 Io(#[from] std::io::Error),
242}
243
244#[derive(Debug, thiserror::Error, PartialEq, Eq)]
249pub enum CommandVerifyError {
250 #[error("extension command path '{path}' escapes plugin directory")]
253 EscapesPluginDir { path: String },
254
255 #[error("extension command '{path}' does not exist (resolved to {})", resolved.display())]
259 Missing { path: String, resolved: PathBuf },
260
261 #[cfg(unix)]
265 #[error("extension command '{path}' exists but is not executable (mode {mode:o})")]
266 NotExecutable { path: String, mode: u32 },
267
268 #[error("extension command '{path}' is a directory, not a file")]
270 NotAFile { path: String },
271}
272
273pub fn verify_extension_command(
294 manifest: &PluginManifest,
295 plugin_dir: &Path,
296) -> Result<Option<PathBuf>, CommandVerifyError> {
297 let Some(ext) = manifest.extension.as_ref() else {
298 return Ok(None);
299 };
300 let cmd = &ext.command;
301 let cmd_path = Path::new(cmd);
302
303 if !cmd.contains(std::path::MAIN_SEPARATOR) && !cmd.contains('/') {
305 return Ok(None);
306 }
307
308 let resolved = if cmd_path.is_absolute() {
309 return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
310 } else {
311 if cmd_path
313 .components()
314 .any(|c| matches!(c, std::path::Component::ParentDir))
315 {
316 return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
317 }
318 let joined = plugin_dir.join(cmd_path);
319 match joined.canonicalize() {
320 Ok(p) => {
321 let canonical_dir = plugin_dir
322 .canonicalize()
323 .unwrap_or_else(|_| plugin_dir.to_path_buf());
324 if !p.starts_with(&canonical_dir) {
325 return Err(CommandVerifyError::EscapesPluginDir { path: cmd.clone() });
326 }
327 p
328 }
329 Err(_) => {
330 return Err(CommandVerifyError::Missing {
331 path: cmd.clone(),
332 resolved: joined,
333 });
334 }
335 }
336 };
337
338 if !resolved.exists() {
339 return Err(CommandVerifyError::Missing {
340 path: cmd.clone(),
341 resolved,
342 });
343 }
344 let meta = std::fs::metadata(&resolved).map_err(|_| CommandVerifyError::Missing {
345 path: cmd.clone(),
346 resolved: resolved.clone(),
347 })?;
348 if meta.is_dir() {
349 return Err(CommandVerifyError::NotAFile { path: cmd.clone() });
350 }
351 #[cfg(unix)]
352 {
353 use std::os::unix::fs::PermissionsExt;
354 let mode = meta.permissions().mode();
355 if mode & 0o111 == 0 {
357 return Err(CommandVerifyError::NotExecutable {
358 path: cmd.clone(),
359 mode,
360 });
361 }
362 }
363 Ok(Some(resolved))
364}
365
366#[derive(Debug, thiserror::Error)]
372pub enum PrebuiltError {
373 #[error("no prebuilt asset declared for this host")]
377 NoMatchingAsset,
378
379 #[error("download failed: {0}")]
381 Download(String),
382
383 #[error("checksum mismatch: expected {expected}, got {actual}")]
387 ChecksumMismatch { expected: String, actual: String },
388
389 #[error("archive extraction failed: {0}")]
391 Extract(String),
392
393 #[error("unsupported archive type for url '{url}'")]
396 UnsupportedArchive { url: String },
397
398 #[error("refusing non-https prebuilt url '{url}'")]
400 UnsafeUrl { url: String },
401
402 #[error("invalid sha256 '{sha256}'; expected exactly 64 hex characters")]
404 InvalidSha256 { sha256: String },
405
406 #[error("prebuilt archive exceeds maximum size of {max} bytes")]
408 TooLarge { max: u64 },
409
410 #[error("io: {0}")]
412 Io(#[from] std::io::Error),
413
414 #[error("prebuilt extracted but extension command not found: {0}")]
417 Verify(#[from] CommandVerifyError),
418}
419
420fn hex_encode_lower(bytes: &[u8]) -> String {
423 let mut out = String::with_capacity(bytes.len() * 2);
424 for b in bytes {
425 out.push(char::from_digit((*b >> 4) as u32, 16).unwrap());
426 out.push(char::from_digit((*b & 0x0f) as u32, 16).unwrap());
427 }
428 out
429}
430
431pub async fn try_install_from_prebuilt(
447 manifest: &PluginManifest,
448 plugin_dir: &Path,
449) -> Result<PathBuf, PrebuiltError> {
450 let Some(ext) = manifest.extension.as_ref() else {
451 return Err(PrebuiltError::NoMatchingAsset);
452 };
453 let Some(triple) = host_triple() else {
454 return Err(PrebuiltError::NoMatchingAsset);
455 };
456 let Some(asset) = ext.prebuilt.get(triple) else {
457 return Err(PrebuiltError::NoMatchingAsset);
458 };
459
460 if !prebuilt_url_allowed(&asset.url) {
461 return Err(PrebuiltError::UnsafeUrl {
462 url: asset.url.clone(),
463 });
464 }
465 let expected_sha = normalize_sha256(&asset.sha256).ok_or_else(|| PrebuiltError::InvalidSha256 {
466 sha256: asset.sha256.clone(),
467 })?;
468
469 let tmp_archive = plugin_dir.join(format!(".prebuilt-{triple}-download"));
470 match std::fs::remove_file(&tmp_archive) {
471 Ok(()) => {}
472 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
473 Err(e) => return Err(PrebuiltError::Io(e)),
474 }
475
476 let download_res = download_prebuilt_to_file(&asset.url, &tmp_archive, MAX_PREBUILT_ARCHIVE_BYTES).await;
477 let actual = match download_res {
478 Ok(sha) => sha,
479 Err(e) => {
480 let _ = std::fs::remove_file(&tmp_archive);
481 return Err(e);
482 }
483 };
484 if actual != expected_sha {
485 let _ = std::fs::remove_file(&tmp_archive);
486 return Err(PrebuiltError::ChecksumMismatch {
487 expected: expected_sha,
488 actual,
489 });
490 }
491
492 let archive = tmp_archive.clone();
493 let dest = plugin_dir.to_path_buf();
494 let url = asset.url.clone();
495 let extract_res = tokio::task::spawn_blocking(move || extract_archive(&archive, &dest, &url))
496 .await
497 .map_err(|e| PrebuiltError::Extract(format!("extract task join error: {e}")))?;
498 let _ = std::fs::remove_file(&tmp_archive);
499 extract_res?;
500
501 let resolved = verify_extension_command(manifest, plugin_dir)?
503 .ok_or_else(|| {
504 PrebuiltError::Verify(CommandVerifyError::Missing {
505 path: ext.command.clone(),
506 resolved: plugin_dir.join(&ext.command),
507 })
508 })?;
509 Ok(resolved)
510}
511
512async fn download_prebuilt_to_file(
513 url: &str,
514 tmp_archive: &Path,
515 max_bytes: u64,
516) -> Result<String, PrebuiltError> {
517 use sha2::{Digest, Sha256};
518
519 let mut hasher = Sha256::new();
520 let mut written: u64 = 0;
521
522 if let Some(path) = url.strip_prefix("file://") {
523 #[cfg(not(test))]
524 {
525 let _ = path;
526 return Err(PrebuiltError::UnsafeUrl { url: url.to_string() });
527 }
528 #[cfg(test)]
529 {
530 let mut input = std::fs::File::open(path)
531 .map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
532 let mut output = std::fs::File::create(tmp_archive)?;
533 let mut buf = [0u8; 8192];
534 loop {
535 let n = std::io::Read::read(&mut input, &mut buf)
536 .map_err(|e| PrebuiltError::Download(format!("file read {path}: {e}")))?;
537 if n == 0 {
538 break;
539 }
540 written += n as u64;
541 if written > max_bytes {
542 return Err(PrebuiltError::TooLarge { max: max_bytes });
543 }
544 hasher.update(&buf[..n]);
545 std::io::Write::write_all(&mut output, &buf[..n])?;
546 }
547 return Ok(hex_encode_lower(&hasher.finalize()));
548 }
549 }
550
551 let client = reqwest::Client::builder()
552 .connect_timeout(PREBUILT_CONNECT_TIMEOUT)
553 .timeout(PREBUILT_REQUEST_TIMEOUT)
554 .build()
555 .map_err(|e| PrebuiltError::Download(e.to_string()))?;
556 let mut response = client
557 .get(url)
558 .send()
559 .await
560 .map_err(|e| PrebuiltError::Download(e.to_string()))?;
561 if !response.status().is_success() {
562 return Err(PrebuiltError::Download(format!("HTTP {}", response.status())));
563 }
564 if let Some(len) = response.content_length() {
565 if len > max_bytes {
566 return Err(PrebuiltError::TooLarge { max: max_bytes });
567 }
568 }
569
570 let mut output = tokio::fs::File::create(tmp_archive).await?;
571 while let Some(chunk) = response
572 .chunk()
573 .await
574 .map_err(|e| PrebuiltError::Download(e.to_string()))?
575 {
576 written += chunk.len() as u64;
577 if written > max_bytes {
578 return Err(PrebuiltError::TooLarge { max: max_bytes });
579 }
580 hasher.update(&chunk);
581 tokio::io::AsyncWriteExt::write_all(&mut output, &chunk).await?;
582 }
583 tokio::io::AsyncWriteExt::flush(&mut output).await?;
584 Ok(hex_encode_lower(&hasher.finalize()))
585}
586
587fn extract_archive(
592 archive: &Path,
593 dest_dir: &Path,
594 url_for_suffix: &str,
595) -> Result<(), PrebuiltError> {
596 let kind = archive_suffix(url_for_suffix)?;
597 let extract_root = dest_dir.join(format!(
598 ".prebuilt-extract-{}",
599 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
600 ));
601 match std::fs::remove_dir_all(&extract_root) {
602 Ok(()) => {}
603 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
604 Err(e) => return Err(PrebuiltError::Io(e)),
605 }
606 std::fs::create_dir_all(&extract_root)?;
607 let result = match kind {
608 "tar.gz" => extract_tar_gz_safe(archive, &extract_root),
609 "zip" => extract_zip_safe(archive, &extract_root),
610 _ => unreachable!(),
611 }
612 .and_then(|_| copy_dir_contents(&extract_root, dest_dir).map_err(PrebuiltError::Io));
613 let cleanup = std::fs::remove_dir_all(&extract_root);
614 match (result, cleanup) {
615 (Ok(()), Ok(())) => Ok(()),
616 (Ok(()), Err(e)) => Err(PrebuiltError::Extract(format!(
617 "failed to clean extraction directory {}: {e}",
618 extract_root.display()
619 ))),
620 (Err(e), _) => Err(e),
621 }
622}
623
624fn extract_tar_gz_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
625 use std::process::{Command, Stdio};
626
627 let list = Command::new("tar")
628 .arg("-tzf")
629 .arg(archive)
630 .output()
631 .map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
632 if !list.status.success() {
633 return Err(PrebuiltError::Extract(format!(
634 "tar list exited {}: {}",
635 list.status,
636 String::from_utf8_lossy(&list.stderr).trim()
637 )));
638 }
639 for line in String::from_utf8_lossy(&list.stdout).lines() {
640 validate_archive_relative_path(Path::new(line))?;
641 }
642 let out = Command::new("tar")
643 .arg("--no-same-owner")
644 .arg("--no-same-permissions")
645 .arg("-xzf")
646 .arg(archive)
647 .arg("-C")
648 .arg(root)
649 .stdin(Stdio::null())
650 .output()
651 .map_err(|e| PrebuiltError::Extract(format!("spawn tar: {e}")))?;
652 if !out.status.success() {
653 return Err(PrebuiltError::Extract(format!(
654 "tar exited {}: {}",
655 out.status,
656 String::from_utf8_lossy(&out.stderr).trim()
657 )));
658 }
659 validate_extracted_tree(root)
660}
661
662fn extract_zip_safe(archive: &Path, root: &Path) -> Result<(), PrebuiltError> {
663 use std::process::{Command, Stdio};
664
665 let list = Command::new("unzip")
666 .arg("-Z1")
667 .arg(archive)
668 .output()
669 .map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
670 if !list.status.success() {
671 return Err(PrebuiltError::Extract(format!(
672 "unzip list exited {}: {}",
673 list.status,
674 String::from_utf8_lossy(&list.stderr).trim()
675 )));
676 }
677 for line in String::from_utf8_lossy(&list.stdout).lines() {
678 validate_archive_relative_path(Path::new(line))?;
679 }
680 let out = Command::new("unzip")
681 .arg("-q")
682 .arg(archive)
683 .arg("-d")
684 .arg(root)
685 .stdin(Stdio::null())
686 .output()
687 .map_err(|e| PrebuiltError::Extract(format!("spawn unzip: {e}")))?;
688 if !out.status.success() {
689 return Err(PrebuiltError::Extract(format!(
690 "unzip exited {}: {}",
691 out.status,
692 String::from_utf8_lossy(&out.stderr).trim()
693 )));
694 }
695 validate_extracted_tree(root)
696}
697
698fn validate_extracted_tree(root: &Path) -> Result<(), PrebuiltError> {
699 let canonical_root = root.canonicalize()?;
700 fn walk(path: &Path, root: &Path) -> Result<(), PrebuiltError> {
701 for entry in std::fs::read_dir(path)? {
702 let entry = entry?;
703 let ty = entry.file_type()?;
704 let p = entry.path();
705 if ty.is_symlink() {
706 let target = std::fs::canonicalize(&p).map_err(|e| {
707 PrebuiltError::Extract(format!("symlink '{}' cannot be resolved: {e}", p.display()))
708 })?;
709 if !target.starts_with(root) {
710 return Err(PrebuiltError::Extract(format!(
711 "symlink '{}' escapes extraction directory",
712 p.display()
713 )));
714 }
715 } else if ty.is_dir() {
716 let c = p.canonicalize()?;
717 if !c.starts_with(root) {
718 return Err(PrebuiltError::Extract(format!(
719 "directory '{}' escapes extraction directory",
720 p.display()
721 )));
722 }
723 walk(&p, root)?;
724 } else if ty.is_file() {
725 let c = p.canonicalize()?;
726 if !c.starts_with(root) {
727 return Err(PrebuiltError::Extract(format!(
728 "file '{}' escapes extraction directory",
729 p.display()
730 )));
731 }
732 } else {
733 return Err(PrebuiltError::Extract(format!(
734 "unsupported archive entry type '{}'",
735 p.display()
736 )));
737 }
738 }
739 Ok(())
740 }
741 walk(&canonical_root, &canonical_root)
742}
743
744pub fn resolve_setup_script(
755 manifest: &PluginManifest,
756 plugin_dir: &Path,
757) -> Result<Option<PathBuf>, SetupError> {
758 if let Some(ext) = manifest.extension.as_ref() {
762 if let Some(setup) = ext.setup.as_deref() {
763 return validate_setup_path(setup, plugin_dir).map(Some);
764 }
765 }
766 if let Some(provides) = manifest.provides.as_ref() {
768 if let Some(sidecar) = provides.sidecar.as_ref() {
769 if let Some(setup) = sidecar.setup.as_deref() {
770 return validate_setup_path(setup, plugin_dir).map(Some);
771 }
772 }
773 }
774 Ok(None)
775}
776
777fn validate_setup_path(setup: &str, plugin_dir: &Path) -> Result<PathBuf, SetupError> {
783 let setup_path = Path::new(setup);
784 if setup_path.is_absolute()
785 || setup_path
786 .components()
787 .any(|c| matches!(c, std::path::Component::ParentDir))
788 {
789 return Err(SetupError::EscapesPluginDir {
790 path: setup.to_string(),
791 });
792 }
793 let joined = plugin_dir.join(setup_path);
794 let canonical = match joined.canonicalize() {
795 Ok(p) => p,
796 Err(_) => {
797 return Err(SetupError::NotFound {
798 path: setup.to_string(),
799 });
800 }
801 };
802 let canonical_dir = plugin_dir
803 .canonicalize()
804 .unwrap_or_else(|_| plugin_dir.to_path_buf());
805 if !canonical.starts_with(&canonical_dir) {
806 return Err(SetupError::EscapesPluginDir {
807 path: setup.to_string(),
808 });
809 }
810 Ok(canonical)
811}
812
813pub fn install_log_path(logs_root: &Path, plugin_name: &str, now_rfc3339: &str) -> PathBuf {
821 let safe_ts = safe_name_fragment(&now_rfc3339.replace(':', "-"));
822 let safe_plugin = safe_name_fragment(plugin_name);
823 logs_root
824 .join("install")
825 .join(format!("{safe_plugin}-{safe_ts}.log"))
826}
827
828pub async fn run_setup_script(
839 script: &Path,
840 plugin_dir: &Path,
841 log_path: &Path,
842 timeout: Duration,
843) -> Result<SetupOutcome, SetupError> {
844 use std::process::Stdio;
845 use tokio::io::AsyncWriteExt;
846 use tokio::process::Command;
847
848 if let Some(parent) = log_path.parent() {
849 std::fs::create_dir_all(parent)?;
850 }
851 let mut log_file = tokio::fs::File::create(log_path).await?;
852 let header = format!(
853 "$ bash {} (cwd: {})\n",
854 script.display(),
855 plugin_dir.display()
856 );
857 log_file.write_all(header.as_bytes()).await?;
858
859 if cfg!(windows) {
860 return Err(SetupError::Io(std::io::Error::new(
861 std::io::ErrorKind::Unsupported,
862 "setup scripts require bash and are not supported on Windows in this release",
863 )));
864 }
865
866 let mut cmd = Command::new("bash");
867 cmd.arg(script)
868 .current_dir(plugin_dir)
869 .env_clear()
870 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
871 .env("HOME", std::env::var_os("HOME").unwrap_or_default())
872 .env("USER", std::env::var_os("USER").unwrap_or_default())
873 .env("SHELL", std::env::var_os("SHELL").unwrap_or_default())
874 .env("SYNAPS_PLUGIN_DIR", plugin_dir)
875 .stdin(Stdio::null())
876 .stdout(Stdio::piped())
877 .stderr(Stdio::piped())
878 .kill_on_drop(true);
879
880 let mut child = cmd.spawn()?;
881 let mut stdout = child.stdout.take().expect("piped stdout");
882 let mut stderr = child.stderr.take().expect("piped stderr");
883
884 let copy_out = async {
885 tokio::io::copy(&mut stdout, &mut log_file).await?;
886 log_file.flush().await?;
887 Ok::<_, std::io::Error>(log_file)
888 };
889 let collect_err = async {
890 let mut buf = Vec::new();
891 tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut buf).await?;
892 Ok::<_, std::io::Error>(buf)
893 };
894
895 let wait = async {
896 let (out_res, err_res, status) = tokio::join!(copy_out, collect_err, child.wait());
897 let mut log_file = out_res?;
898 let err_buf = err_res?;
899 if !err_buf.is_empty() {
900 log_file.write_all(b"\n--- stderr ---\n").await?;
901 log_file.write_all(&err_buf).await?;
902 log_file.flush().await?;
903 }
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}