1use std::fs;
20use std::io::{self, Read};
21use std::path::{Path, PathBuf};
22use std::process::{Child, Command, Stdio};
23
24use sha2::{Digest, Sha256};
25
26const REPO: &str = "mock-server/mockserver-monorepo";
31
32pub const VERSION: &str = env!("CARGO_PKG_VERSION");
35
36const HTTP_TIMEOUT_SECS: u64 = 600;
38
39#[derive(Debug)]
45pub enum LauncherError {
46 UnsupportedPlatform(String),
48 SkipDownload(String),
51 ChecksumMismatch {
53 expected: String,
54 actual: String,
55 },
56 ChecksumMissing(String),
58 Io(io::Error),
60 Http(String),
62 ExtractionFailed(String),
64 LauncherMissing(String),
66 InvalidVersion(String),
68}
69
70impl std::fmt::Display for LauncherError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 LauncherError::UnsupportedPlatform(msg) => {
74 write!(f, "unsupported platform: {msg}")
75 }
76 LauncherError::SkipDownload(msg) => write!(f, "{msg}"),
77 LauncherError::ChecksumMismatch { expected, actual } => {
78 write!(f, "checksum mismatch: expected {expected}, got {actual}")
79 }
80 LauncherError::ChecksumMissing(msg) => {
81 write!(f, "checksum file empty or unparseable: {msg}")
82 }
83 LauncherError::Io(e) => write!(f, "I/O error: {e}"),
84 LauncherError::Http(msg) => write!(f, "HTTP error: {msg}"),
85 LauncherError::ExtractionFailed(msg) => {
86 write!(f, "extraction failed: {msg}")
87 }
88 LauncherError::LauncherMissing(msg) => {
89 write!(f, "launcher missing after extract: {msg}")
90 }
91 LauncherError::InvalidVersion(msg) => {
92 write!(f, "invalid version: {msg}")
93 }
94 }
95 }
96}
97
98impl std::error::Error for LauncherError {
99 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
100 match self {
101 LauncherError::Io(e) => Some(e),
102 _ => None,
103 }
104 }
105}
106
107impl From<io::Error> for LauncherError {
108 fn from(e: io::Error) -> Self {
109 LauncherError::Io(e)
110 }
111}
112
113pub type LauncherResult<T> = Result<T, LauncherError>;
115
116fn validate_version(version: &str) -> LauncherResult<&str> {
123 if version.contains('/') || version.contains('\\') || version.contains("..") {
125 return Err(LauncherError::InvalidVersion(format!(
126 "version contains path separator or '..': {version}"
127 )));
128 }
129 let mut chars = version.chars().peekable();
132
133 fn digits(chars: &mut std::iter::Peekable<std::str::Chars>) -> bool {
135 let mut found = false;
136 while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
137 chars.next();
138 found = true;
139 }
140 found
141 }
142
143 if !digits(&mut chars) {
144 return Err(LauncherError::InvalidVersion(version.to_string()));
145 }
146 if chars.next() != Some('.') {
147 return Err(LauncherError::InvalidVersion(version.to_string()));
148 }
149 if !digits(&mut chars) {
150 return Err(LauncherError::InvalidVersion(version.to_string()));
151 }
152 if chars.next() != Some('.') {
153 return Err(LauncherError::InvalidVersion(version.to_string()));
154 }
155 if !digits(&mut chars) {
156 return Err(LauncherError::InvalidVersion(version.to_string()));
157 }
158
159 if let Some(&c) = chars.peek() {
161 if c == '-' || c == '.' {
162 chars.next();
163 let mut found = false;
164 while chars
165 .peek()
166 .is_some_and(|c| c.is_ascii_alphanumeric() || *c == '.')
167 {
168 chars.next();
169 found = true;
170 }
171 if !found {
172 return Err(LauncherError::InvalidVersion(version.to_string()));
173 }
174 }
175 }
176
177 if chars.next().is_some() {
178 return Err(LauncherError::InvalidVersion(version.to_string()));
179 }
180
181 Ok(version)
182}
183
184fn assert_within(base: &Path, child: &Path) -> LauncherResult<()> {
190 let resolved_base = fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
191
192 let resolved_child = if child.exists() {
193 fs::canonicalize(child).unwrap_or_else(|_| child.to_path_buf())
194 } else if let (Some(parent), Some(file_name)) = (child.parent(), child.file_name()) {
195 let resolved_parent =
197 fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
198 resolved_parent.join(file_name)
199 } else {
200 child.to_path_buf()
201 };
202
203 if !resolved_child.starts_with(&resolved_base) {
204 return Err(LauncherError::InvalidVersion(format!(
205 "resolved path {} escapes cache base {}",
206 resolved_child.display(),
207 resolved_base.display()
208 )));
209 }
210 Ok(())
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct Platform {
220 pub os_name: &'static str,
222 pub arch: &'static str,
224 pub ext: &'static str,
226}
227
228pub fn resolve_platform() -> LauncherResult<Platform> {
232 let os_name;
233 let ext;
234
235 if cfg!(target_os = "linux") {
236 os_name = "linux";
237 ext = "tar.gz";
238 } else if cfg!(target_os = "macos") {
239 os_name = "darwin";
240 ext = "tar.gz";
241 } else if cfg!(target_os = "windows") {
242 os_name = "windows";
243 ext = "zip";
244 } else {
245 return Err(LauncherError::UnsupportedPlatform(format!(
246 "unsupported OS: {}",
247 std::env::consts::OS
248 )));
249 }
250
251 let arch;
252 if cfg!(target_arch = "x86_64") {
253 arch = "x86_64";
254 } else if cfg!(target_arch = "aarch64") {
255 arch = "aarch64";
256 } else {
257 return Err(LauncherError::UnsupportedPlatform(format!(
258 "unsupported architecture: {}",
259 std::env::consts::ARCH
260 )));
261 }
262
263 Ok(Platform { os_name, arch, ext })
264}
265
266#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct BundleName {
273 pub name: String,
275 pub ext: &'static str,
277}
278
279pub fn bundle_base_name(version: &str) -> LauncherResult<BundleName> {
281 let platform = resolve_platform()?;
282 Ok(BundleName {
283 name: format!(
284 "mockserver-{}-{}-{}",
285 version, platform.os_name, platform.arch
286 ),
287 ext: platform.ext,
288 })
289}
290
291pub fn cache_dir() -> PathBuf {
304 if let Ok(dir) = std::env::var("MOCKSERVER_BINARY_CACHE") {
305 return PathBuf::from(dir);
306 }
307
308 let base = if cfg!(target_os = "windows") {
309 std::env::var("LOCALAPPDATA")
310 .map(PathBuf::from)
311 .unwrap_or_else(|_| {
312 dirs::home_dir()
313 .unwrap_or_else(|| PathBuf::from("."))
314 .join("AppData")
315 .join("Local")
316 })
317 } else {
318 std::env::var("XDG_CACHE_HOME")
319 .map(PathBuf::from)
320 .unwrap_or_else(|_| {
321 dirs::home_dir()
322 .unwrap_or_else(|| PathBuf::from("."))
323 .join(".cache")
324 })
325 };
326
327 base.join("mockserver").join("binaries")
328}
329
330const SNAPSHOT_CDN: &str = "https://downloads.mock-server.com";
336
337pub fn is_snapshot(version: &str) -> bool {
340 version.to_ascii_uppercase().contains("-SNAPSHOT")
341}
342
343pub fn asset_url(version: &str, file: &str) -> String {
349 let base = std::env::var("MOCKSERVER_BINARY_BASE_URL").unwrap_or_else(|_| {
350 if is_snapshot(version) {
351 format!("{SNAPSHOT_CDN}/mockserver-{version}")
352 } else {
353 format!(
354 "https://github.com/{REPO}/releases/download/mockserver-{version}"
355 )
356 }
357 });
358 let base = base.trim_end_matches('/');
359 format!("{base}/{file}")
360}
361
362pub fn launcher_path(dir: &Path, bundle_name: &str) -> PathBuf {
368 let bin_name = if cfg!(target_os = "windows") {
369 "mockserver.bat"
370 } else {
371 "mockserver"
372 };
373 dir.join(bundle_name).join("bin").join(bin_name)
374}
375
376pub fn sha256_file(path: &Path) -> LauncherResult<String> {
382 let mut file = fs::File::open(path)?;
383 let mut hasher = Sha256::new();
384 let mut buf = [0u8; 8192];
385 loop {
386 let n = file.read(&mut buf)?;
387 if n == 0 {
388 break;
389 }
390 hasher.update(&buf[..n]);
391 }
392 Ok(format!("{:x}", hasher.finalize()))
393}
394
395pub fn sha256_bytes(data: &[u8]) -> String {
397 let mut hasher = Sha256::new();
398 hasher.update(data);
399 format!("{:x}", hasher.finalize())
400}
401
402fn download(url: &str, dest: &Path) -> LauncherResult<()> {
410 let client = reqwest::blocking::Client::builder()
411 .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
412 .connect_timeout(std::time::Duration::from_secs(30))
413 .build()
414 .map_err(|e| LauncherError::Http(format!("failed to build HTTP client: {e}")))?;
415
416 let mut resp = client
417 .get(url)
418 .send()
419 .map_err(|e| LauncherError::Http(format!("GET {url} failed: {e}")))?;
420
421 if !resp.status().is_success() {
422 return Err(LauncherError::Http(format!(
423 "GET {url} failed: HTTP {}",
424 resp.status()
425 )));
426 }
427
428 let mut file = fs::File::create(dest)?;
430 io::copy(&mut resp, &mut file)
431 .map_err(|e| LauncherError::Http(format!("streaming body to {}: {e}", dest.display())))?;
432 Ok(())
433}
434
435#[derive(Debug, Clone, Default)]
441pub struct EnsureOptions {
442 pub verbose: bool,
444}
445
446pub fn ensure_binary(version: &str, opts: &EnsureOptions) -> LauncherResult<PathBuf> {
456 validate_version(version)?;
458
459 let meta = bundle_base_name(version)?;
460 let base = cache_dir();
461 let dir = base.join(version);
462
463 fs::create_dir_all(&base)?;
466 assert_within(&base, &dir)?;
467
468 let launcher = launcher_path(&dir, &meta.name);
469
470 if launcher.exists() {
472 let size = fs::metadata(&launcher).map(|m| m.len()).unwrap_or(0);
473 if size > 0 {
474 if opts.verbose {
475 eprintln!("Using cached binary: {}", launcher.display());
476 }
477 return Ok(launcher);
478 }
479 }
480
481 if std::env::var("MOCKSERVER_SKIP_BINARY_DOWNLOAD").is_ok() {
483 return Err(LauncherError::SkipDownload(format!(
484 "MOCKSERVER_SKIP_BINARY_DOWNLOAD is set but no cached binary at {}",
485 launcher.display()
486 )));
487 }
488
489 fs::create_dir_all(&dir)?;
490
491 let archive_file = format!("{}.{}", meta.name, meta.ext);
492 let archive = dir.join(&archive_file);
493 let partial = PathBuf::from(format!("{}.part", archive.display()));
494 let sha_file = PathBuf::from(format!("{}.sha256", archive.display()));
495
496 let url = asset_url(version, &archive_file);
498 if opts.verbose {
499 eprintln!("Downloading {url}");
500 }
501
502 let result = (|| -> LauncherResult<()> {
503 download(&url, &partial)?;
504
505 let sha_url = asset_url(version, &format!("{archive_file}.sha256"));
507 download(&sha_url, &sha_file)?;
508
509 let sha_content = fs::read_to_string(&sha_file)?;
510 let expected = sha_content
511 .split_whitespace()
512 .next()
513 .unwrap_or("")
514 .to_string();
515
516 if expected.is_empty() {
517 return Err(LauncherError::ChecksumMissing(format!(
518 "checksum file for {} is empty or unparseable",
519 meta.name
520 )));
521 }
522
523 let actual = sha256_file(&partial)?;
524 if expected != actual {
525 return Err(LauncherError::ChecksumMismatch { expected, actual });
526 }
527
528 if opts.verbose {
529 eprintln!("Checksum verified");
530 }
531
532 fs::rename(&partial, &archive)?;
533 Ok(())
534 })();
535
536 if let Err(e) = result {
537 let _ = fs::remove_file(&partial);
539 let _ = fs::remove_file(&sha_file);
540 return Err(e);
541 }
542
543 if opts.verbose {
546 eprintln!("Extracting {}", archive.display());
547 }
548
549 let tar_status = Command::new("tar")
550 .args([
551 "-xf",
552 &archive.to_string_lossy(),
553 "-C",
554 &dir.to_string_lossy(),
555 ])
556 .status();
557
558 match tar_status {
559 Ok(status) if status.success() => {}
560 Ok(status) => {
561 return Err(LauncherError::ExtractionFailed(format!(
562 "tar exit code: {}",
563 status.code().unwrap_or(-1)
564 )));
565 }
566 Err(e) => {
567 return Err(LauncherError::ExtractionFailed(format!(
568 "could not run tar: {e}"
569 )));
570 }
571 }
572
573 if let Ok(entries) = fs::read_dir(&dir) {
575 for entry in entries.flatten() {
576 let p = entry.path();
577 let resolved = fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
579 let resolved_dir = fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
580 if !resolved.starts_with(&resolved_dir) {
581 return Err(LauncherError::ExtractionFailed(format!(
582 "extracted file escapes version directory: {}",
583 resolved.display()
584 )));
585 }
586 }
587 }
588
589 if !launcher.exists()
591 || fs::metadata(&launcher).map(|m| m.len()).unwrap_or(0) == 0
592 {
593 return Err(LauncherError::LauncherMissing(format!(
594 "launcher missing or empty after extract: {}",
595 launcher.display()
596 )));
597 }
598
599 #[cfg(unix)]
601 {
602 use std::os::unix::fs::PermissionsExt;
603 fs::set_permissions(&launcher, fs::Permissions::from_mode(0o755))?;
604 }
605
606 prune_old_versions(&base, version);
608
609 Ok(launcher)
610}
611
612fn parse_semver_segments(s: &str) -> Option<(u64, u64, u64, String)> {
620 let mut parts = s.splitn(2, |c: char| c == '-' || (!c.is_ascii_digit() && c != '.'));
621 let version_part = parts.next()?;
622 let segments: Vec<&str> = version_part.split('.').collect();
623 if segments.len() < 3 {
624 return None;
625 }
626 let major = segments[0].parse::<u64>().ok()?;
627 let minor = segments[1].parse::<u64>().ok()?;
628 let patch = segments[2].parse::<u64>().ok()?;
629 let rest = s[version_part.len()..].to_string();
630 Some((major, minor, patch, rest))
631}
632
633fn compare_pre_release(a: &str, b: &str) -> std::cmp::Ordering {
639 match (a.is_empty(), b.is_empty()) {
640 (true, true) => std::cmp::Ordering::Equal,
641 (true, false) => std::cmp::Ordering::Greater,
643 (false, true) => std::cmp::Ordering::Less,
645 (false, false) => a.cmp(b),
647 }
648}
649
650pub fn prune_old_versions(cache_base: &Path, current_version: &str) {
655 let entries = match fs::read_dir(cache_base) {
656 Ok(e) => e,
657 Err(_) => return, };
659
660 let mut version_dirs: Vec<(PathBuf, (u64, u64, u64, String))> = Vec::new();
663
664 for entry in entries.flatten() {
665 let path = entry.path();
666
667 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
669 if ext == "part" || ext == "sha256" {
670 let _ = fs::remove_file(&path);
671 continue;
672 }
673 }
674
675 if !path.is_dir() {
676 continue;
677 }
678
679 let dir_name = match path.file_name().and_then(|n| n.to_str()) {
680 Some(n) => n.to_string(),
681 None => continue,
682 };
683
684 if dir_name == current_version {
685 clean_part_files(&path);
687 continue;
688 }
689
690 if path.parent() != Some(cache_base) {
692 continue;
693 }
694
695 if let Some(semver) = parse_semver_segments(&dir_name) {
697 version_dirs.push((path, semver));
698 }
699 }
700
701 version_dirs.sort_by(|a, b| {
705 let (a_maj, a_min, a_pat, ref a_rest) = a.1;
706 let (b_maj, b_min, b_pat, ref b_rest) = b.1;
707 (b_maj, b_min, b_pat)
708 .cmp(&(a_maj, a_min, a_pat))
709 .then_with(|| compare_pre_release(b_rest, a_rest))
710 });
711
712 for (path, _) in version_dirs.iter().skip(1) {
714 let _ = fs::remove_dir_all(path);
715 }
716}
717
718fn clean_part_files(dir: &Path) {
720 if let Ok(entries) = fs::read_dir(dir) {
721 for entry in entries.flatten() {
722 let path = entry.path();
723 if path.extension().is_some_and(|e| e == "part") {
724 let _ = fs::remove_file(&path);
725 }
726 }
727 }
728}
729
730pub struct ServerHandle {
739 child: Child,
740 port: u16,
741}
742
743impl ServerHandle {
744 pub fn port(&self) -> u16 {
746 self.port
747 }
748
749 pub fn stop(&mut self) -> LauncherResult<()> {
751 self.child.kill().map_err(LauncherError::Io)?;
752 let _ = self.child.wait();
753 Ok(())
754 }
755
756 pub fn wait(&mut self) -> LauncherResult<std::process::ExitStatus> {
758 self.child.wait().map_err(LauncherError::Io)
759 }
760}
761
762impl Drop for ServerHandle {
763 fn drop(&mut self) {
764 let _ = self.child.kill();
765 let _ = self.child.wait();
766 }
767}
768
769pub fn ensure_launcher() -> LauncherResult<PathBuf> {
778 ensure_binary(VERSION, &EnsureOptions::default())
779}
780
781pub fn start(port: u16) -> LauncherResult<ServerHandle> {
786 start_with_version(VERSION, port, &EnsureOptions::default())
787}
788
789pub fn start_with_version(
791 version: &str,
792 port: u16,
793 opts: &EnsureOptions,
794) -> LauncherResult<ServerHandle> {
795 let launcher = ensure_binary(version, opts)?;
796
797 #[cfg(target_os = "windows")]
801 let mut cmd = {
802 use std::os::windows::process::CommandExt;
803 let mut c = Command::new("cmd");
804 c.args(["/c", &launcher.to_string_lossy(), "-serverPort", &port.to_string()]);
805 c.creation_flags(0x08000000);
807 c
808 };
809
810 #[cfg(not(target_os = "windows"))]
811 let mut cmd = {
812 let mut c = Command::new(&launcher);
813 c.arg("-serverPort").arg(port.to_string());
814 c
815 };
816
817 cmd.stdout(Stdio::inherit());
818 cmd.stderr(Stdio::inherit());
819
820 let child = cmd.spawn().map_err(LauncherError::Io)?;
821 Ok(ServerHandle { child, port })
822}
823
824#[cfg(test)]
829mod tests {
830 use super::*;
831 use std::fs;
832 use std::path::PathBuf;
833 use std::sync::Mutex;
834
835 static ENV_MUTEX: Mutex<()> = Mutex::new(());
839
840 struct TempDir(PathBuf);
842
843 impl TempDir {
844 fn new(prefix: &str) -> Self {
845 let dir = std::env::temp_dir().join(format!(
846 "mockserver-test-{}-{}",
847 prefix,
848 std::process::id()
849 ));
850 let _ = fs::remove_dir_all(&dir);
851 fs::create_dir_all(&dir).unwrap();
852 Self(dir)
853 }
854
855 fn path(&self) -> &Path {
856 &self.0
857 }
858 }
859
860 impl Drop for TempDir {
861 fn drop(&mut self) {
862 let _ = fs::remove_dir_all(&self.0);
863 }
864 }
865
866 struct EnvGuard {
871 key: String,
872 original: Option<String>,
873 }
874
875 impl EnvGuard {
876 fn new(key: &str, value: Option<&str>) -> Self {
877 let original = std::env::var(key).ok();
878 match value {
879 Some(v) => std::env::set_var(key, v),
880 None => std::env::remove_var(key),
881 }
882 Self {
883 key: key.to_string(),
884 original,
885 }
886 }
887 }
888
889 impl Drop for EnvGuard {
890 fn drop(&mut self) {
891 match &self.original {
892 Some(v) => std::env::set_var(&self.key, v),
893 None => std::env::remove_var(&self.key),
894 }
895 }
896 }
897
898 #[test]
903 fn test_validate_version_valid() {
904 assert!(validate_version("7.0.0").is_ok());
905 assert!(validate_version("6.1.0").is_ok());
906 assert!(validate_version("12.345.678").is_ok());
907 assert!(validate_version("1.2.3-beta.1").is_ok());
908 assert!(validate_version("1.2.3-rc1").is_ok());
909 assert!(validate_version("1.2.3.SNAPSHOT").is_ok());
910 }
911
912 #[test]
913 fn test_validate_version_rejects_path_separators() {
914 assert!(validate_version("../../../etc/passwd").is_err());
915 assert!(validate_version("7.0.0/../../bad").is_err());
916 assert!(validate_version("7.0.0\\..\\bad").is_err());
917 }
918
919 #[test]
920 fn test_validate_version_rejects_invalid_format() {
921 assert!(validate_version("").is_err());
922 assert!(validate_version("abc").is_err());
923 assert!(validate_version("7.0").is_err());
924 assert!(validate_version("7").is_err());
925 assert!(validate_version("7.0.0-").is_err()); }
927
928 #[test]
933 fn test_resolve_platform_returns_valid_triple() {
934 let p = resolve_platform().unwrap();
935 assert!(
936 ["linux", "darwin", "windows"].contains(&p.os_name),
937 "unexpected OS: {}",
938 p.os_name
939 );
940 assert!(
941 ["x86_64", "aarch64"].contains(&p.arch),
942 "unexpected arch: {}",
943 p.arch
944 );
945 if p.os_name == "windows" {
946 assert_eq!(p.ext, "zip");
947 } else {
948 assert_eq!(p.ext, "tar.gz");
949 }
950 }
951
952 #[cfg(target_os = "macos")]
953 #[test]
954 fn test_resolve_platform_macos() {
955 let p = resolve_platform().unwrap();
956 assert_eq!(p.os_name, "darwin");
957 assert_eq!(p.ext, "tar.gz");
958 }
959
960 #[cfg(target_os = "linux")]
961 #[test]
962 fn test_resolve_platform_linux() {
963 let p = resolve_platform().unwrap();
964 assert_eq!(p.os_name, "linux");
965 assert_eq!(p.ext, "tar.gz");
966 }
967
968 #[cfg(target_arch = "aarch64")]
969 #[test]
970 fn test_resolve_platform_aarch64() {
971 let p = resolve_platform().unwrap();
972 assert_eq!(p.arch, "aarch64");
973 }
974
975 #[cfg(target_arch = "x86_64")]
976 #[test]
977 fn test_resolve_platform_x86_64() {
978 let p = resolve_platform().unwrap();
979 assert_eq!(p.arch, "x86_64");
980 }
981
982 #[test]
987 fn test_bundle_base_name() {
988 let b = bundle_base_name("7.0.0").unwrap();
989 let platform = resolve_platform().unwrap();
990 assert_eq!(
991 b.name,
992 format!("mockserver-7.0.0-{}-{}", platform.os_name, platform.arch)
993 );
994 assert_eq!(b.ext, platform.ext);
995 }
996
997 #[test]
998 fn test_bundle_base_name_custom_version() {
999 let b = bundle_base_name("6.1.0").unwrap();
1000 assert!(b.name.starts_with("mockserver-6.1.0-"));
1001 }
1002
1003 #[test]
1008 fn test_cache_dir_default_has_mockserver_binaries() {
1009 let _lock = ENV_MUTEX.lock().unwrap();
1010 let _guard = EnvGuard::new("MOCKSERVER_BINARY_CACHE", None);
1011 let dir = cache_dir();
1012 let path_str = dir.to_string_lossy();
1013 assert!(
1014 path_str.ends_with("mockserver/binaries")
1015 || path_str.ends_with("mockserver\\binaries"),
1016 "cache_dir should end with mockserver/binaries, got: {path_str}"
1017 );
1018 }
1019
1020 #[test]
1021 fn test_cache_dir_respects_env_override() {
1022 let _lock = ENV_MUTEX.lock().unwrap();
1023 let _guard = EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some("/custom/path"));
1024 let dir = cache_dir();
1025 assert_eq!(dir, PathBuf::from("/custom/path"));
1026 }
1027
1028 #[cfg(not(target_os = "windows"))]
1029 #[test]
1030 fn test_cache_dir_respects_xdg_cache_home() {
1031 let _lock = ENV_MUTEX.lock().unwrap();
1032 let _guard1 = EnvGuard::new("MOCKSERVER_BINARY_CACHE", None);
1033 let _guard2 = EnvGuard::new("XDG_CACHE_HOME", Some("/tmp/xdg-test-cache"));
1034 let dir = cache_dir();
1035 assert_eq!(
1036 dir,
1037 PathBuf::from("/tmp/xdg-test-cache/mockserver/binaries")
1038 );
1039 }
1040
1041 #[test]
1046 fn test_asset_url_default() {
1047 let _lock = ENV_MUTEX.lock().unwrap();
1048 let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1049 let url = asset_url("7.0.0", "mockserver-7.0.0-darwin-aarch64.tar.gz");
1050 assert_eq!(
1051 url,
1052 "https://github.com/mock-server/mockserver-monorepo/releases/download/mockserver-7.0.0/mockserver-7.0.0-darwin-aarch64.tar.gz"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_asset_url_custom_base() {
1058 let _lock = ENV_MUTEX.lock().unwrap();
1059 let _guard = EnvGuard::new(
1060 "MOCKSERVER_BINARY_BASE_URL",
1061 Some("https://mirror.internal/releases/"),
1062 );
1063 let url = asset_url("7.0.0", "mockserver-7.0.0-linux-x86_64.tar.gz");
1064 assert_eq!(
1065 url,
1066 "https://mirror.internal/releases/mockserver-7.0.0-linux-x86_64.tar.gz"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_asset_url_strips_trailing_slashes() {
1072 let _lock = ENV_MUTEX.lock().unwrap();
1073 let _guard = EnvGuard::new(
1074 "MOCKSERVER_BINARY_BASE_URL",
1075 Some("https://mirror.internal///"),
1076 );
1077 let url = asset_url("7.0.0", "file.tar.gz");
1078 assert_eq!(url, "https://mirror.internal/file.tar.gz");
1079 }
1080
1081 #[test]
1086 fn test_is_snapshot() {
1087 assert!(is_snapshot("7.0.0-SNAPSHOT"));
1088 assert!(is_snapshot("7.0.0-snapshot"));
1089 assert!(is_snapshot("7.0.0-Snapshot"));
1090 assert!(is_snapshot("7.1.0-SNAPSHOT"));
1091 assert!(!is_snapshot("7.0.0"));
1092 assert!(!is_snapshot("7.0.0-beta.1"));
1093 assert!(!is_snapshot("7.0.0-rc.1"));
1094 }
1095
1096 #[test]
1097 fn test_asset_url_snapshot_uses_cdn() {
1098 let _lock = ENV_MUTEX.lock().unwrap();
1099 let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1100 let url = asset_url(
1101 "7.1.0-SNAPSHOT",
1102 "mockserver-7.1.0-SNAPSHOT-darwin-aarch64.tar.gz",
1103 );
1104 assert_eq!(
1105 url,
1106 "https://downloads.mock-server.com/mockserver-7.1.0-SNAPSHOT/mockserver-7.1.0-SNAPSHOT-darwin-aarch64.tar.gz"
1107 );
1108 }
1109
1110 #[test]
1111 fn test_asset_url_release_uses_github() {
1112 let _lock = ENV_MUTEX.lock().unwrap();
1113 let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1114 let url = asset_url("7.0.0", "mockserver-7.0.0-darwin-aarch64.tar.gz");
1115 assert_eq!(
1116 url,
1117 "https://github.com/mock-server/mockserver-monorepo/releases/download/mockserver-7.0.0/mockserver-7.0.0-darwin-aarch64.tar.gz"
1118 );
1119 }
1120
1121 #[test]
1122 fn test_asset_url_env_override_wins_over_snapshot() {
1123 let _lock = ENV_MUTEX.lock().unwrap();
1124 let _guard = EnvGuard::new(
1125 "MOCKSERVER_BINARY_BASE_URL",
1126 Some("https://custom-mirror.example.com/bins"),
1127 );
1128 let url = asset_url(
1129 "7.1.0-SNAPSHOT",
1130 "mockserver-7.1.0-SNAPSHOT-linux-x86_64.tar.gz",
1131 );
1132 assert_eq!(
1133 url,
1134 "https://custom-mirror.example.com/bins/mockserver-7.1.0-SNAPSHOT-linux-x86_64.tar.gz"
1135 );
1136 }
1137
1138 #[test]
1143 fn test_launcher_path_unix() {
1144 let dir = PathBuf::from("/cache/7.0.0");
1145 let path = launcher_path(&dir, "mockserver-7.0.0-darwin-aarch64");
1146 if cfg!(target_os = "windows") {
1147 assert!(
1148 path.ends_with("bin/mockserver.bat")
1149 || path.ends_with("bin\\mockserver.bat")
1150 );
1151 } else {
1152 assert_eq!(
1153 path,
1154 PathBuf::from(
1155 "/cache/7.0.0/mockserver-7.0.0-darwin-aarch64/bin/mockserver"
1156 )
1157 );
1158 }
1159 }
1160
1161 #[test]
1166 fn test_sha256_bytes_known_value() {
1167 let digest = sha256_bytes(b"hello\n");
1169 assert_eq!(
1170 digest,
1171 "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
1172 );
1173 }
1174
1175 #[test]
1176 fn test_sha256_file_matches_sha256_bytes() {
1177 let tmp = TempDir::new("sha256");
1178 let file = tmp.path().join("test-data.bin");
1179 let data = b"MockServer binary launcher test content";
1180 fs::write(&file, data).unwrap();
1181
1182 let file_digest = sha256_file(&file).unwrap();
1183 let mem_digest = sha256_bytes(data);
1184 assert_eq!(file_digest, mem_digest);
1185 }
1186
1187 #[test]
1188 fn test_sha256_verification_correct_checksum() {
1189 let tmp = TempDir::new("sha-ok");
1190 let archive_data = b"fake-archive-content-12345";
1191 let archive_path = tmp.path().join("archive.tar.gz");
1192 fs::write(&archive_path, archive_data).unwrap();
1193
1194 let expected = sha256_bytes(archive_data);
1195 let actual = sha256_file(&archive_path).unwrap();
1196 assert_eq!(expected, actual, "checksum should match");
1197 }
1198
1199 #[test]
1200 fn test_sha256_verification_wrong_checksum() {
1201 let tmp = TempDir::new("sha-bad");
1202 let archive_data = b"correct content";
1203 let archive_path = tmp.path().join("archive.tar.gz");
1204 fs::write(&archive_path, archive_data).unwrap();
1205
1206 let wrong = sha256_bytes(b"DIFFERENT content");
1207 let actual = sha256_file(&archive_path).unwrap();
1208 assert_ne!(wrong, actual, "checksums should differ");
1209 }
1210
1211 #[test]
1216 fn test_skip_download_env_fails_when_no_cached_binary() {
1217 let _lock = ENV_MUTEX.lock().unwrap();
1218 let tmp = TempDir::new("skip-dl");
1219 let _guard_cache =
1220 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
1221 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", Some("1"));
1222 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1223
1224 let result = ensure_binary("99.99.99", &EnsureOptions::default());
1225 assert!(result.is_err());
1226 let err = result.unwrap_err();
1227 let msg = format!("{err}");
1228 assert!(
1229 msg.contains("MOCKSERVER_SKIP_BINARY_DOWNLOAD"),
1230 "error should mention env var: {msg}"
1231 );
1232 }
1233
1234 #[test]
1235 fn test_skip_download_returns_cached_binary() {
1236 let _lock = ENV_MUTEX.lock().unwrap();
1237 let tmp = TempDir::new("skip-ok");
1238 let version = "99.99.99";
1239 let meta = bundle_base_name(version).unwrap();
1240
1241 let dir = tmp.path().join(version);
1243 let launcher = launcher_path(&dir, &meta.name);
1244 fs::create_dir_all(launcher.parent().unwrap()).unwrap();
1245 fs::write(&launcher, "#!/bin/sh\necho mock").unwrap();
1246
1247 let _guard_cache =
1248 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
1249 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", Some("1"));
1250
1251 let result = ensure_binary(version, &EnsureOptions::default());
1252 assert!(result.is_ok());
1253 assert_eq!(result.unwrap(), launcher);
1254 }
1255
1256 #[test]
1261 fn test_parse_semver_segments_valid() {
1262 assert_eq!(
1263 parse_semver_segments("7.0.0"),
1264 Some((7, 0, 0, String::new()))
1265 );
1266 assert_eq!(
1267 parse_semver_segments("12.345.678"),
1268 Some((12, 345, 678, String::new()))
1269 );
1270 assert_eq!(
1271 parse_semver_segments("1.2.3-beta.1"),
1272 Some((1, 2, 3, "-beta.1".to_string()))
1273 );
1274 }
1275
1276 #[test]
1277 fn test_parse_semver_segments_invalid() {
1278 assert!(parse_semver_segments("abc").is_none());
1279 assert!(parse_semver_segments("7.0").is_none());
1280 assert!(parse_semver_segments("").is_none());
1281 }
1282
1283 #[test]
1288 fn test_compare_pre_release_release_is_greater_than_snapshot() {
1289 assert_eq!(
1291 compare_pre_release("", "-SNAPSHOT"),
1292 std::cmp::Ordering::Greater,
1293 "release (empty) should sort above -SNAPSHOT"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_compare_pre_release_snapshot_is_less_than_release() {
1299 assert_eq!(
1300 compare_pre_release("-SNAPSHOT", ""),
1301 std::cmp::Ordering::Less,
1302 "-SNAPSHOT should sort below release (empty)"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_compare_pre_release_both_empty() {
1308 assert_eq!(
1309 compare_pre_release("", ""),
1310 std::cmp::Ordering::Equal,
1311 );
1312 }
1313
1314 #[test]
1315 fn test_compare_pre_release_both_pre_releases() {
1316 assert_eq!(
1318 compare_pre_release("-alpha", "-beta"),
1319 std::cmp::Ordering::Less,
1320 );
1321 assert_eq!(
1322 compare_pre_release("-beta", "-alpha"),
1323 std::cmp::Ordering::Greater,
1324 );
1325 assert_eq!(
1326 compare_pre_release("-rc1", "-rc1"),
1327 std::cmp::Ordering::Equal,
1328 );
1329 }
1330
1331 #[test]
1336 fn test_prune_removes_old_versions_keeps_current_and_one_previous() {
1337 let tmp = TempDir::new("prune");
1338 let base = tmp.path();
1339
1340 let v1 = base.join("1.0.0");
1342 let v2 = base.join("2.0.0");
1343 let v3 = base.join("3.0.0");
1344 fs::create_dir_all(&v1).unwrap();
1345 fs::write(v1.join("marker"), "v1").unwrap();
1346 fs::create_dir_all(&v2).unwrap();
1347 fs::write(v2.join("marker"), "v2").unwrap();
1348 fs::create_dir_all(&v3).unwrap();
1349 fs::write(v3.join("marker"), "v3").unwrap();
1350
1351 fs::write(base.join("something.part"), "temp").unwrap();
1353
1354 prune_old_versions(base, "3.0.0");
1356
1357 assert!(v3.exists(), "current version should be kept");
1359 assert!(v2.exists(), "one previous version should be kept");
1361 assert!(!v1.exists(), "older versions should be pruned");
1363 assert!(
1365 !base.join("something.part").exists(),
1366 ".part files should be cleaned"
1367 );
1368 }
1369
1370 #[test]
1371 fn test_prune_semver_aware_not_lexicographic() {
1372 let tmp = TempDir::new("prune-semver");
1374 let base = tmp.path();
1375
1376 fs::create_dir_all(base.join("2.0.0")).unwrap();
1377 fs::create_dir_all(base.join("9.0.0")).unwrap();
1378 fs::create_dir_all(base.join("10.0.0")).unwrap();
1379
1380 prune_old_versions(base, "11.0.0");
1382
1383 assert!(
1385 base.join("10.0.0").exists(),
1386 "semver-newest previous (10.0.0) should be kept"
1387 );
1388 assert!(
1390 !base.join("9.0.0").exists(),
1391 "9.0.0 should be pruned"
1392 );
1393 assert!(
1394 !base.join("2.0.0").exists(),
1395 "2.0.0 should be pruned"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_prune_with_single_old_version_keeps_it() {
1401 let tmp = TempDir::new("prune-single");
1402 let base = tmp.path();
1403
1404 let v1 = base.join("1.0.0");
1405 let v2 = base.join("2.0.0");
1406 fs::create_dir_all(&v1).unwrap();
1407 fs::write(v1.join("marker"), "v1").unwrap();
1408 fs::create_dir_all(&v2).unwrap();
1409 fs::write(v2.join("marker"), "v2").unwrap();
1410
1411 prune_old_versions(base, "2.0.0");
1412
1413 assert!(v2.exists(), "current version should be kept");
1414 assert!(v1.exists(), "single previous version should be kept");
1415 }
1416
1417 #[test]
1418 fn test_prune_cleans_part_files_in_current_version_dir() {
1419 let tmp = TempDir::new("prune-part");
1420 let base = tmp.path();
1421
1422 let current = base.join("5.0.0");
1423 fs::create_dir_all(¤t).unwrap();
1424 fs::write(current.join("archive.tar.gz.part"), "partial").unwrap();
1425 fs::write(current.join("real-file.txt"), "keep").unwrap();
1426
1427 prune_old_versions(base, "5.0.0");
1428
1429 assert!(
1430 !current.join("archive.tar.gz.part").exists(),
1431 ".part inside current should be cleaned"
1432 );
1433 assert!(
1434 current.join("real-file.txt").exists(),
1435 "non-.part files should be kept"
1436 );
1437 }
1438
1439 #[test]
1440 fn test_prune_cleans_sha256_temp_files() {
1441 let tmp = TempDir::new("prune-sha");
1442 let base = tmp.path();
1443
1444 fs::create_dir_all(base.join("5.0.0")).unwrap();
1445 fs::write(base.join("archive.tar.gz.sha256"), "temp").unwrap();
1446
1447 prune_old_versions(base, "5.0.0");
1448
1449 assert!(
1450 !base.join("archive.tar.gz.sha256").exists(),
1451 ".sha256 files at cache root should be cleaned"
1452 );
1453 }
1454
1455 #[test]
1456 fn test_prune_empty_cache_is_safe() {
1457 let tmp = TempDir::new("prune-empty");
1458 prune_old_versions(tmp.path(), "1.0.0");
1460 }
1461
1462 #[test]
1463 fn test_prune_nonexistent_cache_dir_is_safe() {
1464 let nonexistent = PathBuf::from("/tmp/mockserver-test-nonexistent-dir-12345");
1465 prune_old_versions(&nonexistent, "1.0.0");
1467 }
1468
1469 #[test]
1470 fn test_prune_with_many_old_versions() {
1471 let tmp = TempDir::new("prune-many");
1472 let base = tmp.path();
1473
1474 for i in 1..=5 {
1476 let v = base.join(format!("{i}.0.0"));
1477 fs::create_dir_all(&v).unwrap();
1478 fs::write(v.join("marker"), format!("v{i}")).unwrap();
1479 }
1480 let current = base.join("6.0.0");
1481 fs::create_dir_all(¤t).unwrap();
1482
1483 prune_old_versions(base, "6.0.0");
1484
1485 assert!(current.exists(), "current must be kept");
1486 assert!(
1488 base.join("5.0.0").exists(),
1489 "newest previous version should be kept"
1490 );
1491 for i in 1..=4 {
1493 let v = base.join(format!("{i}.0.0"));
1494 assert!(!v.exists(), "version {i}.0.0 should be pruned");
1495 }
1496 }
1497
1498 #[test]
1499 fn test_prune_keeps_release_over_snapshot() {
1500 let tmp = TempDir::new("prune-rel-snap");
1504 let base = tmp.path();
1505
1506 let release = base.join("7.0.0");
1507 let snapshot = base.join("7.0.0-SNAPSHOT");
1508 let current = base.join("8.0.0");
1509 fs::create_dir_all(&release).unwrap();
1510 fs::write(release.join("marker"), "release").unwrap();
1511 fs::create_dir_all(&snapshot).unwrap();
1512 fs::write(snapshot.join("marker"), "snapshot").unwrap();
1513 fs::create_dir_all(¤t).unwrap();
1514
1515 prune_old_versions(base, "8.0.0");
1516
1517 assert!(current.exists(), "current version should be kept");
1518 assert!(
1519 release.exists(),
1520 "release 7.0.0 should be kept (newer than SNAPSHOT)"
1521 );
1522 assert!(
1523 !snapshot.exists(),
1524 "7.0.0-SNAPSHOT should be pruned (older than release)"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_prune_release_never_deleted_in_favour_of_snapshot() {
1530 let tmp = TempDir::new("prune-no-snap-win");
1533 let base = tmp.path();
1534
1535 let release = base.join("6.1.0");
1536 let snapshot = base.join("6.1.0-SNAPSHOT");
1537 fs::create_dir_all(&release).unwrap();
1538 fs::create_dir_all(&snapshot).unwrap();
1539
1540 prune_old_versions(base, "7.0.0");
1541
1542 assert!(
1543 release.exists(),
1544 "release 6.1.0 must be kept, not its SNAPSHOT"
1545 );
1546 assert!(
1547 !snapshot.exists(),
1548 "6.1.0-SNAPSHOT must be pruned when release exists"
1549 );
1550 }
1551
1552 struct TestServer {
1559 base_url: String,
1560 server: tiny_http::Server,
1561 serve_dir: PathBuf,
1562 }
1563
1564 impl TestServer {
1565 fn start(serve_dir: PathBuf) -> Self {
1566 let server =
1567 tiny_http::Server::http("127.0.0.1:0").expect("failed to start test HTTP server");
1568 let addr = server.server_addr().to_ip().unwrap();
1569 let base_url = format!("http://127.0.0.1:{}", addr.port());
1570 TestServer {
1571 base_url,
1572 server,
1573 serve_dir,
1574 }
1575 }
1576
1577 fn serve_in_background(self) -> (String, std::thread::JoinHandle<()>) {
1579 let base_url = self.base_url.clone();
1580 let handle = std::thread::spawn(move || {
1581 for _ in 0..10 {
1583 let request = match self.server.recv_timeout(
1584 std::time::Duration::from_secs(10),
1585 ) {
1586 Ok(Some(r)) => r,
1587 _ => break,
1588 };
1589
1590 let url_path = request.url().to_string();
1591 let file_name = url_path.trim_start_matches('/');
1593 let file_path = self.serve_dir.join(file_name);
1594
1595 if file_path.exists() {
1596 let data = fs::read(&file_path).unwrap();
1597 let response = tiny_http::Response::from_data(data);
1598 let _ = request.respond(response);
1599 } else {
1600 let response = tiny_http::Response::from_string("Not Found")
1601 .with_status_code(tiny_http::StatusCode(404));
1602 let _ = request.respond(response);
1603 }
1604 }
1605 });
1606 (base_url, handle)
1607 }
1608 }
1609
1610 fn build_fixture_archive(
1612 work_dir: &Path,
1613 bundle_name: &str,
1614 ) -> (PathBuf, String) {
1615 let archive_root = work_dir.join("build");
1616 let bin_dir = archive_root.join(bundle_name).join("bin");
1617 fs::create_dir_all(&bin_dir).unwrap();
1618 fs::write(bin_dir.join("mockserver"), "#!/bin/sh\necho mock").unwrap();
1619
1620 let ext = if cfg!(target_os = "windows") {
1621 "zip"
1622 } else {
1623 "tar.gz"
1624 };
1625 let archive_file = format!("{bundle_name}.{ext}");
1626 let archive_path = work_dir.join(&archive_file);
1627
1628 let status = Command::new("tar")
1629 .args([
1630 "-czf",
1631 &archive_path.to_string_lossy(),
1632 "-C",
1633 &archive_root.to_string_lossy(),
1634 bundle_name,
1635 ])
1636 .status()
1637 .unwrap();
1638 assert!(status.success(), "tar create should succeed");
1639
1640 let checksum = sha256_file(&archive_path).unwrap();
1641 (archive_path, checksum)
1642 }
1643
1644 #[test]
1645 fn test_ensure_binary_download_and_verify_sha256() {
1646 let _lock = ENV_MUTEX.lock().unwrap();
1648 let tmp = TempDir::new("ensure-http-ok");
1649 let version = "99.0.0";
1650 let meta = bundle_base_name(version).unwrap();
1651 let archive_file = format!("{}.{}", meta.name, meta.ext);
1652
1653 let serve_dir = tmp.path().join("serve");
1654 fs::create_dir_all(&serve_dir).unwrap();
1655
1656 let (archive_path, checksum) = build_fixture_archive(tmp.path(), &meta.name);
1657 fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
1658 fs::write(
1659 serve_dir.join(format!("{archive_file}.sha256")),
1660 format!("{checksum} {archive_file}\n"),
1661 )
1662 .unwrap();
1663
1664 let server = TestServer::start(serve_dir);
1665 let (base_url, _handle) = server.serve_in_background();
1666
1667 let cache = tmp.path().join("cache");
1668 let _guard_cache =
1669 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1670 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1671 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1672
1673 let result = ensure_binary(version, &EnsureOptions { verbose: true });
1674 assert!(result.is_ok(), "ensure_binary should succeed: {:?}", result);
1675 let launcher = result.unwrap();
1676 assert!(launcher.exists(), "launcher should exist after download+extract");
1677 assert!(
1678 fs::metadata(&launcher).unwrap().len() > 0,
1679 "launcher should be non-empty"
1680 );
1681 }
1682
1683 #[test]
1684 fn test_ensure_binary_sha256_mismatch_fails_and_cleans_up() {
1685 let _lock = ENV_MUTEX.lock().unwrap();
1687 let tmp = TempDir::new("ensure-http-bad-sha");
1688 let version = "99.0.1";
1689 let meta = bundle_base_name(version).unwrap();
1690 let archive_file = format!("{}.{}", meta.name, meta.ext);
1691
1692 let serve_dir = tmp.path().join("serve");
1693 fs::create_dir_all(&serve_dir).unwrap();
1694
1695 let (archive_path, _checksum) = build_fixture_archive(tmp.path(), &meta.name);
1696 fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
1697 fs::write(
1699 serve_dir.join(format!("{archive_file}.sha256")),
1700 "0000000000000000000000000000000000000000000000000000000000000000 file\n",
1701 )
1702 .unwrap();
1703
1704 let server = TestServer::start(serve_dir);
1705 let (base_url, _handle) = server.serve_in_background();
1706
1707 let cache = tmp.path().join("cache");
1708 let _guard_cache =
1709 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1710 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1711 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1712
1713 let result = ensure_binary(version, &EnsureOptions::default());
1714 assert!(result.is_err(), "should fail on checksum mismatch");
1715 let msg = format!("{}", result.unwrap_err());
1716 assert!(msg.contains("checksum mismatch"), "error message: {msg}");
1717
1718 let ver_dir = cache.join(version);
1720 let part_file = PathBuf::from(format!(
1721 "{}.part",
1722 ver_dir.join(&archive_file).display()
1723 ));
1724 assert!(
1725 !part_file.exists(),
1726 ".part file should be cleaned up on failure"
1727 );
1728 }
1729
1730 #[test]
1731 fn test_ensure_binary_empty_sha256_fails() {
1732 let _lock = ENV_MUTEX.lock().unwrap();
1734 let tmp = TempDir::new("ensure-http-empty-sha");
1735 let version = "99.0.2";
1736 let meta = bundle_base_name(version).unwrap();
1737 let archive_file = format!("{}.{}", meta.name, meta.ext);
1738
1739 let serve_dir = tmp.path().join("serve");
1740 fs::create_dir_all(&serve_dir).unwrap();
1741
1742 let (archive_path, _checksum) = build_fixture_archive(tmp.path(), &meta.name);
1743 fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
1744 fs::write(
1746 serve_dir.join(format!("{archive_file}.sha256")),
1747 "",
1748 )
1749 .unwrap();
1750
1751 let server = TestServer::start(serve_dir);
1752 let (base_url, _handle) = server.serve_in_background();
1753
1754 let cache = tmp.path().join("cache");
1755 let _guard_cache =
1756 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1757 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1758 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1759
1760 let result = ensure_binary(version, &EnsureOptions::default());
1761 assert!(result.is_err(), "should fail on empty checksum");
1762 let msg = format!("{}", result.unwrap_err());
1763 assert!(
1764 msg.contains("empty or unparseable"),
1765 "error message: {msg}"
1766 );
1767 }
1768
1769 #[test]
1774 fn test_version_is_valid_and_non_empty() {
1775 assert!(!VERSION.is_empty(), "VERSION should be non-empty");
1779 assert!(
1780 validate_version(VERSION).is_ok(),
1781 "VERSION should be a valid semver: {VERSION}"
1782 );
1783 }
1784
1785 #[test]
1790 #[ignore] fn test_ensure_binary_live_download() {
1792 let _lock = ENV_MUTEX.lock().unwrap();
1793 let tmp = TempDir::new("live-dl");
1794 let _guard =
1795 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
1796 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1797 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1798
1799 let result = ensure_binary(
1800 VERSION,
1801 &EnsureOptions { verbose: true },
1802 );
1803 match result {
1804 Ok(path) => {
1805 assert!(path.exists());
1806 assert!(fs::metadata(&path).unwrap().len() > 0);
1807 }
1808 Err(e) => {
1809 eprintln!("Live download failed (expected if no release): {e}");
1811 }
1812 }
1813 }
1814}