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 NotFound(String),
64 NoBundle(String),
66 ExtractionFailed(String),
68 LauncherMissing(String),
70 InvalidVersion(String),
72}
73
74impl std::fmt::Display for LauncherError {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 match self {
77 LauncherError::UnsupportedPlatform(msg) => {
78 write!(f, "unsupported platform: {msg}")
79 }
80 LauncherError::SkipDownload(msg) => write!(f, "{msg}"),
81 LauncherError::ChecksumMismatch { expected, actual } => {
82 write!(f, "checksum mismatch: expected {expected}, got {actual}")
83 }
84 LauncherError::ChecksumMissing(msg) => {
85 write!(f, "checksum file empty or unparseable: {msg}")
86 }
87 LauncherError::Io(e) => write!(f, "I/O error: {e}"),
88 LauncherError::Http(msg) => write!(f, "HTTP error: {msg}"),
89 LauncherError::NotFound(msg) => write!(f, "not found (HTTP 404): {msg}"),
90 LauncherError::NoBundle(msg) => write!(f, "{msg}"),
91 LauncherError::ExtractionFailed(msg) => {
92 write!(f, "extraction failed: {msg}")
93 }
94 LauncherError::LauncherMissing(msg) => {
95 write!(f, "launcher missing after extract: {msg}")
96 }
97 LauncherError::InvalidVersion(msg) => {
98 write!(f, "invalid version: {msg}")
99 }
100 }
101 }
102}
103
104impl std::error::Error for LauncherError {
105 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
106 match self {
107 LauncherError::Io(e) => Some(e),
108 _ => None,
109 }
110 }
111}
112
113impl From<io::Error> for LauncherError {
114 fn from(e: io::Error) -> Self {
115 LauncherError::Io(e)
116 }
117}
118
119pub type LauncherResult<T> = Result<T, LauncherError>;
121
122fn validate_version(version: &str) -> LauncherResult<&str> {
129 if version.contains('/') || version.contains('\\') || version.contains("..") {
131 return Err(LauncherError::InvalidVersion(format!(
132 "version contains path separator or '..': {version}"
133 )));
134 }
135 let mut chars = version.chars().peekable();
138
139 fn digits(chars: &mut std::iter::Peekable<std::str::Chars>) -> bool {
141 let mut found = false;
142 while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
143 chars.next();
144 found = true;
145 }
146 found
147 }
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 if chars.next() != Some('.') {
159 return Err(LauncherError::InvalidVersion(version.to_string()));
160 }
161 if !digits(&mut chars) {
162 return Err(LauncherError::InvalidVersion(version.to_string()));
163 }
164
165 if let Some(&c) = chars.peek() {
167 if c == '-' || c == '.' {
168 chars.next();
169 let mut found = false;
170 while chars
171 .peek()
172 .is_some_and(|c| c.is_ascii_alphanumeric() || *c == '.')
173 {
174 chars.next();
175 found = true;
176 }
177 if !found {
178 return Err(LauncherError::InvalidVersion(version.to_string()));
179 }
180 }
181 }
182
183 if chars.next().is_some() {
184 return Err(LauncherError::InvalidVersion(version.to_string()));
185 }
186
187 Ok(version)
188}
189
190fn assert_within(base: &Path, child: &Path) -> LauncherResult<()> {
196 let resolved_base = fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
197
198 let resolved_child = if child.exists() {
199 fs::canonicalize(child).unwrap_or_else(|_| child.to_path_buf())
200 } else if let (Some(parent), Some(file_name)) = (child.parent(), child.file_name()) {
201 let resolved_parent =
203 fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
204 resolved_parent.join(file_name)
205 } else {
206 child.to_path_buf()
207 };
208
209 if !resolved_child.starts_with(&resolved_base) {
210 return Err(LauncherError::InvalidVersion(format!(
211 "resolved path {} escapes cache base {}",
212 resolved_child.display(),
213 resolved_base.display()
214 )));
215 }
216 Ok(())
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct Platform {
226 pub os_name: &'static str,
228 pub arch: &'static str,
230 pub ext: &'static str,
232}
233
234pub fn resolve_platform() -> LauncherResult<Platform> {
238 let os_name;
239 let ext;
240
241 if cfg!(target_os = "linux") {
242 os_name = "linux";
243 ext = "tar.gz";
244 } else if cfg!(target_os = "macos") {
245 os_name = "darwin";
246 ext = "tar.gz";
247 } else if cfg!(target_os = "windows") {
248 os_name = "windows";
249 ext = "zip";
250 } else {
251 return Err(LauncherError::UnsupportedPlatform(format!(
252 "unsupported OS: {}",
253 std::env::consts::OS
254 )));
255 }
256
257 let arch;
258 if cfg!(target_arch = "x86_64") {
259 arch = "x86_64";
260 } else if cfg!(target_arch = "aarch64") {
261 arch = "aarch64";
262 } else {
263 return Err(LauncherError::UnsupportedPlatform(format!(
264 "unsupported architecture: {}",
265 std::env::consts::ARCH
266 )));
267 }
268
269 Ok(Platform { os_name, arch, ext })
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct BundleName {
279 pub name: String,
281 pub ext: &'static str,
283}
284
285pub fn bundle_base_name(version: &str) -> LauncherResult<BundleName> {
287 let platform = resolve_platform()?;
288 Ok(BundleName {
289 name: format!(
290 "mockserver-{}-{}-{}",
291 version, platform.os_name, platform.arch
292 ),
293 ext: platform.ext,
294 })
295}
296
297pub fn cache_dir() -> PathBuf {
310 if let Ok(dir) = std::env::var("MOCKSERVER_BINARY_CACHE") {
311 return PathBuf::from(dir);
312 }
313
314 let base = if cfg!(target_os = "windows") {
315 std::env::var("LOCALAPPDATA")
316 .map(PathBuf::from)
317 .unwrap_or_else(|_| {
318 dirs::home_dir()
319 .unwrap_or_else(|| PathBuf::from("."))
320 .join("AppData")
321 .join("Local")
322 })
323 } else {
324 std::env::var("XDG_CACHE_HOME")
325 .map(PathBuf::from)
326 .unwrap_or_else(|_| {
327 dirs::home_dir()
328 .unwrap_or_else(|| PathBuf::from("."))
329 .join(".cache")
330 })
331 };
332
333 base.join("mockserver").join("binaries")
334}
335
336const SNAPSHOT_CDN: &str = "https://downloads.mock-server.com";
342
343fn no_bundle_message(version: &str) -> String {
349 format!(
350 "no MockServer release bundle is published for version {version} \
351 (no downloadable asset at the GitHub release tag 'mockserver-{version}'). \
352 Use a MockServer version that ships self-contained bundles, \
353 or run MockServer via Docker (docker run mockserver/mockserver:mockserver-{version}), \
354 or use the Maven Central jar (org.mock-server:mockserver-netty:{version})."
355 )
356}
357
358pub fn is_snapshot(version: &str) -> bool {
361 version.to_ascii_uppercase().contains("-SNAPSHOT")
362}
363
364pub fn asset_url(version: &str, file: &str) -> String {
370 let base = std::env::var("MOCKSERVER_BINARY_BASE_URL").unwrap_or_else(|_| {
371 if is_snapshot(version) {
372 format!("{SNAPSHOT_CDN}/mockserver-{version}")
373 } else {
374 format!(
375 "https://github.com/{REPO}/releases/download/mockserver-{version}"
376 )
377 }
378 });
379 let base = base.trim_end_matches('/');
380 format!("{base}/{file}")
381}
382
383pub fn launcher_path(dir: &Path, bundle_name: &str) -> PathBuf {
389 let bin_name = if cfg!(target_os = "windows") {
390 "mockserver.bat"
391 } else {
392 "mockserver"
393 };
394 dir.join(bundle_name).join("bin").join(bin_name)
395}
396
397pub fn sha256_file(path: &Path) -> LauncherResult<String> {
403 let mut file = fs::File::open(path)?;
404 let mut hasher = Sha256::new();
405 let mut buf = [0u8; 8192];
406 loop {
407 let n = file.read(&mut buf)?;
408 if n == 0 {
409 break;
410 }
411 hasher.update(&buf[..n]);
412 }
413 Ok(format!("{:x}", hasher.finalize()))
414}
415
416pub fn sha256_bytes(data: &[u8]) -> String {
418 let mut hasher = Sha256::new();
419 hasher.update(data);
420 format!("{:x}", hasher.finalize())
421}
422
423fn download(url: &str, dest: &Path) -> LauncherResult<()> {
431 let client = reqwest::blocking::Client::builder()
432 .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
433 .connect_timeout(std::time::Duration::from_secs(30))
434 .build()
435 .map_err(|e| LauncherError::Http(format!("failed to build HTTP client: {e}")))?;
436
437 let mut resp = client
438 .get(url)
439 .send()
440 .map_err(|e| LauncherError::Http(format!("GET {url} failed: {e}")))?;
441
442 if resp.status() == reqwest::StatusCode::NOT_FOUND {
443 return Err(LauncherError::NotFound(format!("GET {url}")));
444 }
445 if !resp.status().is_success() {
446 return Err(LauncherError::Http(format!(
447 "GET {url} failed: HTTP {}",
448 resp.status()
449 )));
450 }
451
452 let mut file = fs::File::create(dest)?;
454 io::copy(&mut resp, &mut file)
455 .map_err(|e| LauncherError::Http(format!("streaming body to {}: {e}", dest.display())))?;
456 Ok(())
457}
458
459#[derive(Debug, Clone, Default)]
465pub struct EnsureOptions {
466 pub verbose: bool,
468}
469
470pub fn ensure_binary(version: &str, opts: &EnsureOptions) -> LauncherResult<PathBuf> {
480 validate_version(version)?;
482
483 let meta = bundle_base_name(version)?;
484 let base = cache_dir();
485 let dir = base.join(version);
486
487 fs::create_dir_all(&base)?;
490 assert_within(&base, &dir)?;
491
492 let launcher = launcher_path(&dir, &meta.name);
493
494 if launcher.exists() {
496 let size = fs::metadata(&launcher).map(|m| m.len()).unwrap_or(0);
497 if size > 0 {
498 if opts.verbose {
499 eprintln!("Using cached binary: {}", launcher.display());
500 }
501 return Ok(launcher);
502 }
503 }
504
505 if std::env::var("MOCKSERVER_SKIP_BINARY_DOWNLOAD").is_ok() {
507 return Err(LauncherError::SkipDownload(format!(
508 "MOCKSERVER_SKIP_BINARY_DOWNLOAD is set but no cached binary at {}",
509 launcher.display()
510 )));
511 }
512
513 fs::create_dir_all(&dir)?;
514
515 let archive_file = format!("{}.{}", meta.name, meta.ext);
516 let archive = dir.join(&archive_file);
517 let partial = PathBuf::from(format!("{}.part", archive.display()));
518 let sha_file = PathBuf::from(format!("{}.sha256", archive.display()));
519
520 let url = asset_url(version, &archive_file);
522 if opts.verbose {
523 eprintln!("Downloading {url}");
524 }
525
526 let result = (|| -> LauncherResult<()> {
527 match download(&url, &partial) {
531 Ok(()) => {}
532 Err(LauncherError::NotFound(_)) => {
533 return Err(LauncherError::NoBundle(no_bundle_message(version)));
534 }
535 Err(e) => return Err(e),
536 }
537
538 let sha_url = asset_url(version, &format!("{archive_file}.sha256"));
540 download(&sha_url, &sha_file)?;
541
542 let sha_content = fs::read_to_string(&sha_file)?;
543 let expected = sha_content
544 .split_whitespace()
545 .next()
546 .unwrap_or("")
547 .to_string();
548
549 if expected.is_empty() {
550 return Err(LauncherError::ChecksumMissing(format!(
551 "checksum file for {} is empty or unparseable",
552 meta.name
553 )));
554 }
555
556 let actual = sha256_file(&partial)?;
557 if expected != actual {
558 return Err(LauncherError::ChecksumMismatch { expected, actual });
559 }
560
561 if opts.verbose {
562 eprintln!("Checksum verified");
563 }
564
565 fs::rename(&partial, &archive)?;
566 Ok(())
567 })();
568
569 if let Err(e) = result {
570 let _ = fs::remove_file(&partial);
572 let _ = fs::remove_file(&sha_file);
573 return Err(e);
574 }
575
576 if opts.verbose {
579 eprintln!("Extracting {}", archive.display());
580 }
581
582 let tar_status = Command::new("tar")
583 .args([
584 "-xf",
585 &archive.to_string_lossy(),
586 "-C",
587 &dir.to_string_lossy(),
588 ])
589 .status();
590
591 match tar_status {
592 Ok(status) if status.success() => {}
593 Ok(status) => {
594 return Err(LauncherError::ExtractionFailed(format!(
595 "tar exit code: {}",
596 status.code().unwrap_or(-1)
597 )));
598 }
599 Err(e) => {
600 return Err(LauncherError::ExtractionFailed(format!(
601 "could not run tar: {e}"
602 )));
603 }
604 }
605
606 if let Ok(entries) = fs::read_dir(&dir) {
608 for entry in entries.flatten() {
609 let p = entry.path();
610 let resolved = fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
612 let resolved_dir = fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
613 if !resolved.starts_with(&resolved_dir) {
614 return Err(LauncherError::ExtractionFailed(format!(
615 "extracted file escapes version directory: {}",
616 resolved.display()
617 )));
618 }
619 }
620 }
621
622 if !launcher.exists()
624 || fs::metadata(&launcher).map(|m| m.len()).unwrap_or(0) == 0
625 {
626 return Err(LauncherError::LauncherMissing(format!(
627 "launcher missing or empty after extract: {}",
628 launcher.display()
629 )));
630 }
631
632 #[cfg(unix)]
634 {
635 use std::os::unix::fs::PermissionsExt;
636 fs::set_permissions(&launcher, fs::Permissions::from_mode(0o755))?;
637 }
638
639 prune_old_versions(&base, version);
641
642 Ok(launcher)
643}
644
645fn parse_semver_segments(s: &str) -> Option<(u64, u64, u64, String)> {
653 let mut parts = s.splitn(2, |c: char| c == '-' || (!c.is_ascii_digit() && c != '.'));
654 let version_part = parts.next()?;
655 let segments: Vec<&str> = version_part.split('.').collect();
656 if segments.len() < 3 {
657 return None;
658 }
659 let major = segments[0].parse::<u64>().ok()?;
660 let minor = segments[1].parse::<u64>().ok()?;
661 let patch = segments[2].parse::<u64>().ok()?;
662 let rest = s[version_part.len()..].to_string();
663 Some((major, minor, patch, rest))
664}
665
666fn compare_pre_release(a: &str, b: &str) -> std::cmp::Ordering {
672 match (a.is_empty(), b.is_empty()) {
673 (true, true) => std::cmp::Ordering::Equal,
674 (true, false) => std::cmp::Ordering::Greater,
676 (false, true) => std::cmp::Ordering::Less,
678 (false, false) => a.cmp(b),
680 }
681}
682
683pub fn prune_old_versions(cache_base: &Path, current_version: &str) {
688 let entries = match fs::read_dir(cache_base) {
689 Ok(e) => e,
690 Err(_) => return, };
692
693 let mut version_dirs: Vec<(PathBuf, (u64, u64, u64, String))> = Vec::new();
696
697 for entry in entries.flatten() {
698 let path = entry.path();
699
700 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
702 if ext == "part" || ext == "sha256" {
703 let _ = fs::remove_file(&path);
704 continue;
705 }
706 }
707
708 if !path.is_dir() {
709 continue;
710 }
711
712 let dir_name = match path.file_name().and_then(|n| n.to_str()) {
713 Some(n) => n.to_string(),
714 None => continue,
715 };
716
717 if dir_name == current_version {
718 clean_part_files(&path);
720 continue;
721 }
722
723 if path.parent() != Some(cache_base) {
725 continue;
726 }
727
728 if let Some(semver) = parse_semver_segments(&dir_name) {
730 version_dirs.push((path, semver));
731 }
732 }
733
734 version_dirs.sort_by(|a, b| {
738 let (a_maj, a_min, a_pat, ref a_rest) = a.1;
739 let (b_maj, b_min, b_pat, ref b_rest) = b.1;
740 (b_maj, b_min, b_pat)
741 .cmp(&(a_maj, a_min, a_pat))
742 .then_with(|| compare_pre_release(b_rest, a_rest))
743 });
744
745 for (path, _) in version_dirs.iter().skip(1) {
747 let _ = fs::remove_dir_all(path);
748 }
749}
750
751fn clean_part_files(dir: &Path) {
753 if let Ok(entries) = fs::read_dir(dir) {
754 for entry in entries.flatten() {
755 let path = entry.path();
756 if path.extension().is_some_and(|e| e == "part") {
757 let _ = fs::remove_file(&path);
758 }
759 }
760 }
761}
762
763pub struct ServerHandle {
772 child: Child,
773 port: u16,
774}
775
776impl ServerHandle {
777 pub fn port(&self) -> u16 {
779 self.port
780 }
781
782 pub fn stop(&mut self) -> LauncherResult<()> {
784 self.child.kill().map_err(LauncherError::Io)?;
785 let _ = self.child.wait();
786 Ok(())
787 }
788
789 pub fn wait(&mut self) -> LauncherResult<std::process::ExitStatus> {
791 self.child.wait().map_err(LauncherError::Io)
792 }
793}
794
795impl Drop for ServerHandle {
796 fn drop(&mut self) {
797 let _ = self.child.kill();
798 let _ = self.child.wait();
799 }
800}
801
802pub fn ensure_launcher() -> LauncherResult<PathBuf> {
811 ensure_binary(VERSION, &EnsureOptions::default())
812}
813
814pub fn start(port: u16) -> LauncherResult<ServerHandle> {
819 start_with_version(VERSION, port, &EnsureOptions::default())
820}
821
822pub fn start_with_version(
824 version: &str,
825 port: u16,
826 opts: &EnsureOptions,
827) -> LauncherResult<ServerHandle> {
828 let launcher = ensure_binary(version, opts)?;
829
830 #[cfg(target_os = "windows")]
834 let mut cmd = {
835 use std::os::windows::process::CommandExt;
836 let mut c = Command::new("cmd");
837 c.args(["/c", &launcher.to_string_lossy(), "-serverPort", &port.to_string()]);
838 c.creation_flags(0x08000000);
840 c
841 };
842
843 #[cfg(not(target_os = "windows"))]
844 let mut cmd = {
845 let mut c = Command::new(&launcher);
846 c.arg("-serverPort").arg(port.to_string());
847 c
848 };
849
850 cmd.stdout(Stdio::inherit());
851 cmd.stderr(Stdio::inherit());
852
853 let child = cmd.spawn().map_err(LauncherError::Io)?;
854 Ok(ServerHandle { child, port })
855}
856
857#[cfg(test)]
862mod tests {
863 use super::*;
864 use std::fs;
865 use std::path::PathBuf;
866 use std::sync::Mutex;
867
868 static ENV_MUTEX: Mutex<()> = Mutex::new(());
872
873 struct TempDir(PathBuf);
875
876 impl TempDir {
877 fn new(prefix: &str) -> Self {
878 let dir = std::env::temp_dir().join(format!(
879 "mockserver-test-{}-{}",
880 prefix,
881 std::process::id()
882 ));
883 let _ = fs::remove_dir_all(&dir);
884 fs::create_dir_all(&dir).unwrap();
885 Self(dir)
886 }
887
888 fn path(&self) -> &Path {
889 &self.0
890 }
891 }
892
893 impl Drop for TempDir {
894 fn drop(&mut self) {
895 let _ = fs::remove_dir_all(&self.0);
896 }
897 }
898
899 struct EnvGuard {
904 key: String,
905 original: Option<String>,
906 }
907
908 impl EnvGuard {
909 fn new(key: &str, value: Option<&str>) -> Self {
910 let original = std::env::var(key).ok();
911 match value {
912 Some(v) => std::env::set_var(key, v),
913 None => std::env::remove_var(key),
914 }
915 Self {
916 key: key.to_string(),
917 original,
918 }
919 }
920 }
921
922 impl Drop for EnvGuard {
923 fn drop(&mut self) {
924 match &self.original {
925 Some(v) => std::env::set_var(&self.key, v),
926 None => std::env::remove_var(&self.key),
927 }
928 }
929 }
930
931 #[test]
936 fn test_validate_version_valid() {
937 assert!(validate_version("7.0.0").is_ok());
938 assert!(validate_version("6.1.0").is_ok());
939 assert!(validate_version("12.345.678").is_ok());
940 assert!(validate_version("1.2.3-beta.1").is_ok());
941 assert!(validate_version("1.2.3-rc1").is_ok());
942 assert!(validate_version("1.2.3.SNAPSHOT").is_ok());
943 }
944
945 #[test]
946 fn test_validate_version_rejects_path_separators() {
947 assert!(validate_version("../../../etc/passwd").is_err());
948 assert!(validate_version("7.0.0/../../bad").is_err());
949 assert!(validate_version("7.0.0\\..\\bad").is_err());
950 }
951
952 #[test]
953 fn test_validate_version_rejects_invalid_format() {
954 assert!(validate_version("").is_err());
955 assert!(validate_version("abc").is_err());
956 assert!(validate_version("7.0").is_err());
957 assert!(validate_version("7").is_err());
958 assert!(validate_version("7.0.0-").is_err()); }
960
961 #[test]
966 fn test_resolve_platform_returns_valid_triple() {
967 let p = resolve_platform().unwrap();
968 assert!(
969 ["linux", "darwin", "windows"].contains(&p.os_name),
970 "unexpected OS: {}",
971 p.os_name
972 );
973 assert!(
974 ["x86_64", "aarch64"].contains(&p.arch),
975 "unexpected arch: {}",
976 p.arch
977 );
978 if p.os_name == "windows" {
979 assert_eq!(p.ext, "zip");
980 } else {
981 assert_eq!(p.ext, "tar.gz");
982 }
983 }
984
985 #[cfg(target_os = "macos")]
986 #[test]
987 fn test_resolve_platform_macos() {
988 let p = resolve_platform().unwrap();
989 assert_eq!(p.os_name, "darwin");
990 assert_eq!(p.ext, "tar.gz");
991 }
992
993 #[cfg(target_os = "linux")]
994 #[test]
995 fn test_resolve_platform_linux() {
996 let p = resolve_platform().unwrap();
997 assert_eq!(p.os_name, "linux");
998 assert_eq!(p.ext, "tar.gz");
999 }
1000
1001 #[cfg(target_arch = "aarch64")]
1002 #[test]
1003 fn test_resolve_platform_aarch64() {
1004 let p = resolve_platform().unwrap();
1005 assert_eq!(p.arch, "aarch64");
1006 }
1007
1008 #[cfg(target_arch = "x86_64")]
1009 #[test]
1010 fn test_resolve_platform_x86_64() {
1011 let p = resolve_platform().unwrap();
1012 assert_eq!(p.arch, "x86_64");
1013 }
1014
1015 #[test]
1020 fn test_bundle_base_name() {
1021 let b = bundle_base_name("7.0.0").unwrap();
1022 let platform = resolve_platform().unwrap();
1023 assert_eq!(
1024 b.name,
1025 format!("mockserver-7.0.0-{}-{}", platform.os_name, platform.arch)
1026 );
1027 assert_eq!(b.ext, platform.ext);
1028 }
1029
1030 #[test]
1031 fn test_bundle_base_name_custom_version() {
1032 let b = bundle_base_name("6.1.0").unwrap();
1033 assert!(b.name.starts_with("mockserver-6.1.0-"));
1034 }
1035
1036 #[test]
1041 fn test_cache_dir_default_has_mockserver_binaries() {
1042 let _lock = ENV_MUTEX.lock().unwrap();
1043 let _guard = EnvGuard::new("MOCKSERVER_BINARY_CACHE", None);
1044 let dir = cache_dir();
1045 let path_str = dir.to_string_lossy();
1046 assert!(
1047 path_str.ends_with("mockserver/binaries")
1048 || path_str.ends_with("mockserver\\binaries"),
1049 "cache_dir should end with mockserver/binaries, got: {path_str}"
1050 );
1051 }
1052
1053 #[test]
1054 fn test_cache_dir_respects_env_override() {
1055 let _lock = ENV_MUTEX.lock().unwrap();
1056 let _guard = EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some("/custom/path"));
1057 let dir = cache_dir();
1058 assert_eq!(dir, PathBuf::from("/custom/path"));
1059 }
1060
1061 #[cfg(not(target_os = "windows"))]
1062 #[test]
1063 fn test_cache_dir_respects_xdg_cache_home() {
1064 let _lock = ENV_MUTEX.lock().unwrap();
1065 let _guard1 = EnvGuard::new("MOCKSERVER_BINARY_CACHE", None);
1066 let _guard2 = EnvGuard::new("XDG_CACHE_HOME", Some("/tmp/xdg-test-cache"));
1067 let dir = cache_dir();
1068 assert_eq!(
1069 dir,
1070 PathBuf::from("/tmp/xdg-test-cache/mockserver/binaries")
1071 );
1072 }
1073
1074 #[test]
1079 fn test_asset_url_default() {
1080 let _lock = ENV_MUTEX.lock().unwrap();
1081 let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1082 let url = asset_url("7.0.0", "mockserver-7.0.0-darwin-aarch64.tar.gz");
1083 assert_eq!(
1084 url,
1085 "https://github.com/mock-server/mockserver-monorepo/releases/download/mockserver-7.0.0/mockserver-7.0.0-darwin-aarch64.tar.gz"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_asset_url_custom_base() {
1091 let _lock = ENV_MUTEX.lock().unwrap();
1092 let _guard = EnvGuard::new(
1093 "MOCKSERVER_BINARY_BASE_URL",
1094 Some("https://mirror.internal/releases/"),
1095 );
1096 let url = asset_url("7.0.0", "mockserver-7.0.0-linux-x86_64.tar.gz");
1097 assert_eq!(
1098 url,
1099 "https://mirror.internal/releases/mockserver-7.0.0-linux-x86_64.tar.gz"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_asset_url_strips_trailing_slashes() {
1105 let _lock = ENV_MUTEX.lock().unwrap();
1106 let _guard = EnvGuard::new(
1107 "MOCKSERVER_BINARY_BASE_URL",
1108 Some("https://mirror.internal///"),
1109 );
1110 let url = asset_url("7.0.0", "file.tar.gz");
1111 assert_eq!(url, "https://mirror.internal/file.tar.gz");
1112 }
1113
1114 #[test]
1119 fn test_is_snapshot() {
1120 assert!(is_snapshot("7.0.0-SNAPSHOT"));
1121 assert!(is_snapshot("7.0.0-snapshot"));
1122 assert!(is_snapshot("7.0.0-Snapshot"));
1123 assert!(is_snapshot("7.1.0-SNAPSHOT"));
1124 assert!(!is_snapshot("7.0.0"));
1125 assert!(!is_snapshot("7.0.0-beta.1"));
1126 assert!(!is_snapshot("7.0.0-rc.1"));
1127 }
1128
1129 #[test]
1130 fn test_asset_url_snapshot_uses_cdn() {
1131 let _lock = ENV_MUTEX.lock().unwrap();
1132 let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1133 let url = asset_url(
1134 "7.1.0-SNAPSHOT",
1135 "mockserver-7.1.0-SNAPSHOT-darwin-aarch64.tar.gz",
1136 );
1137 assert_eq!(
1138 url,
1139 "https://downloads.mock-server.com/mockserver-7.1.0-SNAPSHOT/mockserver-7.1.0-SNAPSHOT-darwin-aarch64.tar.gz"
1140 );
1141 }
1142
1143 #[test]
1144 fn test_asset_url_release_uses_github() {
1145 let _lock = ENV_MUTEX.lock().unwrap();
1146 let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1147 let url = asset_url("7.0.0", "mockserver-7.0.0-darwin-aarch64.tar.gz");
1148 assert_eq!(
1149 url,
1150 "https://github.com/mock-server/mockserver-monorepo/releases/download/mockserver-7.0.0/mockserver-7.0.0-darwin-aarch64.tar.gz"
1151 );
1152 }
1153
1154 #[test]
1155 fn test_asset_url_env_override_wins_over_snapshot() {
1156 let _lock = ENV_MUTEX.lock().unwrap();
1157 let _guard = EnvGuard::new(
1158 "MOCKSERVER_BINARY_BASE_URL",
1159 Some("https://custom-mirror.example.com/bins"),
1160 );
1161 let url = asset_url(
1162 "7.1.0-SNAPSHOT",
1163 "mockserver-7.1.0-SNAPSHOT-linux-x86_64.tar.gz",
1164 );
1165 assert_eq!(
1166 url,
1167 "https://custom-mirror.example.com/bins/mockserver-7.1.0-SNAPSHOT-linux-x86_64.tar.gz"
1168 );
1169 }
1170
1171 #[test]
1176 fn test_launcher_path_unix() {
1177 let dir = PathBuf::from("/cache/7.0.0");
1178 let path = launcher_path(&dir, "mockserver-7.0.0-darwin-aarch64");
1179 if cfg!(target_os = "windows") {
1180 assert!(
1181 path.ends_with("bin/mockserver.bat")
1182 || path.ends_with("bin\\mockserver.bat")
1183 );
1184 } else {
1185 assert_eq!(
1186 path,
1187 PathBuf::from(
1188 "/cache/7.0.0/mockserver-7.0.0-darwin-aarch64/bin/mockserver"
1189 )
1190 );
1191 }
1192 }
1193
1194 #[test]
1199 fn test_sha256_bytes_known_value() {
1200 let digest = sha256_bytes(b"hello\n");
1202 assert_eq!(
1203 digest,
1204 "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_sha256_file_matches_sha256_bytes() {
1210 let tmp = TempDir::new("sha256");
1211 let file = tmp.path().join("test-data.bin");
1212 let data = b"MockServer binary launcher test content";
1213 fs::write(&file, data).unwrap();
1214
1215 let file_digest = sha256_file(&file).unwrap();
1216 let mem_digest = sha256_bytes(data);
1217 assert_eq!(file_digest, mem_digest);
1218 }
1219
1220 #[test]
1221 fn test_sha256_verification_correct_checksum() {
1222 let tmp = TempDir::new("sha-ok");
1223 let archive_data = b"fake-archive-content-12345";
1224 let archive_path = tmp.path().join("archive.tar.gz");
1225 fs::write(&archive_path, archive_data).unwrap();
1226
1227 let expected = sha256_bytes(archive_data);
1228 let actual = sha256_file(&archive_path).unwrap();
1229 assert_eq!(expected, actual, "checksum should match");
1230 }
1231
1232 #[test]
1233 fn test_sha256_verification_wrong_checksum() {
1234 let tmp = TempDir::new("sha-bad");
1235 let archive_data = b"correct content";
1236 let archive_path = tmp.path().join("archive.tar.gz");
1237 fs::write(&archive_path, archive_data).unwrap();
1238
1239 let wrong = sha256_bytes(b"DIFFERENT content");
1240 let actual = sha256_file(&archive_path).unwrap();
1241 assert_ne!(wrong, actual, "checksums should differ");
1242 }
1243
1244 #[test]
1249 fn test_skip_download_env_fails_when_no_cached_binary() {
1250 let _lock = ENV_MUTEX.lock().unwrap();
1251 let tmp = TempDir::new("skip-dl");
1252 let _guard_cache =
1253 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
1254 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", Some("1"));
1255 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1256
1257 let result = ensure_binary("99.99.99", &EnsureOptions::default());
1258 assert!(result.is_err());
1259 let err = result.unwrap_err();
1260 let msg = format!("{err}");
1261 assert!(
1262 msg.contains("MOCKSERVER_SKIP_BINARY_DOWNLOAD"),
1263 "error should mention env var: {msg}"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_skip_download_returns_cached_binary() {
1269 let _lock = ENV_MUTEX.lock().unwrap();
1270 let tmp = TempDir::new("skip-ok");
1271 let version = "99.99.99";
1272 let meta = bundle_base_name(version).unwrap();
1273
1274 let dir = tmp.path().join(version);
1276 let launcher = launcher_path(&dir, &meta.name);
1277 fs::create_dir_all(launcher.parent().unwrap()).unwrap();
1278 fs::write(&launcher, "#!/bin/sh\necho mock").unwrap();
1279
1280 let _guard_cache =
1281 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
1282 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", Some("1"));
1283
1284 let result = ensure_binary(version, &EnsureOptions::default());
1285 assert!(result.is_ok());
1286 assert_eq!(result.unwrap(), launcher);
1287 }
1288
1289 #[test]
1294 fn test_parse_semver_segments_valid() {
1295 assert_eq!(
1296 parse_semver_segments("7.0.0"),
1297 Some((7, 0, 0, String::new()))
1298 );
1299 assert_eq!(
1300 parse_semver_segments("12.345.678"),
1301 Some((12, 345, 678, String::new()))
1302 );
1303 assert_eq!(
1304 parse_semver_segments("1.2.3-beta.1"),
1305 Some((1, 2, 3, "-beta.1".to_string()))
1306 );
1307 }
1308
1309 #[test]
1310 fn test_parse_semver_segments_invalid() {
1311 assert!(parse_semver_segments("abc").is_none());
1312 assert!(parse_semver_segments("7.0").is_none());
1313 assert!(parse_semver_segments("").is_none());
1314 }
1315
1316 #[test]
1321 fn test_compare_pre_release_release_is_greater_than_snapshot() {
1322 assert_eq!(
1324 compare_pre_release("", "-SNAPSHOT"),
1325 std::cmp::Ordering::Greater,
1326 "release (empty) should sort above -SNAPSHOT"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_compare_pre_release_snapshot_is_less_than_release() {
1332 assert_eq!(
1333 compare_pre_release("-SNAPSHOT", ""),
1334 std::cmp::Ordering::Less,
1335 "-SNAPSHOT should sort below release (empty)"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_compare_pre_release_both_empty() {
1341 assert_eq!(
1342 compare_pre_release("", ""),
1343 std::cmp::Ordering::Equal,
1344 );
1345 }
1346
1347 #[test]
1348 fn test_compare_pre_release_both_pre_releases() {
1349 assert_eq!(
1351 compare_pre_release("-alpha", "-beta"),
1352 std::cmp::Ordering::Less,
1353 );
1354 assert_eq!(
1355 compare_pre_release("-beta", "-alpha"),
1356 std::cmp::Ordering::Greater,
1357 );
1358 assert_eq!(
1359 compare_pre_release("-rc1", "-rc1"),
1360 std::cmp::Ordering::Equal,
1361 );
1362 }
1363
1364 #[test]
1369 fn test_prune_removes_old_versions_keeps_current_and_one_previous() {
1370 let tmp = TempDir::new("prune");
1371 let base = tmp.path();
1372
1373 let v1 = base.join("1.0.0");
1375 let v2 = base.join("2.0.0");
1376 let v3 = base.join("3.0.0");
1377 fs::create_dir_all(&v1).unwrap();
1378 fs::write(v1.join("marker"), "v1").unwrap();
1379 fs::create_dir_all(&v2).unwrap();
1380 fs::write(v2.join("marker"), "v2").unwrap();
1381 fs::create_dir_all(&v3).unwrap();
1382 fs::write(v3.join("marker"), "v3").unwrap();
1383
1384 fs::write(base.join("something.part"), "temp").unwrap();
1386
1387 prune_old_versions(base, "3.0.0");
1389
1390 assert!(v3.exists(), "current version should be kept");
1392 assert!(v2.exists(), "one previous version should be kept");
1394 assert!(!v1.exists(), "older versions should be pruned");
1396 assert!(
1398 !base.join("something.part").exists(),
1399 ".part files should be cleaned"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_prune_semver_aware_not_lexicographic() {
1405 let tmp = TempDir::new("prune-semver");
1407 let base = tmp.path();
1408
1409 fs::create_dir_all(base.join("2.0.0")).unwrap();
1410 fs::create_dir_all(base.join("9.0.0")).unwrap();
1411 fs::create_dir_all(base.join("10.0.0")).unwrap();
1412
1413 prune_old_versions(base, "11.0.0");
1415
1416 assert!(
1418 base.join("10.0.0").exists(),
1419 "semver-newest previous (10.0.0) should be kept"
1420 );
1421 assert!(
1423 !base.join("9.0.0").exists(),
1424 "9.0.0 should be pruned"
1425 );
1426 assert!(
1427 !base.join("2.0.0").exists(),
1428 "2.0.0 should be pruned"
1429 );
1430 }
1431
1432 #[test]
1433 fn test_prune_with_single_old_version_keeps_it() {
1434 let tmp = TempDir::new("prune-single");
1435 let base = tmp.path();
1436
1437 let v1 = base.join("1.0.0");
1438 let v2 = base.join("2.0.0");
1439 fs::create_dir_all(&v1).unwrap();
1440 fs::write(v1.join("marker"), "v1").unwrap();
1441 fs::create_dir_all(&v2).unwrap();
1442 fs::write(v2.join("marker"), "v2").unwrap();
1443
1444 prune_old_versions(base, "2.0.0");
1445
1446 assert!(v2.exists(), "current version should be kept");
1447 assert!(v1.exists(), "single previous version should be kept");
1448 }
1449
1450 #[test]
1451 fn test_prune_cleans_part_files_in_current_version_dir() {
1452 let tmp = TempDir::new("prune-part");
1453 let base = tmp.path();
1454
1455 let current = base.join("5.0.0");
1456 fs::create_dir_all(¤t).unwrap();
1457 fs::write(current.join("archive.tar.gz.part"), "partial").unwrap();
1458 fs::write(current.join("real-file.txt"), "keep").unwrap();
1459
1460 prune_old_versions(base, "5.0.0");
1461
1462 assert!(
1463 !current.join("archive.tar.gz.part").exists(),
1464 ".part inside current should be cleaned"
1465 );
1466 assert!(
1467 current.join("real-file.txt").exists(),
1468 "non-.part files should be kept"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_prune_cleans_sha256_temp_files() {
1474 let tmp = TempDir::new("prune-sha");
1475 let base = tmp.path();
1476
1477 fs::create_dir_all(base.join("5.0.0")).unwrap();
1478 fs::write(base.join("archive.tar.gz.sha256"), "temp").unwrap();
1479
1480 prune_old_versions(base, "5.0.0");
1481
1482 assert!(
1483 !base.join("archive.tar.gz.sha256").exists(),
1484 ".sha256 files at cache root should be cleaned"
1485 );
1486 }
1487
1488 #[test]
1489 fn test_prune_empty_cache_is_safe() {
1490 let tmp = TempDir::new("prune-empty");
1491 prune_old_versions(tmp.path(), "1.0.0");
1493 }
1494
1495 #[test]
1496 fn test_prune_nonexistent_cache_dir_is_safe() {
1497 let nonexistent = PathBuf::from("/tmp/mockserver-test-nonexistent-dir-12345");
1498 prune_old_versions(&nonexistent, "1.0.0");
1500 }
1501
1502 #[test]
1503 fn test_prune_with_many_old_versions() {
1504 let tmp = TempDir::new("prune-many");
1505 let base = tmp.path();
1506
1507 for i in 1..=5 {
1509 let v = base.join(format!("{i}.0.0"));
1510 fs::create_dir_all(&v).unwrap();
1511 fs::write(v.join("marker"), format!("v{i}")).unwrap();
1512 }
1513 let current = base.join("6.0.0");
1514 fs::create_dir_all(¤t).unwrap();
1515
1516 prune_old_versions(base, "6.0.0");
1517
1518 assert!(current.exists(), "current must be kept");
1519 assert!(
1521 base.join("5.0.0").exists(),
1522 "newest previous version should be kept"
1523 );
1524 for i in 1..=4 {
1526 let v = base.join(format!("{i}.0.0"));
1527 assert!(!v.exists(), "version {i}.0.0 should be pruned");
1528 }
1529 }
1530
1531 #[test]
1532 fn test_prune_keeps_release_over_snapshot() {
1533 let tmp = TempDir::new("prune-rel-snap");
1537 let base = tmp.path();
1538
1539 let release = base.join("7.0.0");
1540 let snapshot = base.join("7.0.0-SNAPSHOT");
1541 let current = base.join("8.0.0");
1542 fs::create_dir_all(&release).unwrap();
1543 fs::write(release.join("marker"), "release").unwrap();
1544 fs::create_dir_all(&snapshot).unwrap();
1545 fs::write(snapshot.join("marker"), "snapshot").unwrap();
1546 fs::create_dir_all(¤t).unwrap();
1547
1548 prune_old_versions(base, "8.0.0");
1549
1550 assert!(current.exists(), "current version should be kept");
1551 assert!(
1552 release.exists(),
1553 "release 7.0.0 should be kept (newer than SNAPSHOT)"
1554 );
1555 assert!(
1556 !snapshot.exists(),
1557 "7.0.0-SNAPSHOT should be pruned (older than release)"
1558 );
1559 }
1560
1561 #[test]
1562 fn test_prune_release_never_deleted_in_favour_of_snapshot() {
1563 let tmp = TempDir::new("prune-no-snap-win");
1566 let base = tmp.path();
1567
1568 let release = base.join("6.1.0");
1569 let snapshot = base.join("6.1.0-SNAPSHOT");
1570 fs::create_dir_all(&release).unwrap();
1571 fs::create_dir_all(&snapshot).unwrap();
1572
1573 prune_old_versions(base, "7.0.0");
1574
1575 assert!(
1576 release.exists(),
1577 "release 6.1.0 must be kept, not its SNAPSHOT"
1578 );
1579 assert!(
1580 !snapshot.exists(),
1581 "6.1.0-SNAPSHOT must be pruned when release exists"
1582 );
1583 }
1584
1585 struct TestServer {
1592 base_url: String,
1593 server: tiny_http::Server,
1594 serve_dir: PathBuf,
1595 }
1596
1597 impl TestServer {
1598 fn start(serve_dir: PathBuf) -> Self {
1599 let server =
1600 tiny_http::Server::http("127.0.0.1:0").expect("failed to start test HTTP server");
1601 let addr = server.server_addr().to_ip().unwrap();
1602 let base_url = format!("http://127.0.0.1:{}", addr.port());
1603 TestServer {
1604 base_url,
1605 server,
1606 serve_dir,
1607 }
1608 }
1609
1610 fn serve_in_background(self) -> (String, std::thread::JoinHandle<()>) {
1612 let base_url = self.base_url.clone();
1613 let handle = std::thread::spawn(move || {
1614 for _ in 0..10 {
1616 let request = match self.server.recv_timeout(
1617 std::time::Duration::from_secs(10),
1618 ) {
1619 Ok(Some(r)) => r,
1620 _ => break,
1621 };
1622
1623 let url_path = request.url().to_string();
1624 let file_name = url_path.trim_start_matches('/');
1626 let file_path = self.serve_dir.join(file_name);
1627
1628 if file_path.exists() {
1629 let data = fs::read(&file_path).unwrap();
1630 let response = tiny_http::Response::from_data(data);
1631 let _ = request.respond(response);
1632 } else {
1633 let response = tiny_http::Response::from_string("Not Found")
1634 .with_status_code(tiny_http::StatusCode(404));
1635 let _ = request.respond(response);
1636 }
1637 }
1638 });
1639 (base_url, handle)
1640 }
1641 }
1642
1643 fn build_fixture_archive(
1645 work_dir: &Path,
1646 bundle_name: &str,
1647 ) -> (PathBuf, String) {
1648 let archive_root = work_dir.join("build");
1649 let bin_dir = archive_root.join(bundle_name).join("bin");
1650 fs::create_dir_all(&bin_dir).unwrap();
1651 fs::write(bin_dir.join("mockserver"), "#!/bin/sh\necho mock").unwrap();
1652
1653 let ext = if cfg!(target_os = "windows") {
1654 "zip"
1655 } else {
1656 "tar.gz"
1657 };
1658 let archive_file = format!("{bundle_name}.{ext}");
1659 let archive_path = work_dir.join(&archive_file);
1660
1661 let status = Command::new("tar")
1662 .args([
1663 "-czf",
1664 &archive_path.to_string_lossy(),
1665 "-C",
1666 &archive_root.to_string_lossy(),
1667 bundle_name,
1668 ])
1669 .status()
1670 .unwrap();
1671 assert!(status.success(), "tar create should succeed");
1672
1673 let checksum = sha256_file(&archive_path).unwrap();
1674 (archive_path, checksum)
1675 }
1676
1677 #[test]
1678 fn test_ensure_binary_download_and_verify_sha256() {
1679 let _lock = ENV_MUTEX.lock().unwrap();
1681 let tmp = TempDir::new("ensure-http-ok");
1682 let version = "99.0.0";
1683 let meta = bundle_base_name(version).unwrap();
1684 let archive_file = format!("{}.{}", meta.name, meta.ext);
1685
1686 let serve_dir = tmp.path().join("serve");
1687 fs::create_dir_all(&serve_dir).unwrap();
1688
1689 let (archive_path, checksum) = build_fixture_archive(tmp.path(), &meta.name);
1690 fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
1691 fs::write(
1692 serve_dir.join(format!("{archive_file}.sha256")),
1693 format!("{checksum} {archive_file}\n"),
1694 )
1695 .unwrap();
1696
1697 let server = TestServer::start(serve_dir);
1698 let (base_url, _handle) = server.serve_in_background();
1699
1700 let cache = tmp.path().join("cache");
1701 let _guard_cache =
1702 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1703 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1704 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1705
1706 let result = ensure_binary(version, &EnsureOptions { verbose: true });
1707 assert!(result.is_ok(), "ensure_binary should succeed: {:?}", result);
1708 let launcher = result.unwrap();
1709 assert!(launcher.exists(), "launcher should exist after download+extract");
1710 assert!(
1711 fs::metadata(&launcher).unwrap().len() > 0,
1712 "launcher should be non-empty"
1713 );
1714 }
1715
1716 #[test]
1717 fn test_ensure_binary_sha256_mismatch_fails_and_cleans_up() {
1718 let _lock = ENV_MUTEX.lock().unwrap();
1720 let tmp = TempDir::new("ensure-http-bad-sha");
1721 let version = "99.0.1";
1722 let meta = bundle_base_name(version).unwrap();
1723 let archive_file = format!("{}.{}", meta.name, meta.ext);
1724
1725 let serve_dir = tmp.path().join("serve");
1726 fs::create_dir_all(&serve_dir).unwrap();
1727
1728 let (archive_path, _checksum) = build_fixture_archive(tmp.path(), &meta.name);
1729 fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
1730 fs::write(
1732 serve_dir.join(format!("{archive_file}.sha256")),
1733 "0000000000000000000000000000000000000000000000000000000000000000 file\n",
1734 )
1735 .unwrap();
1736
1737 let server = TestServer::start(serve_dir);
1738 let (base_url, _handle) = server.serve_in_background();
1739
1740 let cache = tmp.path().join("cache");
1741 let _guard_cache =
1742 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1743 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1744 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1745
1746 let result = ensure_binary(version, &EnsureOptions::default());
1747 assert!(result.is_err(), "should fail on checksum mismatch");
1748 let msg = format!("{}", result.unwrap_err());
1749 assert!(msg.contains("checksum mismatch"), "error message: {msg}");
1750
1751 let ver_dir = cache.join(version);
1753 let part_file = PathBuf::from(format!(
1754 "{}.part",
1755 ver_dir.join(&archive_file).display()
1756 ));
1757 assert!(
1758 !part_file.exists(),
1759 ".part file should be cleaned up on failure"
1760 );
1761 }
1762
1763 #[test]
1764 fn test_ensure_binary_empty_sha256_fails() {
1765 let _lock = ENV_MUTEX.lock().unwrap();
1767 let tmp = TempDir::new("ensure-http-empty-sha");
1768 let version = "99.0.2";
1769 let meta = bundle_base_name(version).unwrap();
1770 let archive_file = format!("{}.{}", meta.name, meta.ext);
1771
1772 let serve_dir = tmp.path().join("serve");
1773 fs::create_dir_all(&serve_dir).unwrap();
1774
1775 let (archive_path, _checksum) = build_fixture_archive(tmp.path(), &meta.name);
1776 fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
1777 fs::write(
1779 serve_dir.join(format!("{archive_file}.sha256")),
1780 "",
1781 )
1782 .unwrap();
1783
1784 let server = TestServer::start(serve_dir);
1785 let (base_url, _handle) = server.serve_in_background();
1786
1787 let cache = tmp.path().join("cache");
1788 let _guard_cache =
1789 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1790 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1791 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1792
1793 let result = ensure_binary(version, &EnsureOptions::default());
1794 assert!(result.is_err(), "should fail on empty checksum");
1795 let msg = format!("{}", result.unwrap_err());
1796 assert!(
1797 msg.contains("empty or unparseable"),
1798 "error message: {msg}"
1799 );
1800 }
1801
1802 #[test]
1803 fn test_ensure_binary_missing_bundle_404_gives_actionable_error() {
1804 let _lock = ENV_MUTEX.lock().unwrap();
1807 let tmp = TempDir::new("ensure-http-404");
1808 let version = "99.9.9";
1809
1810 let serve_dir = tmp.path().join("serve");
1811 fs::create_dir_all(&serve_dir).unwrap();
1812
1813 let server = TestServer::start(serve_dir);
1814 let (base_url, _handle) = server.serve_in_background();
1815
1816 let cache = tmp.path().join("cache");
1817 let _guard_cache =
1818 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1819 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1820 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1821
1822 let result = ensure_binary(version, &EnsureOptions::default());
1823 assert!(result.is_err(), "should fail when no bundle exists");
1824 let msg = format!("{}", result.unwrap_err());
1825 assert!(msg.contains(version), "should name the version: {msg}");
1826 assert!(
1827 msg.contains("no MockServer release bundle"),
1828 "should explain no bundle: {msg}"
1829 );
1830 assert!(
1831 msg.contains("docker run mockserver/mockserver:mockserver-99.9.9"),
1832 "should suggest Docker: {msg}"
1833 );
1834 assert!(
1835 msg.contains("org.mock-server:mockserver-netty:99.9.9"),
1836 "should suggest Maven Central jar: {msg}"
1837 );
1838 }
1839
1840 #[test]
1845 fn test_version_is_valid_and_non_empty() {
1846 assert!(!VERSION.is_empty(), "VERSION should be non-empty");
1850 assert!(
1851 validate_version(VERSION).is_ok(),
1852 "VERSION should be a valid semver: {VERSION}"
1853 );
1854 }
1855
1856 #[test]
1861 #[ignore] fn test_ensure_binary_live_download() {
1863 let _lock = ENV_MUTEX.lock().unwrap();
1864 let tmp = TempDir::new("live-dl");
1865 let _guard =
1866 EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
1867 let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1868 let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1869
1870 let result = ensure_binary(
1871 VERSION,
1872 &EnsureOptions { verbose: true },
1873 );
1874 match result {
1875 Ok(path) => {
1876 assert!(path.exists());
1877 assert!(fs::metadata(&path).unwrap().len() > 0);
1878 }
1879 Err(e) => {
1880 eprintln!("Live download failed (expected if no release): {e}");
1882 }
1883 }
1884 }
1885}