Skip to main content

mockserver_client/
launcher.rs

1//! On-demand binary launcher for MockServer.
2//!
3//! Downloads the self-contained, JVM-less MockServer binary bundle for the
4//! current platform from a GitHub Release, verifies its SHA-256, caches it
5//! per-user, and launches it.  No Java installation and no Docker required.
6//!
7//! This is a faithful port of the reference implementation in
8//! `mockserver-node/downloadBinary.js`.
9//!
10//! # Environment overrides
11//!
12//! | Variable | Purpose |
13//! |----------|---------|
14//! | `MOCKSERVER_BINARY_BASE_URL` | Mirror host for the release assets (corporate / air-gapped) |
15//! | `MOCKSERVER_BINARY_CACHE` | Cache directory (default: per-OS user cache) |
16//! | `MOCKSERVER_SKIP_BINARY_DOWNLOAD` | Fail instead of downloading (air-gapped CI with a pre-seeded cache) |
17//! | `HTTPS_PROXY` / `HTTP_PROXY` | Honoured by the HTTP client for proxy routing |
18
19use std::fs;
20use std::io::{self, Read};
21use std::path::{Path, PathBuf};
22use std::process::{Child, Command, Stdio};
23
24use sha2::{Digest, Sha256};
25
26// ---------------------------------------------------------------------------
27// Constants
28// ---------------------------------------------------------------------------
29
30const REPO: &str = "mock-server/mockserver-monorepo";
31
32/// The MockServer version this client targets — derived from Cargo.toml at
33/// compile time.
34pub const VERSION: &str = env!("CARGO_PKG_VERSION");
35
36/// HTTP connect + read timeout for binary downloads (10 minutes).
37const HTTP_TIMEOUT_SECS: u64 = 600;
38
39// ---------------------------------------------------------------------------
40// Error type
41// ---------------------------------------------------------------------------
42
43/// Errors specific to the binary launcher.
44#[derive(Debug)]
45pub enum LauncherError {
46    /// The current OS or architecture is not supported.
47    UnsupportedPlatform(String),
48    /// Download was skipped (MOCKSERVER_SKIP_BINARY_DOWNLOAD is set) but no
49    /// cached binary exists.
50    SkipDownload(String),
51    /// SHA-256 checksum verification failed.
52    ChecksumMismatch {
53        expected: String,
54        actual: String,
55    },
56    /// The checksum file was empty or unparseable.
57    ChecksumMissing(String),
58    /// An I/O error (download, filesystem, process spawn).
59    Io(io::Error),
60    /// An HTTP transport error.
61    Http(String),
62    /// The server responded with HTTP 404 — no asset was published at the URL.
63    NotFound(String),
64    /// No downloadable release bundle exists for the requested version.
65    NoBundle(String),
66    /// The extraction command (`tar`) failed.
67    ExtractionFailed(String),
68    /// The launcher binary was missing or empty after extraction.
69    LauncherMissing(String),
70    /// The version string is invalid or contains path traversal.
71    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
119/// Result type alias for launcher operations.
120pub type LauncherResult<T> = Result<T, LauncherError>;
121
122// ---------------------------------------------------------------------------
123// Version validation (H1)
124// ---------------------------------------------------------------------------
125
126/// Validate a version string against the semver-like pattern and reject path
127/// separators or `..` sequences. Returns the version unchanged if valid.
128fn validate_version(version: &str) -> LauncherResult<&str> {
129    // Reject path separators and parent-directory traversal.
130    if version.contains('/') || version.contains('\\') || version.contains("..") {
131        return Err(LauncherError::InvalidVersion(format!(
132            "version contains path separator or '..': {version}"
133        )));
134    }
135    // Must match: digits.digits.digits optionally followed by [-.][alphanumeric/dot]+
136    // Pattern: ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$
137    let mut chars = version.chars().peekable();
138
139    // Helper: consume one or more digits, return false if none.
140    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    // Optional pre-release / build metadata: [-.][0-9A-Za-z.]+
166    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
190/// Assert that `child` is a descendant of `base`. Both paths are
191/// canonicalized (resolving symlinks) before the check. If `child` does
192/// not yet exist, we canonicalize its parent (which must exist) and append
193/// the final component — this avoids false negatives on macOS where `/var`
194/// is a symlink to `/private/var`.
195fn 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        // Parent should exist (it's the cache base we just created).
202        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// ---------------------------------------------------------------------------
220// Platform detection
221// ---------------------------------------------------------------------------
222
223/// Resolved platform triple for the bundle file name.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct Platform {
226    /// `linux`, `darwin`, or `windows`.
227    pub os_name: &'static str,
228    /// `x86_64` or `aarch64`.
229    pub arch: &'static str,
230    /// `tar.gz` or `zip`.
231    pub ext: &'static str,
232}
233
234/// Map the current platform and architecture to the bundle naming tokens.
235///
236/// Returns an error on unsupported OS/arch combinations.
237pub 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// ---------------------------------------------------------------------------
273// Bundle naming
274// ---------------------------------------------------------------------------
275
276/// The base name and extension of the bundle archive for the given version.
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct BundleName {
279    /// e.g. `mockserver-7.0.0-darwin-aarch64`
280    pub name: String,
281    /// e.g. `tar.gz`
282    pub ext: &'static str,
283}
284
285/// Compute the bundle base name for a given version on the current platform.
286pub 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
297// ---------------------------------------------------------------------------
298// Cache directory
299// ---------------------------------------------------------------------------
300
301/// Resolve the binary cache base directory.
302///
303/// Precedence:
304/// 1. `MOCKSERVER_BINARY_CACHE` env var
305/// 2. Windows: `%LOCALAPPDATA%` (fallback `~/AppData/Local`)
306/// 3. Unix: `$XDG_CACHE_HOME` or `~/.cache`
307///
308/// Then append `/mockserver/binaries`.
309pub 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
336// ---------------------------------------------------------------------------
337// Asset URL
338// ---------------------------------------------------------------------------
339
340/// The CDN base URL used for SNAPSHOT version downloads.
341const SNAPSHOT_CDN: &str = "https://downloads.mock-server.com";
342
343/// Build a clear, actionable error message for a missing release bundle.
344///
345/// Explains that no downloadable bundle exists for `version` and lists the
346/// concrete alternatives. The wording is kept consistent across all client
347/// languages.
348fn 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
358/// Returns `true` if the version string contains "-SNAPSHOT" (case-insensitive),
359/// indicating a pre-release snapshot build.
360pub fn is_snapshot(version: &str) -> bool {
361    version.to_ascii_uppercase().contains("-SNAPSHOT")
362}
363
364/// Compute the download URL for a release asset.
365///
366/// Uses `MOCKSERVER_BINARY_BASE_URL` if set; otherwise defaults to GitHub
367/// Releases for release versions and the downloads.mock-server.com CDN for
368/// SNAPSHOT versions.
369pub 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
383// ---------------------------------------------------------------------------
384// Launcher path
385// ---------------------------------------------------------------------------
386
387/// The expected path to the launcher binary inside the extracted bundle.
388pub 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
397// ---------------------------------------------------------------------------
398// SHA-256 helpers
399// ---------------------------------------------------------------------------
400
401/// Compute the SHA-256 hex digest of a file.
402pub 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
416/// Compute the SHA-256 hex digest of a byte slice (used in tests).
417pub fn sha256_bytes(data: &[u8]) -> String {
418    let mut hasher = Sha256::new();
419    hasher.update(data);
420    format!("{:x}", hasher.finalize())
421}
422
423// ---------------------------------------------------------------------------
424// Download helper
425// ---------------------------------------------------------------------------
426
427/// Download a URL to a local file path, streaming the body to disk to avoid
428/// buffering large binaries in memory. Proxy env vars (`HTTPS_PROXY`,
429/// `HTTP_PROXY`) are honoured by the reqwest client.
430fn 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    // Stream body directly to file to avoid a large heap allocation (H6/SEC-09).
453    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// ---------------------------------------------------------------------------
460// Ensure binary (core logic)
461// ---------------------------------------------------------------------------
462
463/// Options for [`ensure_binary`].
464#[derive(Debug, Clone, Default)]
465pub struct EnsureOptions {
466    /// Optional logger closure. If `false`, progress is silent.
467    pub verbose: bool,
468}
469
470/// Ensure the platform binary is present and return the launcher path,
471/// downloading + verifying + extracting + caching on first use.
472///
473/// SHA-256 verification is always performed when downloading — it cannot be
474/// skipped by external callers.
475///
476/// After a successful install the cache is pruned: only the current version
477/// directory (and at most one previous) is kept; stale `.part` temp files are
478/// removed.
479pub fn ensure_binary(version: &str, opts: &EnsureOptions) -> LauncherResult<PathBuf> {
480    // H1: validate version string.
481    validate_version(version)?;
482
483    let meta = bundle_base_name(version)?;
484    let base = cache_dir();
485    let dir = base.join(version);
486
487    // H1: assert the version directory stays within the cache base.
488    // Create the base first so canonicalize can resolve it.
489    fs::create_dir_all(&base)?;
490    assert_within(&base, &dir)?;
491
492    let launcher = launcher_path(&dir, &meta.name);
493
494    // Reuse cached binary if it exists and is non-empty.
495    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    // MOCKSERVER_SKIP_BINARY_DOWNLOAD => fail instead of downloading.
506    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    // Download to a temp .part file; rename only after checksum passes.
521    let url = asset_url(version, &archive_file);
522    if opts.verbose {
523        eprintln!("Downloading {url}");
524    }
525
526    let result = (|| -> LauncherResult<()> {
527        // A 404 on the archive means the release tag exists but ships no bundle
528        // for this version (or the tag does not exist). Translate it into
529        // actionable guidance instead of an opaque HTTP error.
530        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        // Verify SHA-256 — always fail-closed. (H2: not bypassable)
539        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        // H3: best-effort cleanup of BOTH the .part and .sha256 temp files.
571        let _ = fs::remove_file(&partial);
572        let _ = fs::remove_file(&sha_file);
573        return Err(e);
574    }
575
576    // Extract with the system tar (handles both .tar.gz and .zip on
577    // macOS/Linux bsdtar and GNU tar).
578    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    // H3: verify no extracted file escaped verDir.
607    if let Ok(entries) = fs::read_dir(&dir) {
608        for entry in entries.flatten() {
609            let p = entry.path();
610            // Resolve symlinks to catch escapes.
611            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    // Verify the launcher exists and is non-empty.
623    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    // chmod 0755 on non-Windows.
633    #[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 version directories from the cache.
640    prune_old_versions(&base, version);
641
642    Ok(launcher)
643}
644
645// ---------------------------------------------------------------------------
646// Cache pruning
647// ---------------------------------------------------------------------------
648
649/// Parse a version string into numeric segments for semver-aware comparison
650/// (H7). Returns `None` if it does not start with three numeric dot-separated
651/// segments.
652fn 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
666/// Compare two pre-release suffixes for semver ordering.
667///
668/// Per semver, a release (empty pre-release) is NEWER/GREATER than any
669/// pre-release of the same numeric version (e.g. `7.0.0 > 7.0.0-SNAPSHOT`).
670/// Among two pre-releases, compare their identifiers lexicographically.
671fn 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        // a is a release, b is a pre-release => a is newer (greater)
675        (true, false) => std::cmp::Ordering::Greater,
676        // a is a pre-release, b is a release => b is newer (a is less)
677        (false, true) => std::cmp::Ordering::Less,
678        // both are pre-releases => compare lexicographically
679        (false, false) => a.cmp(b),
680    }
681}
682
683/// Remove old version directories from the cache base, keeping only the
684/// current version (and at most one previous). Also removes leftover `.part`
685/// and `.sha256` temp files. Safe: never deletes outside the cache dir;
686/// tolerates concurrent runs.
687pub 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, // cache dir might not exist yet
691    };
692
693    // Collect version directories (entries that are directories and look like
694    // version strings — we just check they are dirs and not the current one).
695    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        // Clean up stale .part and .sha256 temp files at the cache root level.
701        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 up .part files inside the current version dir too.
719            clean_part_files(&path);
720            continue;
721        }
722
723        // Safety: only remove dirs that are children of cache_base.
724        if path.parent() != Some(cache_base) {
725            continue;
726        }
727
728        // H7: parse semver for comparison; skip non-version directories.
729        if let Some(semver) = parse_semver_segments(&dir_name) {
730            version_dirs.push((path, semver));
731        }
732    }
733
734    // H7: sort by semver descending (newest first) using numeric comparison.
735    // When numeric cores are equal, a release (empty pre-release) is NEWER
736    // than a pre-release (e.g. 7.0.0 > 7.0.0-SNAPSHOT).
737    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    // Keep at most one previous version (the newest one).
746    for (path, _) in version_dirs.iter().skip(1) {
747        let _ = fs::remove_dir_all(path);
748    }
749}
750
751/// Remove `.part` temp files from a directory.
752fn 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
763// ---------------------------------------------------------------------------
764// Server handle
765// ---------------------------------------------------------------------------
766
767/// A handle to a running MockServer process.
768///
769/// When dropped, the process is killed (SIGKILL on Unix, TerminateProcess on
770/// Windows).
771pub struct ServerHandle {
772    child: Child,
773    port: u16,
774}
775
776impl ServerHandle {
777    /// The port the server was started on.
778    pub fn port(&self) -> u16 {
779        self.port
780    }
781
782    /// Stop the server by killing the process.
783    pub fn stop(&mut self) -> LauncherResult<()> {
784        self.child.kill().map_err(LauncherError::Io)?;
785        let _ = self.child.wait();
786        Ok(())
787    }
788
789    /// Wait for the child process to exit, returning its exit status.
790    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
802// ---------------------------------------------------------------------------
803// Public API: start / ensure_launcher
804// ---------------------------------------------------------------------------
805
806/// Ensure the launcher binary is available and return its path.
807///
808/// This is a convenience wrapper around [`ensure_binary`] that uses the
809/// crate's compiled-in version.
810pub fn ensure_launcher() -> LauncherResult<PathBuf> {
811    ensure_binary(VERSION, &EnsureOptions::default())
812}
813
814/// Download (if needed) and start a MockServer on the given port.
815///
816/// Returns a [`ServerHandle`] that can be used to stop the server. The
817/// process inherits stdout/stderr by default so logs are visible.
818pub fn start(port: u16) -> LauncherResult<ServerHandle> {
819    start_with_version(VERSION, port, &EnsureOptions::default())
820}
821
822/// Start a MockServer of the given version on the given port.
823pub 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    // On Windows, .bat files cannot be spawned directly by
831    // std::process::Command — they must be invoked via `cmd.exe /c`.
832    // On non-Windows, spawn the launcher directly.
833    #[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        // CREATE_NO_WINDOW suppresses the console window for background use.
839        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// ---------------------------------------------------------------------------
858// Tests
859// ---------------------------------------------------------------------------
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use std::fs;
865    use std::path::PathBuf;
866    use std::sync::Mutex;
867
868    // Global mutex to serialize tests that mutate environment variables.
869    // Rust's test harness runs tests in parallel within the same process,
870    // and env vars are process-global, so we must serialize access.
871    static ENV_MUTEX: Mutex<()> = Mutex::new(());
872
873    // Helper: create a temp directory that is cleaned up when dropped.
874    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    // -----------------------------------------------------------------------
900    // EnvGuard — temporarily set or unset an env var, restore on drop.
901    // -----------------------------------------------------------------------
902
903    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    // -----------------------------------------------------------------------
932    // Version validation (H1)
933    // -----------------------------------------------------------------------
934
935    #[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()); // trailing dash with nothing after
959    }
960
961    // -----------------------------------------------------------------------
962    // Platform / arch detection
963    // -----------------------------------------------------------------------
964
965    #[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    // -----------------------------------------------------------------------
1016    // Bundle naming
1017    // -----------------------------------------------------------------------
1018
1019    #[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    // -----------------------------------------------------------------------
1037    // Cache path resolution
1038    // -----------------------------------------------------------------------
1039
1040    #[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    // -----------------------------------------------------------------------
1075    // Asset URL
1076    // -----------------------------------------------------------------------
1077
1078    #[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    // -----------------------------------------------------------------------
1115    // SNAPSHOT vs Release URL selection
1116    // -----------------------------------------------------------------------
1117
1118    #[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    // -----------------------------------------------------------------------
1172    // Launcher path
1173    // -----------------------------------------------------------------------
1174
1175    #[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    // -----------------------------------------------------------------------
1195    // SHA-256 verification
1196    // -----------------------------------------------------------------------
1197
1198    #[test]
1199    fn test_sha256_bytes_known_value() {
1200        // SHA-256 of "hello\n" = known constant.
1201        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    // -----------------------------------------------------------------------
1245    // MOCKSERVER_SKIP_BINARY_DOWNLOAD behaviour
1246    // -----------------------------------------------------------------------
1247
1248    #[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        // Pre-seed the cache with a fake launcher.
1275        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    // -----------------------------------------------------------------------
1290    // Semver parsing for pruning (H7)
1291    // -----------------------------------------------------------------------
1292
1293    #[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    // -----------------------------------------------------------------------
1317    // Pre-release comparison (semver ordering)
1318    // -----------------------------------------------------------------------
1319
1320    #[test]
1321    fn test_compare_pre_release_release_is_greater_than_snapshot() {
1322        // A release (empty pre-release) is NEWER than its -SNAPSHOT.
1323        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        // Among two pre-releases, compare lexicographically.
1350        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    // -----------------------------------------------------------------------
1365    // Cache pruning
1366    // -----------------------------------------------------------------------
1367
1368    #[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        // Create three "version" directories.
1374        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        // Also create a stale .part file.
1385        fs::write(base.join("something.part"), "temp").unwrap();
1386
1387        // Current version is 3.0.0.
1388        prune_old_versions(base, "3.0.0");
1389
1390        // 3.0.0 should exist (current).
1391        assert!(v3.exists(), "current version should be kept");
1392        // 2.0.0 should exist (most recent previous by semver, not mtime).
1393        assert!(v2.exists(), "one previous version should be kept");
1394        // 1.0.0 should be gone.
1395        assert!(!v1.exists(), "older versions should be pruned");
1396        // .part file should be gone.
1397        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        // H7: 10.0.0 > 9.0.0 > 2.0.0, not "10.0.0" < "2.0.0" < "9.0.0"
1406        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        // Current is 11.0.0 (not present as dir).
1414        prune_old_versions(base, "11.0.0");
1415
1416        // Newest previous by semver is 10.0.0 — it should be kept.
1417        assert!(
1418            base.join("10.0.0").exists(),
1419            "semver-newest previous (10.0.0) should be kept"
1420        );
1421        // 9.0.0 and 2.0.0 should be pruned.
1422        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(&current).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        // Just verify it does not panic.
1492        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        // Should not panic.
1499        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        // Create five old versions and one current.
1508        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(&current).unwrap();
1515
1516        prune_old_versions(base, "6.0.0");
1517
1518        assert!(current.exists(), "current must be kept");
1519        // Only the newest of the 5 old versions should remain (by semver: 5.0.0).
1520        assert!(
1521            base.join("5.0.0").exists(),
1522            "newest previous version should be kept"
1523        );
1524        // All others should be gone.
1525        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        // When current is something else, and both 7.0.0 and 7.0.0-SNAPSHOT
1534        // exist as previous versions, pruning should keep the release (7.0.0)
1535        // because it is newer than 7.0.0-SNAPSHOT by semver rules.
1536        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(&current).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        // Regression test: with only a release and its SNAPSHOT as old versions,
1564        // the release must be the one retained (maxPrevious=1).
1565        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    // -----------------------------------------------------------------------
1586    // Hermetic download+SHA256+extract tests via local HTTP server (H8)
1587    // -----------------------------------------------------------------------
1588
1589    /// Helper: spin up a tiny HTTP server that serves files from a directory.
1590    /// Returns (base_url, server_guard). The server shuts down on drop.
1591    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        /// Serve requests in a background thread. Returns a join handle.
1611        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                // Serve up to 10 requests then exit (enough for any test).
1615                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                    // Strip leading '/' to get the filename.
1625                    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    /// Build a minimal tar.gz fixture containing `<bundle_name>/bin/mockserver`.
1644    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        // Hermetic test: local HTTP server serves the fixture archive + sha256.
1680        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        // Serve a valid archive but a WRONG sha256 checksum.
1719        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        // Write a WRONG checksum.
1731        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        // .part file should have been cleaned up.
1752        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        // Serve a valid archive but an EMPTY sha256 checksum file.
1766        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        // Empty checksum file.
1778        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        // Serve an EMPTY directory so every asset 404s — simulates a release
1805        // tag that ships no bundle for this version.
1806        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    // -----------------------------------------------------------------------
1841    // Version constant
1842    // -----------------------------------------------------------------------
1843
1844    #[test]
1845    fn test_version_is_valid_and_non_empty() {
1846        // VERSION is derived from Cargo.toml at compile time via env!().
1847        // We verify it is non-empty and passes validation; the exact value
1848        // is guaranteed by the Cargo build system.
1849        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    // -----------------------------------------------------------------------
1857    // Integration test — skipped when no real bundle available
1858    // -----------------------------------------------------------------------
1859
1860    #[test]
1861    #[ignore] // requires a real released binary to be downloadable
1862    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                // If the release doesn't exist yet, that's expected.
1881                eprintln!("Live download failed (expected if no release): {e}");
1882            }
1883        }
1884    }
1885}