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 extraction command (`tar`) failed.
63    ExtractionFailed(String),
64    /// The launcher binary was missing or empty after extraction.
65    LauncherMissing(String),
66    /// The version string is invalid or contains path traversal.
67    InvalidVersion(String),
68}
69
70impl std::fmt::Display for LauncherError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            LauncherError::UnsupportedPlatform(msg) => {
74                write!(f, "unsupported platform: {msg}")
75            }
76            LauncherError::SkipDownload(msg) => write!(f, "{msg}"),
77            LauncherError::ChecksumMismatch { expected, actual } => {
78                write!(f, "checksum mismatch: expected {expected}, got {actual}")
79            }
80            LauncherError::ChecksumMissing(msg) => {
81                write!(f, "checksum file empty or unparseable: {msg}")
82            }
83            LauncherError::Io(e) => write!(f, "I/O error: {e}"),
84            LauncherError::Http(msg) => write!(f, "HTTP error: {msg}"),
85            LauncherError::ExtractionFailed(msg) => {
86                write!(f, "extraction failed: {msg}")
87            }
88            LauncherError::LauncherMissing(msg) => {
89                write!(f, "launcher missing after extract: {msg}")
90            }
91            LauncherError::InvalidVersion(msg) => {
92                write!(f, "invalid version: {msg}")
93            }
94        }
95    }
96}
97
98impl std::error::Error for LauncherError {
99    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
100        match self {
101            LauncherError::Io(e) => Some(e),
102            _ => None,
103        }
104    }
105}
106
107impl From<io::Error> for LauncherError {
108    fn from(e: io::Error) -> Self {
109        LauncherError::Io(e)
110    }
111}
112
113/// Result type alias for launcher operations.
114pub type LauncherResult<T> = Result<T, LauncherError>;
115
116// ---------------------------------------------------------------------------
117// Version validation (H1)
118// ---------------------------------------------------------------------------
119
120/// Validate a version string against the semver-like pattern and reject path
121/// separators or `..` sequences. Returns the version unchanged if valid.
122fn validate_version(version: &str) -> LauncherResult<&str> {
123    // Reject path separators and parent-directory traversal.
124    if version.contains('/') || version.contains('\\') || version.contains("..") {
125        return Err(LauncherError::InvalidVersion(format!(
126            "version contains path separator or '..': {version}"
127        )));
128    }
129    // Must match: digits.digits.digits optionally followed by [-.][alphanumeric/dot]+
130    // Pattern: ^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$
131    let mut chars = version.chars().peekable();
132
133    // Helper: consume one or more digits, return false if none.
134    fn digits(chars: &mut std::iter::Peekable<std::str::Chars>) -> bool {
135        let mut found = false;
136        while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
137            chars.next();
138            found = true;
139        }
140        found
141    }
142
143    if !digits(&mut chars) {
144        return Err(LauncherError::InvalidVersion(version.to_string()));
145    }
146    if chars.next() != Some('.') {
147        return Err(LauncherError::InvalidVersion(version.to_string()));
148    }
149    if !digits(&mut chars) {
150        return Err(LauncherError::InvalidVersion(version.to_string()));
151    }
152    if chars.next() != Some('.') {
153        return Err(LauncherError::InvalidVersion(version.to_string()));
154    }
155    if !digits(&mut chars) {
156        return Err(LauncherError::InvalidVersion(version.to_string()));
157    }
158
159    // Optional pre-release / build metadata: [-.][0-9A-Za-z.]+
160    if let Some(&c) = chars.peek() {
161        if c == '-' || c == '.' {
162            chars.next();
163            let mut found = false;
164            while chars
165                .peek()
166                .is_some_and(|c| c.is_ascii_alphanumeric() || *c == '.')
167            {
168                chars.next();
169                found = true;
170            }
171            if !found {
172                return Err(LauncherError::InvalidVersion(version.to_string()));
173            }
174        }
175    }
176
177    if chars.next().is_some() {
178        return Err(LauncherError::InvalidVersion(version.to_string()));
179    }
180
181    Ok(version)
182}
183
184/// Assert that `child` is a descendant of `base`. Both paths are
185/// canonicalized (resolving symlinks) before the check. If `child` does
186/// not yet exist, we canonicalize its parent (which must exist) and append
187/// the final component — this avoids false negatives on macOS where `/var`
188/// is a symlink to `/private/var`.
189fn assert_within(base: &Path, child: &Path) -> LauncherResult<()> {
190    let resolved_base = fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
191
192    let resolved_child = if child.exists() {
193        fs::canonicalize(child).unwrap_or_else(|_| child.to_path_buf())
194    } else if let (Some(parent), Some(file_name)) = (child.parent(), child.file_name()) {
195        // Parent should exist (it's the cache base we just created).
196        let resolved_parent =
197            fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
198        resolved_parent.join(file_name)
199    } else {
200        child.to_path_buf()
201    };
202
203    if !resolved_child.starts_with(&resolved_base) {
204        return Err(LauncherError::InvalidVersion(format!(
205            "resolved path {} escapes cache base {}",
206            resolved_child.display(),
207            resolved_base.display()
208        )));
209    }
210    Ok(())
211}
212
213// ---------------------------------------------------------------------------
214// Platform detection
215// ---------------------------------------------------------------------------
216
217/// Resolved platform triple for the bundle file name.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct Platform {
220    /// `linux`, `darwin`, or `windows`.
221    pub os_name: &'static str,
222    /// `x86_64` or `aarch64`.
223    pub arch: &'static str,
224    /// `tar.gz` or `zip`.
225    pub ext: &'static str,
226}
227
228/// Map the current platform and architecture to the bundle naming tokens.
229///
230/// Returns an error on unsupported OS/arch combinations.
231pub fn resolve_platform() -> LauncherResult<Platform> {
232    let os_name;
233    let ext;
234
235    if cfg!(target_os = "linux") {
236        os_name = "linux";
237        ext = "tar.gz";
238    } else if cfg!(target_os = "macos") {
239        os_name = "darwin";
240        ext = "tar.gz";
241    } else if cfg!(target_os = "windows") {
242        os_name = "windows";
243        ext = "zip";
244    } else {
245        return Err(LauncherError::UnsupportedPlatform(format!(
246            "unsupported OS: {}",
247            std::env::consts::OS
248        )));
249    }
250
251    let arch;
252    if cfg!(target_arch = "x86_64") {
253        arch = "x86_64";
254    } else if cfg!(target_arch = "aarch64") {
255        arch = "aarch64";
256    } else {
257        return Err(LauncherError::UnsupportedPlatform(format!(
258            "unsupported architecture: {}",
259            std::env::consts::ARCH
260        )));
261    }
262
263    Ok(Platform { os_name, arch, ext })
264}
265
266// ---------------------------------------------------------------------------
267// Bundle naming
268// ---------------------------------------------------------------------------
269
270/// The base name and extension of the bundle archive for the given version.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct BundleName {
273    /// e.g. `mockserver-7.0.0-darwin-aarch64`
274    pub name: String,
275    /// e.g. `tar.gz`
276    pub ext: &'static str,
277}
278
279/// Compute the bundle base name for a given version on the current platform.
280pub fn bundle_base_name(version: &str) -> LauncherResult<BundleName> {
281    let platform = resolve_platform()?;
282    Ok(BundleName {
283        name: format!(
284            "mockserver-{}-{}-{}",
285            version, platform.os_name, platform.arch
286        ),
287        ext: platform.ext,
288    })
289}
290
291// ---------------------------------------------------------------------------
292// Cache directory
293// ---------------------------------------------------------------------------
294
295/// Resolve the binary cache base directory.
296///
297/// Precedence:
298/// 1. `MOCKSERVER_BINARY_CACHE` env var
299/// 2. Windows: `%LOCALAPPDATA%` (fallback `~/AppData/Local`)
300/// 3. Unix: `$XDG_CACHE_HOME` or `~/.cache`
301///
302/// Then append `/mockserver/binaries`.
303pub fn cache_dir() -> PathBuf {
304    if let Ok(dir) = std::env::var("MOCKSERVER_BINARY_CACHE") {
305        return PathBuf::from(dir);
306    }
307
308    let base = if cfg!(target_os = "windows") {
309        std::env::var("LOCALAPPDATA")
310            .map(PathBuf::from)
311            .unwrap_or_else(|_| {
312                dirs::home_dir()
313                    .unwrap_or_else(|| PathBuf::from("."))
314                    .join("AppData")
315                    .join("Local")
316            })
317    } else {
318        std::env::var("XDG_CACHE_HOME")
319            .map(PathBuf::from)
320            .unwrap_or_else(|_| {
321                dirs::home_dir()
322                    .unwrap_or_else(|| PathBuf::from("."))
323                    .join(".cache")
324            })
325    };
326
327    base.join("mockserver").join("binaries")
328}
329
330// ---------------------------------------------------------------------------
331// Asset URL
332// ---------------------------------------------------------------------------
333
334/// The CDN base URL used for SNAPSHOT version downloads.
335const SNAPSHOT_CDN: &str = "https://downloads.mock-server.com";
336
337/// Returns `true` if the version string contains "-SNAPSHOT" (case-insensitive),
338/// indicating a pre-release snapshot build.
339pub fn is_snapshot(version: &str) -> bool {
340    version.to_ascii_uppercase().contains("-SNAPSHOT")
341}
342
343/// Compute the download URL for a release asset.
344///
345/// Uses `MOCKSERVER_BINARY_BASE_URL` if set; otherwise defaults to GitHub
346/// Releases for release versions and the downloads.mock-server.com CDN for
347/// SNAPSHOT versions.
348pub fn asset_url(version: &str, file: &str) -> String {
349    let base = std::env::var("MOCKSERVER_BINARY_BASE_URL").unwrap_or_else(|_| {
350        if is_snapshot(version) {
351            format!("{SNAPSHOT_CDN}/mockserver-{version}")
352        } else {
353            format!(
354                "https://github.com/{REPO}/releases/download/mockserver-{version}"
355            )
356        }
357    });
358    let base = base.trim_end_matches('/');
359    format!("{base}/{file}")
360}
361
362// ---------------------------------------------------------------------------
363// Launcher path
364// ---------------------------------------------------------------------------
365
366/// The expected path to the launcher binary inside the extracted bundle.
367pub fn launcher_path(dir: &Path, bundle_name: &str) -> PathBuf {
368    let bin_name = if cfg!(target_os = "windows") {
369        "mockserver.bat"
370    } else {
371        "mockserver"
372    };
373    dir.join(bundle_name).join("bin").join(bin_name)
374}
375
376// ---------------------------------------------------------------------------
377// SHA-256 helpers
378// ---------------------------------------------------------------------------
379
380/// Compute the SHA-256 hex digest of a file.
381pub fn sha256_file(path: &Path) -> LauncherResult<String> {
382    let mut file = fs::File::open(path)?;
383    let mut hasher = Sha256::new();
384    let mut buf = [0u8; 8192];
385    loop {
386        let n = file.read(&mut buf)?;
387        if n == 0 {
388            break;
389        }
390        hasher.update(&buf[..n]);
391    }
392    Ok(format!("{:x}", hasher.finalize()))
393}
394
395/// Compute the SHA-256 hex digest of a byte slice (used in tests).
396pub fn sha256_bytes(data: &[u8]) -> String {
397    let mut hasher = Sha256::new();
398    hasher.update(data);
399    format!("{:x}", hasher.finalize())
400}
401
402// ---------------------------------------------------------------------------
403// Download helper
404// ---------------------------------------------------------------------------
405
406/// Download a URL to a local file path, streaming the body to disk to avoid
407/// buffering large binaries in memory. Proxy env vars (`HTTPS_PROXY`,
408/// `HTTP_PROXY`) are honoured by the reqwest client.
409fn download(url: &str, dest: &Path) -> LauncherResult<()> {
410    let client = reqwest::blocking::Client::builder()
411        .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
412        .connect_timeout(std::time::Duration::from_secs(30))
413        .build()
414        .map_err(|e| LauncherError::Http(format!("failed to build HTTP client: {e}")))?;
415
416    let mut resp = client
417        .get(url)
418        .send()
419        .map_err(|e| LauncherError::Http(format!("GET {url} failed: {e}")))?;
420
421    if !resp.status().is_success() {
422        return Err(LauncherError::Http(format!(
423            "GET {url} failed: HTTP {}",
424            resp.status()
425        )));
426    }
427
428    // Stream body directly to file to avoid a large heap allocation (H6/SEC-09).
429    let mut file = fs::File::create(dest)?;
430    io::copy(&mut resp, &mut file)
431        .map_err(|e| LauncherError::Http(format!("streaming body to {}: {e}", dest.display())))?;
432    Ok(())
433}
434
435// ---------------------------------------------------------------------------
436// Ensure binary (core logic)
437// ---------------------------------------------------------------------------
438
439/// Options for [`ensure_binary`].
440#[derive(Debug, Clone, Default)]
441pub struct EnsureOptions {
442    /// Optional logger closure. If `false`, progress is silent.
443    pub verbose: bool,
444}
445
446/// Ensure the platform binary is present and return the launcher path,
447/// downloading + verifying + extracting + caching on first use.
448///
449/// SHA-256 verification is always performed when downloading — it cannot be
450/// skipped by external callers.
451///
452/// After a successful install the cache is pruned: only the current version
453/// directory (and at most one previous) is kept; stale `.part` temp files are
454/// removed.
455pub fn ensure_binary(version: &str, opts: &EnsureOptions) -> LauncherResult<PathBuf> {
456    // H1: validate version string.
457    validate_version(version)?;
458
459    let meta = bundle_base_name(version)?;
460    let base = cache_dir();
461    let dir = base.join(version);
462
463    // H1: assert the version directory stays within the cache base.
464    // Create the base first so canonicalize can resolve it.
465    fs::create_dir_all(&base)?;
466    assert_within(&base, &dir)?;
467
468    let launcher = launcher_path(&dir, &meta.name);
469
470    // Reuse cached binary if it exists and is non-empty.
471    if launcher.exists() {
472        let size = fs::metadata(&launcher).map(|m| m.len()).unwrap_or(0);
473        if size > 0 {
474            if opts.verbose {
475                eprintln!("Using cached binary: {}", launcher.display());
476            }
477            return Ok(launcher);
478        }
479    }
480
481    // MOCKSERVER_SKIP_BINARY_DOWNLOAD => fail instead of downloading.
482    if std::env::var("MOCKSERVER_SKIP_BINARY_DOWNLOAD").is_ok() {
483        return Err(LauncherError::SkipDownload(format!(
484            "MOCKSERVER_SKIP_BINARY_DOWNLOAD is set but no cached binary at {}",
485            launcher.display()
486        )));
487    }
488
489    fs::create_dir_all(&dir)?;
490
491    let archive_file = format!("{}.{}", meta.name, meta.ext);
492    let archive = dir.join(&archive_file);
493    let partial = PathBuf::from(format!("{}.part", archive.display()));
494    let sha_file = PathBuf::from(format!("{}.sha256", archive.display()));
495
496    // Download to a temp .part file; rename only after checksum passes.
497    let url = asset_url(version, &archive_file);
498    if opts.verbose {
499        eprintln!("Downloading {url}");
500    }
501
502    let result = (|| -> LauncherResult<()> {
503        download(&url, &partial)?;
504
505        // Verify SHA-256 — always fail-closed. (H2: not bypassable)
506        let sha_url = asset_url(version, &format!("{archive_file}.sha256"));
507        download(&sha_url, &sha_file)?;
508
509        let sha_content = fs::read_to_string(&sha_file)?;
510        let expected = sha_content
511            .split_whitespace()
512            .next()
513            .unwrap_or("")
514            .to_string();
515
516        if expected.is_empty() {
517            return Err(LauncherError::ChecksumMissing(format!(
518                "checksum file for {} is empty or unparseable",
519                meta.name
520            )));
521        }
522
523        let actual = sha256_file(&partial)?;
524        if expected != actual {
525            return Err(LauncherError::ChecksumMismatch { expected, actual });
526        }
527
528        if opts.verbose {
529            eprintln!("Checksum verified");
530        }
531
532        fs::rename(&partial, &archive)?;
533        Ok(())
534    })();
535
536    if let Err(e) = result {
537        // H3: best-effort cleanup of BOTH the .part and .sha256 temp files.
538        let _ = fs::remove_file(&partial);
539        let _ = fs::remove_file(&sha_file);
540        return Err(e);
541    }
542
543    // Extract with the system tar (handles both .tar.gz and .zip on
544    // macOS/Linux bsdtar and GNU tar).
545    if opts.verbose {
546        eprintln!("Extracting {}", archive.display());
547    }
548
549    let tar_status = Command::new("tar")
550        .args([
551            "-xf",
552            &archive.to_string_lossy(),
553            "-C",
554            &dir.to_string_lossy(),
555        ])
556        .status();
557
558    match tar_status {
559        Ok(status) if status.success() => {}
560        Ok(status) => {
561            return Err(LauncherError::ExtractionFailed(format!(
562                "tar exit code: {}",
563                status.code().unwrap_or(-1)
564            )));
565        }
566        Err(e) => {
567            return Err(LauncherError::ExtractionFailed(format!(
568                "could not run tar: {e}"
569            )));
570        }
571    }
572
573    // H3: verify no extracted file escaped verDir.
574    if let Ok(entries) = fs::read_dir(&dir) {
575        for entry in entries.flatten() {
576            let p = entry.path();
577            // Resolve symlinks to catch escapes.
578            let resolved = fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
579            let resolved_dir = fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
580            if !resolved.starts_with(&resolved_dir) {
581                return Err(LauncherError::ExtractionFailed(format!(
582                    "extracted file escapes version directory: {}",
583                    resolved.display()
584                )));
585            }
586        }
587    }
588
589    // Verify the launcher exists and is non-empty.
590    if !launcher.exists()
591        || fs::metadata(&launcher).map(|m| m.len()).unwrap_or(0) == 0
592    {
593        return Err(LauncherError::LauncherMissing(format!(
594            "launcher missing or empty after extract: {}",
595            launcher.display()
596        )));
597    }
598
599    // chmod 0755 on non-Windows.
600    #[cfg(unix)]
601    {
602        use std::os::unix::fs::PermissionsExt;
603        fs::set_permissions(&launcher, fs::Permissions::from_mode(0o755))?;
604    }
605
606    // Prune old version directories from the cache.
607    prune_old_versions(&base, version);
608
609    Ok(launcher)
610}
611
612// ---------------------------------------------------------------------------
613// Cache pruning
614// ---------------------------------------------------------------------------
615
616/// Parse a version string into numeric segments for semver-aware comparison
617/// (H7). Returns `None` if it does not start with three numeric dot-separated
618/// segments.
619fn parse_semver_segments(s: &str) -> Option<(u64, u64, u64, String)> {
620    let mut parts = s.splitn(2, |c: char| c == '-' || (!c.is_ascii_digit() && c != '.'));
621    let version_part = parts.next()?;
622    let segments: Vec<&str> = version_part.split('.').collect();
623    if segments.len() < 3 {
624        return None;
625    }
626    let major = segments[0].parse::<u64>().ok()?;
627    let minor = segments[1].parse::<u64>().ok()?;
628    let patch = segments[2].parse::<u64>().ok()?;
629    let rest = s[version_part.len()..].to_string();
630    Some((major, minor, patch, rest))
631}
632
633/// Compare two pre-release suffixes for semver ordering.
634///
635/// Per semver, a release (empty pre-release) is NEWER/GREATER than any
636/// pre-release of the same numeric version (e.g. `7.0.0 > 7.0.0-SNAPSHOT`).
637/// Among two pre-releases, compare their identifiers lexicographically.
638fn compare_pre_release(a: &str, b: &str) -> std::cmp::Ordering {
639    match (a.is_empty(), b.is_empty()) {
640        (true, true) => std::cmp::Ordering::Equal,
641        // a is a release, b is a pre-release => a is newer (greater)
642        (true, false) => std::cmp::Ordering::Greater,
643        // a is a pre-release, b is a release => b is newer (a is less)
644        (false, true) => std::cmp::Ordering::Less,
645        // both are pre-releases => compare lexicographically
646        (false, false) => a.cmp(b),
647    }
648}
649
650/// Remove old version directories from the cache base, keeping only the
651/// current version (and at most one previous). Also removes leftover `.part`
652/// and `.sha256` temp files. Safe: never deletes outside the cache dir;
653/// tolerates concurrent runs.
654pub fn prune_old_versions(cache_base: &Path, current_version: &str) {
655    let entries = match fs::read_dir(cache_base) {
656        Ok(e) => e,
657        Err(_) => return, // cache dir might not exist yet
658    };
659
660    // Collect version directories (entries that are directories and look like
661    // version strings — we just check they are dirs and not the current one).
662    let mut version_dirs: Vec<(PathBuf, (u64, u64, u64, String))> = Vec::new();
663
664    for entry in entries.flatten() {
665        let path = entry.path();
666
667        // Clean up stale .part and .sha256 temp files at the cache root level.
668        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
669            if ext == "part" || ext == "sha256" {
670                let _ = fs::remove_file(&path);
671                continue;
672            }
673        }
674
675        if !path.is_dir() {
676            continue;
677        }
678
679        let dir_name = match path.file_name().and_then(|n| n.to_str()) {
680            Some(n) => n.to_string(),
681            None => continue,
682        };
683
684        if dir_name == current_version {
685            // Clean up .part files inside the current version dir too.
686            clean_part_files(&path);
687            continue;
688        }
689
690        // Safety: only remove dirs that are children of cache_base.
691        if path.parent() != Some(cache_base) {
692            continue;
693        }
694
695        // H7: parse semver for comparison; skip non-version directories.
696        if let Some(semver) = parse_semver_segments(&dir_name) {
697            version_dirs.push((path, semver));
698        }
699    }
700
701    // H7: sort by semver descending (newest first) using numeric comparison.
702    // When numeric cores are equal, a release (empty pre-release) is NEWER
703    // than a pre-release (e.g. 7.0.0 > 7.0.0-SNAPSHOT).
704    version_dirs.sort_by(|a, b| {
705        let (a_maj, a_min, a_pat, ref a_rest) = a.1;
706        let (b_maj, b_min, b_pat, ref b_rest) = b.1;
707        (b_maj, b_min, b_pat)
708            .cmp(&(a_maj, a_min, a_pat))
709            .then_with(|| compare_pre_release(b_rest, a_rest))
710    });
711
712    // Keep at most one previous version (the newest one).
713    for (path, _) in version_dirs.iter().skip(1) {
714        let _ = fs::remove_dir_all(path);
715    }
716}
717
718/// Remove `.part` temp files from a directory.
719fn clean_part_files(dir: &Path) {
720    if let Ok(entries) = fs::read_dir(dir) {
721        for entry in entries.flatten() {
722            let path = entry.path();
723            if path.extension().is_some_and(|e| e == "part") {
724                let _ = fs::remove_file(&path);
725            }
726        }
727    }
728}
729
730// ---------------------------------------------------------------------------
731// Server handle
732// ---------------------------------------------------------------------------
733
734/// A handle to a running MockServer process.
735///
736/// When dropped, the process is killed (SIGKILL on Unix, TerminateProcess on
737/// Windows).
738pub struct ServerHandle {
739    child: Child,
740    port: u16,
741}
742
743impl ServerHandle {
744    /// The port the server was started on.
745    pub fn port(&self) -> u16 {
746        self.port
747    }
748
749    /// Stop the server by killing the process.
750    pub fn stop(&mut self) -> LauncherResult<()> {
751        self.child.kill().map_err(LauncherError::Io)?;
752        let _ = self.child.wait();
753        Ok(())
754    }
755
756    /// Wait for the child process to exit, returning its exit status.
757    pub fn wait(&mut self) -> LauncherResult<std::process::ExitStatus> {
758        self.child.wait().map_err(LauncherError::Io)
759    }
760}
761
762impl Drop for ServerHandle {
763    fn drop(&mut self) {
764        let _ = self.child.kill();
765        let _ = self.child.wait();
766    }
767}
768
769// ---------------------------------------------------------------------------
770// Public API: start / ensure_launcher
771// ---------------------------------------------------------------------------
772
773/// Ensure the launcher binary is available and return its path.
774///
775/// This is a convenience wrapper around [`ensure_binary`] that uses the
776/// crate's compiled-in version.
777pub fn ensure_launcher() -> LauncherResult<PathBuf> {
778    ensure_binary(VERSION, &EnsureOptions::default())
779}
780
781/// Download (if needed) and start a MockServer on the given port.
782///
783/// Returns a [`ServerHandle`] that can be used to stop the server. The
784/// process inherits stdout/stderr by default so logs are visible.
785pub fn start(port: u16) -> LauncherResult<ServerHandle> {
786    start_with_version(VERSION, port, &EnsureOptions::default())
787}
788
789/// Start a MockServer of the given version on the given port.
790pub fn start_with_version(
791    version: &str,
792    port: u16,
793    opts: &EnsureOptions,
794) -> LauncherResult<ServerHandle> {
795    let launcher = ensure_binary(version, opts)?;
796
797    // On Windows, .bat files cannot be spawned directly by
798    // std::process::Command — they must be invoked via `cmd.exe /c`.
799    // On non-Windows, spawn the launcher directly.
800    #[cfg(target_os = "windows")]
801    let mut cmd = {
802        use std::os::windows::process::CommandExt;
803        let mut c = Command::new("cmd");
804        c.args(["/c", &launcher.to_string_lossy(), "-serverPort", &port.to_string()]);
805        // CREATE_NO_WINDOW suppresses the console window for background use.
806        c.creation_flags(0x08000000);
807        c
808    };
809
810    #[cfg(not(target_os = "windows"))]
811    let mut cmd = {
812        let mut c = Command::new(&launcher);
813        c.arg("-serverPort").arg(port.to_string());
814        c
815    };
816
817    cmd.stdout(Stdio::inherit());
818    cmd.stderr(Stdio::inherit());
819
820    let child = cmd.spawn().map_err(LauncherError::Io)?;
821    Ok(ServerHandle { child, port })
822}
823
824// ---------------------------------------------------------------------------
825// Tests
826// ---------------------------------------------------------------------------
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use std::fs;
832    use std::path::PathBuf;
833    use std::sync::Mutex;
834
835    // Global mutex to serialize tests that mutate environment variables.
836    // Rust's test harness runs tests in parallel within the same process,
837    // and env vars are process-global, so we must serialize access.
838    static ENV_MUTEX: Mutex<()> = Mutex::new(());
839
840    // Helper: create a temp directory that is cleaned up when dropped.
841    struct TempDir(PathBuf);
842
843    impl TempDir {
844        fn new(prefix: &str) -> Self {
845            let dir = std::env::temp_dir().join(format!(
846                "mockserver-test-{}-{}",
847                prefix,
848                std::process::id()
849            ));
850            let _ = fs::remove_dir_all(&dir);
851            fs::create_dir_all(&dir).unwrap();
852            Self(dir)
853        }
854
855        fn path(&self) -> &Path {
856            &self.0
857        }
858    }
859
860    impl Drop for TempDir {
861        fn drop(&mut self) {
862            let _ = fs::remove_dir_all(&self.0);
863        }
864    }
865
866    // -----------------------------------------------------------------------
867    // EnvGuard — temporarily set or unset an env var, restore on drop.
868    // -----------------------------------------------------------------------
869
870    struct EnvGuard {
871        key: String,
872        original: Option<String>,
873    }
874
875    impl EnvGuard {
876        fn new(key: &str, value: Option<&str>) -> Self {
877            let original = std::env::var(key).ok();
878            match value {
879                Some(v) => std::env::set_var(key, v),
880                None => std::env::remove_var(key),
881            }
882            Self {
883                key: key.to_string(),
884                original,
885            }
886        }
887    }
888
889    impl Drop for EnvGuard {
890        fn drop(&mut self) {
891            match &self.original {
892                Some(v) => std::env::set_var(&self.key, v),
893                None => std::env::remove_var(&self.key),
894            }
895        }
896    }
897
898    // -----------------------------------------------------------------------
899    // Version validation (H1)
900    // -----------------------------------------------------------------------
901
902    #[test]
903    fn test_validate_version_valid() {
904        assert!(validate_version("7.0.0").is_ok());
905        assert!(validate_version("6.1.0").is_ok());
906        assert!(validate_version("12.345.678").is_ok());
907        assert!(validate_version("1.2.3-beta.1").is_ok());
908        assert!(validate_version("1.2.3-rc1").is_ok());
909        assert!(validate_version("1.2.3.SNAPSHOT").is_ok());
910    }
911
912    #[test]
913    fn test_validate_version_rejects_path_separators() {
914        assert!(validate_version("../../../etc/passwd").is_err());
915        assert!(validate_version("7.0.0/../../bad").is_err());
916        assert!(validate_version("7.0.0\\..\\bad").is_err());
917    }
918
919    #[test]
920    fn test_validate_version_rejects_invalid_format() {
921        assert!(validate_version("").is_err());
922        assert!(validate_version("abc").is_err());
923        assert!(validate_version("7.0").is_err());
924        assert!(validate_version("7").is_err());
925        assert!(validate_version("7.0.0-").is_err()); // trailing dash with nothing after
926    }
927
928    // -----------------------------------------------------------------------
929    // Platform / arch detection
930    // -----------------------------------------------------------------------
931
932    #[test]
933    fn test_resolve_platform_returns_valid_triple() {
934        let p = resolve_platform().unwrap();
935        assert!(
936            ["linux", "darwin", "windows"].contains(&p.os_name),
937            "unexpected OS: {}",
938            p.os_name
939        );
940        assert!(
941            ["x86_64", "aarch64"].contains(&p.arch),
942            "unexpected arch: {}",
943            p.arch
944        );
945        if p.os_name == "windows" {
946            assert_eq!(p.ext, "zip");
947        } else {
948            assert_eq!(p.ext, "tar.gz");
949        }
950    }
951
952    #[cfg(target_os = "macos")]
953    #[test]
954    fn test_resolve_platform_macos() {
955        let p = resolve_platform().unwrap();
956        assert_eq!(p.os_name, "darwin");
957        assert_eq!(p.ext, "tar.gz");
958    }
959
960    #[cfg(target_os = "linux")]
961    #[test]
962    fn test_resolve_platform_linux() {
963        let p = resolve_platform().unwrap();
964        assert_eq!(p.os_name, "linux");
965        assert_eq!(p.ext, "tar.gz");
966    }
967
968    #[cfg(target_arch = "aarch64")]
969    #[test]
970    fn test_resolve_platform_aarch64() {
971        let p = resolve_platform().unwrap();
972        assert_eq!(p.arch, "aarch64");
973    }
974
975    #[cfg(target_arch = "x86_64")]
976    #[test]
977    fn test_resolve_platform_x86_64() {
978        let p = resolve_platform().unwrap();
979        assert_eq!(p.arch, "x86_64");
980    }
981
982    // -----------------------------------------------------------------------
983    // Bundle naming
984    // -----------------------------------------------------------------------
985
986    #[test]
987    fn test_bundle_base_name() {
988        let b = bundle_base_name("7.0.0").unwrap();
989        let platform = resolve_platform().unwrap();
990        assert_eq!(
991            b.name,
992            format!("mockserver-7.0.0-{}-{}", platform.os_name, platform.arch)
993        );
994        assert_eq!(b.ext, platform.ext);
995    }
996
997    #[test]
998    fn test_bundle_base_name_custom_version() {
999        let b = bundle_base_name("6.1.0").unwrap();
1000        assert!(b.name.starts_with("mockserver-6.1.0-"));
1001    }
1002
1003    // -----------------------------------------------------------------------
1004    // Cache path resolution
1005    // -----------------------------------------------------------------------
1006
1007    #[test]
1008    fn test_cache_dir_default_has_mockserver_binaries() {
1009        let _lock = ENV_MUTEX.lock().unwrap();
1010        let _guard = EnvGuard::new("MOCKSERVER_BINARY_CACHE", None);
1011        let dir = cache_dir();
1012        let path_str = dir.to_string_lossy();
1013        assert!(
1014            path_str.ends_with("mockserver/binaries")
1015                || path_str.ends_with("mockserver\\binaries"),
1016            "cache_dir should end with mockserver/binaries, got: {path_str}"
1017        );
1018    }
1019
1020    #[test]
1021    fn test_cache_dir_respects_env_override() {
1022        let _lock = ENV_MUTEX.lock().unwrap();
1023        let _guard = EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some("/custom/path"));
1024        let dir = cache_dir();
1025        assert_eq!(dir, PathBuf::from("/custom/path"));
1026    }
1027
1028    #[cfg(not(target_os = "windows"))]
1029    #[test]
1030    fn test_cache_dir_respects_xdg_cache_home() {
1031        let _lock = ENV_MUTEX.lock().unwrap();
1032        let _guard1 = EnvGuard::new("MOCKSERVER_BINARY_CACHE", None);
1033        let _guard2 = EnvGuard::new("XDG_CACHE_HOME", Some("/tmp/xdg-test-cache"));
1034        let dir = cache_dir();
1035        assert_eq!(
1036            dir,
1037            PathBuf::from("/tmp/xdg-test-cache/mockserver/binaries")
1038        );
1039    }
1040
1041    // -----------------------------------------------------------------------
1042    // Asset URL
1043    // -----------------------------------------------------------------------
1044
1045    #[test]
1046    fn test_asset_url_default() {
1047        let _lock = ENV_MUTEX.lock().unwrap();
1048        let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1049        let url = asset_url("7.0.0", "mockserver-7.0.0-darwin-aarch64.tar.gz");
1050        assert_eq!(
1051            url,
1052            "https://github.com/mock-server/mockserver-monorepo/releases/download/mockserver-7.0.0/mockserver-7.0.0-darwin-aarch64.tar.gz"
1053        );
1054    }
1055
1056    #[test]
1057    fn test_asset_url_custom_base() {
1058        let _lock = ENV_MUTEX.lock().unwrap();
1059        let _guard = EnvGuard::new(
1060            "MOCKSERVER_BINARY_BASE_URL",
1061            Some("https://mirror.internal/releases/"),
1062        );
1063        let url = asset_url("7.0.0", "mockserver-7.0.0-linux-x86_64.tar.gz");
1064        assert_eq!(
1065            url,
1066            "https://mirror.internal/releases/mockserver-7.0.0-linux-x86_64.tar.gz"
1067        );
1068    }
1069
1070    #[test]
1071    fn test_asset_url_strips_trailing_slashes() {
1072        let _lock = ENV_MUTEX.lock().unwrap();
1073        let _guard = EnvGuard::new(
1074            "MOCKSERVER_BINARY_BASE_URL",
1075            Some("https://mirror.internal///"),
1076        );
1077        let url = asset_url("7.0.0", "file.tar.gz");
1078        assert_eq!(url, "https://mirror.internal/file.tar.gz");
1079    }
1080
1081    // -----------------------------------------------------------------------
1082    // SNAPSHOT vs Release URL selection
1083    // -----------------------------------------------------------------------
1084
1085    #[test]
1086    fn test_is_snapshot() {
1087        assert!(is_snapshot("7.0.0-SNAPSHOT"));
1088        assert!(is_snapshot("7.0.0-snapshot"));
1089        assert!(is_snapshot("7.0.0-Snapshot"));
1090        assert!(is_snapshot("7.1.0-SNAPSHOT"));
1091        assert!(!is_snapshot("7.0.0"));
1092        assert!(!is_snapshot("7.0.0-beta.1"));
1093        assert!(!is_snapshot("7.0.0-rc.1"));
1094    }
1095
1096    #[test]
1097    fn test_asset_url_snapshot_uses_cdn() {
1098        let _lock = ENV_MUTEX.lock().unwrap();
1099        let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1100        let url = asset_url(
1101            "7.1.0-SNAPSHOT",
1102            "mockserver-7.1.0-SNAPSHOT-darwin-aarch64.tar.gz",
1103        );
1104        assert_eq!(
1105            url,
1106            "https://downloads.mock-server.com/mockserver-7.1.0-SNAPSHOT/mockserver-7.1.0-SNAPSHOT-darwin-aarch64.tar.gz"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_asset_url_release_uses_github() {
1112        let _lock = ENV_MUTEX.lock().unwrap();
1113        let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1114        let url = asset_url("7.0.0", "mockserver-7.0.0-darwin-aarch64.tar.gz");
1115        assert_eq!(
1116            url,
1117            "https://github.com/mock-server/mockserver-monorepo/releases/download/mockserver-7.0.0/mockserver-7.0.0-darwin-aarch64.tar.gz"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_asset_url_env_override_wins_over_snapshot() {
1123        let _lock = ENV_MUTEX.lock().unwrap();
1124        let _guard = EnvGuard::new(
1125            "MOCKSERVER_BINARY_BASE_URL",
1126            Some("https://custom-mirror.example.com/bins"),
1127        );
1128        let url = asset_url(
1129            "7.1.0-SNAPSHOT",
1130            "mockserver-7.1.0-SNAPSHOT-linux-x86_64.tar.gz",
1131        );
1132        assert_eq!(
1133            url,
1134            "https://custom-mirror.example.com/bins/mockserver-7.1.0-SNAPSHOT-linux-x86_64.tar.gz"
1135        );
1136    }
1137
1138    // -----------------------------------------------------------------------
1139    // Launcher path
1140    // -----------------------------------------------------------------------
1141
1142    #[test]
1143    fn test_launcher_path_unix() {
1144        let dir = PathBuf::from("/cache/7.0.0");
1145        let path = launcher_path(&dir, "mockserver-7.0.0-darwin-aarch64");
1146        if cfg!(target_os = "windows") {
1147            assert!(
1148                path.ends_with("bin/mockserver.bat")
1149                    || path.ends_with("bin\\mockserver.bat")
1150            );
1151        } else {
1152            assert_eq!(
1153                path,
1154                PathBuf::from(
1155                    "/cache/7.0.0/mockserver-7.0.0-darwin-aarch64/bin/mockserver"
1156                )
1157            );
1158        }
1159    }
1160
1161    // -----------------------------------------------------------------------
1162    // SHA-256 verification
1163    // -----------------------------------------------------------------------
1164
1165    #[test]
1166    fn test_sha256_bytes_known_value() {
1167        // SHA-256 of "hello\n" = known constant.
1168        let digest = sha256_bytes(b"hello\n");
1169        assert_eq!(
1170            digest,
1171            "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
1172        );
1173    }
1174
1175    #[test]
1176    fn test_sha256_file_matches_sha256_bytes() {
1177        let tmp = TempDir::new("sha256");
1178        let file = tmp.path().join("test-data.bin");
1179        let data = b"MockServer binary launcher test content";
1180        fs::write(&file, data).unwrap();
1181
1182        let file_digest = sha256_file(&file).unwrap();
1183        let mem_digest = sha256_bytes(data);
1184        assert_eq!(file_digest, mem_digest);
1185    }
1186
1187    #[test]
1188    fn test_sha256_verification_correct_checksum() {
1189        let tmp = TempDir::new("sha-ok");
1190        let archive_data = b"fake-archive-content-12345";
1191        let archive_path = tmp.path().join("archive.tar.gz");
1192        fs::write(&archive_path, archive_data).unwrap();
1193
1194        let expected = sha256_bytes(archive_data);
1195        let actual = sha256_file(&archive_path).unwrap();
1196        assert_eq!(expected, actual, "checksum should match");
1197    }
1198
1199    #[test]
1200    fn test_sha256_verification_wrong_checksum() {
1201        let tmp = TempDir::new("sha-bad");
1202        let archive_data = b"correct content";
1203        let archive_path = tmp.path().join("archive.tar.gz");
1204        fs::write(&archive_path, archive_data).unwrap();
1205
1206        let wrong = sha256_bytes(b"DIFFERENT content");
1207        let actual = sha256_file(&archive_path).unwrap();
1208        assert_ne!(wrong, actual, "checksums should differ");
1209    }
1210
1211    // -----------------------------------------------------------------------
1212    // MOCKSERVER_SKIP_BINARY_DOWNLOAD behaviour
1213    // -----------------------------------------------------------------------
1214
1215    #[test]
1216    fn test_skip_download_env_fails_when_no_cached_binary() {
1217        let _lock = ENV_MUTEX.lock().unwrap();
1218        let tmp = TempDir::new("skip-dl");
1219        let _guard_cache =
1220            EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
1221        let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", Some("1"));
1222        let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1223
1224        let result = ensure_binary("99.99.99", &EnsureOptions::default());
1225        assert!(result.is_err());
1226        let err = result.unwrap_err();
1227        let msg = format!("{err}");
1228        assert!(
1229            msg.contains("MOCKSERVER_SKIP_BINARY_DOWNLOAD"),
1230            "error should mention env var: {msg}"
1231        );
1232    }
1233
1234    #[test]
1235    fn test_skip_download_returns_cached_binary() {
1236        let _lock = ENV_MUTEX.lock().unwrap();
1237        let tmp = TempDir::new("skip-ok");
1238        let version = "99.99.99";
1239        let meta = bundle_base_name(version).unwrap();
1240
1241        // Pre-seed the cache with a fake launcher.
1242        let dir = tmp.path().join(version);
1243        let launcher = launcher_path(&dir, &meta.name);
1244        fs::create_dir_all(launcher.parent().unwrap()).unwrap();
1245        fs::write(&launcher, "#!/bin/sh\necho mock").unwrap();
1246
1247        let _guard_cache =
1248            EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
1249        let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", Some("1"));
1250
1251        let result = ensure_binary(version, &EnsureOptions::default());
1252        assert!(result.is_ok());
1253        assert_eq!(result.unwrap(), launcher);
1254    }
1255
1256    // -----------------------------------------------------------------------
1257    // Semver parsing for pruning (H7)
1258    // -----------------------------------------------------------------------
1259
1260    #[test]
1261    fn test_parse_semver_segments_valid() {
1262        assert_eq!(
1263            parse_semver_segments("7.0.0"),
1264            Some((7, 0, 0, String::new()))
1265        );
1266        assert_eq!(
1267            parse_semver_segments("12.345.678"),
1268            Some((12, 345, 678, String::new()))
1269        );
1270        assert_eq!(
1271            parse_semver_segments("1.2.3-beta.1"),
1272            Some((1, 2, 3, "-beta.1".to_string()))
1273        );
1274    }
1275
1276    #[test]
1277    fn test_parse_semver_segments_invalid() {
1278        assert!(parse_semver_segments("abc").is_none());
1279        assert!(parse_semver_segments("7.0").is_none());
1280        assert!(parse_semver_segments("").is_none());
1281    }
1282
1283    // -----------------------------------------------------------------------
1284    // Pre-release comparison (semver ordering)
1285    // -----------------------------------------------------------------------
1286
1287    #[test]
1288    fn test_compare_pre_release_release_is_greater_than_snapshot() {
1289        // A release (empty pre-release) is NEWER than its -SNAPSHOT.
1290        assert_eq!(
1291            compare_pre_release("", "-SNAPSHOT"),
1292            std::cmp::Ordering::Greater,
1293            "release (empty) should sort above -SNAPSHOT"
1294        );
1295    }
1296
1297    #[test]
1298    fn test_compare_pre_release_snapshot_is_less_than_release() {
1299        assert_eq!(
1300            compare_pre_release("-SNAPSHOT", ""),
1301            std::cmp::Ordering::Less,
1302            "-SNAPSHOT should sort below release (empty)"
1303        );
1304    }
1305
1306    #[test]
1307    fn test_compare_pre_release_both_empty() {
1308        assert_eq!(
1309            compare_pre_release("", ""),
1310            std::cmp::Ordering::Equal,
1311        );
1312    }
1313
1314    #[test]
1315    fn test_compare_pre_release_both_pre_releases() {
1316        // Among two pre-releases, compare lexicographically.
1317        assert_eq!(
1318            compare_pre_release("-alpha", "-beta"),
1319            std::cmp::Ordering::Less,
1320        );
1321        assert_eq!(
1322            compare_pre_release("-beta", "-alpha"),
1323            std::cmp::Ordering::Greater,
1324        );
1325        assert_eq!(
1326            compare_pre_release("-rc1", "-rc1"),
1327            std::cmp::Ordering::Equal,
1328        );
1329    }
1330
1331    // -----------------------------------------------------------------------
1332    // Cache pruning
1333    // -----------------------------------------------------------------------
1334
1335    #[test]
1336    fn test_prune_removes_old_versions_keeps_current_and_one_previous() {
1337        let tmp = TempDir::new("prune");
1338        let base = tmp.path();
1339
1340        // Create three "version" directories.
1341        let v1 = base.join("1.0.0");
1342        let v2 = base.join("2.0.0");
1343        let v3 = base.join("3.0.0");
1344        fs::create_dir_all(&v1).unwrap();
1345        fs::write(v1.join("marker"), "v1").unwrap();
1346        fs::create_dir_all(&v2).unwrap();
1347        fs::write(v2.join("marker"), "v2").unwrap();
1348        fs::create_dir_all(&v3).unwrap();
1349        fs::write(v3.join("marker"), "v3").unwrap();
1350
1351        // Also create a stale .part file.
1352        fs::write(base.join("something.part"), "temp").unwrap();
1353
1354        // Current version is 3.0.0.
1355        prune_old_versions(base, "3.0.0");
1356
1357        // 3.0.0 should exist (current).
1358        assert!(v3.exists(), "current version should be kept");
1359        // 2.0.0 should exist (most recent previous by semver, not mtime).
1360        assert!(v2.exists(), "one previous version should be kept");
1361        // 1.0.0 should be gone.
1362        assert!(!v1.exists(), "older versions should be pruned");
1363        // .part file should be gone.
1364        assert!(
1365            !base.join("something.part").exists(),
1366            ".part files should be cleaned"
1367        );
1368    }
1369
1370    #[test]
1371    fn test_prune_semver_aware_not_lexicographic() {
1372        // H7: 10.0.0 > 9.0.0 > 2.0.0, not "10.0.0" < "2.0.0" < "9.0.0"
1373        let tmp = TempDir::new("prune-semver");
1374        let base = tmp.path();
1375
1376        fs::create_dir_all(base.join("2.0.0")).unwrap();
1377        fs::create_dir_all(base.join("9.0.0")).unwrap();
1378        fs::create_dir_all(base.join("10.0.0")).unwrap();
1379
1380        // Current is 11.0.0 (not present as dir).
1381        prune_old_versions(base, "11.0.0");
1382
1383        // Newest previous by semver is 10.0.0 — it should be kept.
1384        assert!(
1385            base.join("10.0.0").exists(),
1386            "semver-newest previous (10.0.0) should be kept"
1387        );
1388        // 9.0.0 and 2.0.0 should be pruned.
1389        assert!(
1390            !base.join("9.0.0").exists(),
1391            "9.0.0 should be pruned"
1392        );
1393        assert!(
1394            !base.join("2.0.0").exists(),
1395            "2.0.0 should be pruned"
1396        );
1397    }
1398
1399    #[test]
1400    fn test_prune_with_single_old_version_keeps_it() {
1401        let tmp = TempDir::new("prune-single");
1402        let base = tmp.path();
1403
1404        let v1 = base.join("1.0.0");
1405        let v2 = base.join("2.0.0");
1406        fs::create_dir_all(&v1).unwrap();
1407        fs::write(v1.join("marker"), "v1").unwrap();
1408        fs::create_dir_all(&v2).unwrap();
1409        fs::write(v2.join("marker"), "v2").unwrap();
1410
1411        prune_old_versions(base, "2.0.0");
1412
1413        assert!(v2.exists(), "current version should be kept");
1414        assert!(v1.exists(), "single previous version should be kept");
1415    }
1416
1417    #[test]
1418    fn test_prune_cleans_part_files_in_current_version_dir() {
1419        let tmp = TempDir::new("prune-part");
1420        let base = tmp.path();
1421
1422        let current = base.join("5.0.0");
1423        fs::create_dir_all(&current).unwrap();
1424        fs::write(current.join("archive.tar.gz.part"), "partial").unwrap();
1425        fs::write(current.join("real-file.txt"), "keep").unwrap();
1426
1427        prune_old_versions(base, "5.0.0");
1428
1429        assert!(
1430            !current.join("archive.tar.gz.part").exists(),
1431            ".part inside current should be cleaned"
1432        );
1433        assert!(
1434            current.join("real-file.txt").exists(),
1435            "non-.part files should be kept"
1436        );
1437    }
1438
1439    #[test]
1440    fn test_prune_cleans_sha256_temp_files() {
1441        let tmp = TempDir::new("prune-sha");
1442        let base = tmp.path();
1443
1444        fs::create_dir_all(base.join("5.0.0")).unwrap();
1445        fs::write(base.join("archive.tar.gz.sha256"), "temp").unwrap();
1446
1447        prune_old_versions(base, "5.0.0");
1448
1449        assert!(
1450            !base.join("archive.tar.gz.sha256").exists(),
1451            ".sha256 files at cache root should be cleaned"
1452        );
1453    }
1454
1455    #[test]
1456    fn test_prune_empty_cache_is_safe() {
1457        let tmp = TempDir::new("prune-empty");
1458        // Just verify it does not panic.
1459        prune_old_versions(tmp.path(), "1.0.0");
1460    }
1461
1462    #[test]
1463    fn test_prune_nonexistent_cache_dir_is_safe() {
1464        let nonexistent = PathBuf::from("/tmp/mockserver-test-nonexistent-dir-12345");
1465        // Should not panic.
1466        prune_old_versions(&nonexistent, "1.0.0");
1467    }
1468
1469    #[test]
1470    fn test_prune_with_many_old_versions() {
1471        let tmp = TempDir::new("prune-many");
1472        let base = tmp.path();
1473
1474        // Create five old versions and one current.
1475        for i in 1..=5 {
1476            let v = base.join(format!("{i}.0.0"));
1477            fs::create_dir_all(&v).unwrap();
1478            fs::write(v.join("marker"), format!("v{i}")).unwrap();
1479        }
1480        let current = base.join("6.0.0");
1481        fs::create_dir_all(&current).unwrap();
1482
1483        prune_old_versions(base, "6.0.0");
1484
1485        assert!(current.exists(), "current must be kept");
1486        // Only the newest of the 5 old versions should remain (by semver: 5.0.0).
1487        assert!(
1488            base.join("5.0.0").exists(),
1489            "newest previous version should be kept"
1490        );
1491        // All others should be gone.
1492        for i in 1..=4 {
1493            let v = base.join(format!("{i}.0.0"));
1494            assert!(!v.exists(), "version {i}.0.0 should be pruned");
1495        }
1496    }
1497
1498    #[test]
1499    fn test_prune_keeps_release_over_snapshot() {
1500        // When current is something else, and both 7.0.0 and 7.0.0-SNAPSHOT
1501        // exist as previous versions, pruning should keep the release (7.0.0)
1502        // because it is newer than 7.0.0-SNAPSHOT by semver rules.
1503        let tmp = TempDir::new("prune-rel-snap");
1504        let base = tmp.path();
1505
1506        let release = base.join("7.0.0");
1507        let snapshot = base.join("7.0.0-SNAPSHOT");
1508        let current = base.join("8.0.0");
1509        fs::create_dir_all(&release).unwrap();
1510        fs::write(release.join("marker"), "release").unwrap();
1511        fs::create_dir_all(&snapshot).unwrap();
1512        fs::write(snapshot.join("marker"), "snapshot").unwrap();
1513        fs::create_dir_all(&current).unwrap();
1514
1515        prune_old_versions(base, "8.0.0");
1516
1517        assert!(current.exists(), "current version should be kept");
1518        assert!(
1519            release.exists(),
1520            "release 7.0.0 should be kept (newer than SNAPSHOT)"
1521        );
1522        assert!(
1523            !snapshot.exists(),
1524            "7.0.0-SNAPSHOT should be pruned (older than release)"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_prune_release_never_deleted_in_favour_of_snapshot() {
1530        // Regression test: with only a release and its SNAPSHOT as old versions,
1531        // the release must be the one retained (maxPrevious=1).
1532        let tmp = TempDir::new("prune-no-snap-win");
1533        let base = tmp.path();
1534
1535        let release = base.join("6.1.0");
1536        let snapshot = base.join("6.1.0-SNAPSHOT");
1537        fs::create_dir_all(&release).unwrap();
1538        fs::create_dir_all(&snapshot).unwrap();
1539
1540        prune_old_versions(base, "7.0.0");
1541
1542        assert!(
1543            release.exists(),
1544            "release 6.1.0 must be kept, not its SNAPSHOT"
1545        );
1546        assert!(
1547            !snapshot.exists(),
1548            "6.1.0-SNAPSHOT must be pruned when release exists"
1549        );
1550    }
1551
1552    // -----------------------------------------------------------------------
1553    // Hermetic download+SHA256+extract tests via local HTTP server (H8)
1554    // -----------------------------------------------------------------------
1555
1556    /// Helper: spin up a tiny HTTP server that serves files from a directory.
1557    /// Returns (base_url, server_guard). The server shuts down on drop.
1558    struct TestServer {
1559        base_url: String,
1560        server: tiny_http::Server,
1561        serve_dir: PathBuf,
1562    }
1563
1564    impl TestServer {
1565        fn start(serve_dir: PathBuf) -> Self {
1566            let server =
1567                tiny_http::Server::http("127.0.0.1:0").expect("failed to start test HTTP server");
1568            let addr = server.server_addr().to_ip().unwrap();
1569            let base_url = format!("http://127.0.0.1:{}", addr.port());
1570            TestServer {
1571                base_url,
1572                server,
1573                serve_dir,
1574            }
1575        }
1576
1577        /// Serve requests in a background thread. Returns a join handle.
1578        fn serve_in_background(self) -> (String, std::thread::JoinHandle<()>) {
1579            let base_url = self.base_url.clone();
1580            let handle = std::thread::spawn(move || {
1581                // Serve up to 10 requests then exit (enough for any test).
1582                for _ in 0..10 {
1583                    let request = match self.server.recv_timeout(
1584                        std::time::Duration::from_secs(10),
1585                    ) {
1586                        Ok(Some(r)) => r,
1587                        _ => break,
1588                    };
1589
1590                    let url_path = request.url().to_string();
1591                    // Strip leading '/' to get the filename.
1592                    let file_name = url_path.trim_start_matches('/');
1593                    let file_path = self.serve_dir.join(file_name);
1594
1595                    if file_path.exists() {
1596                        let data = fs::read(&file_path).unwrap();
1597                        let response = tiny_http::Response::from_data(data);
1598                        let _ = request.respond(response);
1599                    } else {
1600                        let response = tiny_http::Response::from_string("Not Found")
1601                            .with_status_code(tiny_http::StatusCode(404));
1602                        let _ = request.respond(response);
1603                    }
1604                }
1605            });
1606            (base_url, handle)
1607        }
1608    }
1609
1610    /// Build a minimal tar.gz fixture containing `<bundle_name>/bin/mockserver`.
1611    fn build_fixture_archive(
1612        work_dir: &Path,
1613        bundle_name: &str,
1614    ) -> (PathBuf, String) {
1615        let archive_root = work_dir.join("build");
1616        let bin_dir = archive_root.join(bundle_name).join("bin");
1617        fs::create_dir_all(&bin_dir).unwrap();
1618        fs::write(bin_dir.join("mockserver"), "#!/bin/sh\necho mock").unwrap();
1619
1620        let ext = if cfg!(target_os = "windows") {
1621            "zip"
1622        } else {
1623            "tar.gz"
1624        };
1625        let archive_file = format!("{bundle_name}.{ext}");
1626        let archive_path = work_dir.join(&archive_file);
1627
1628        let status = Command::new("tar")
1629            .args([
1630                "-czf",
1631                &archive_path.to_string_lossy(),
1632                "-C",
1633                &archive_root.to_string_lossy(),
1634                bundle_name,
1635            ])
1636            .status()
1637            .unwrap();
1638        assert!(status.success(), "tar create should succeed");
1639
1640        let checksum = sha256_file(&archive_path).unwrap();
1641        (archive_path, checksum)
1642    }
1643
1644    #[test]
1645    fn test_ensure_binary_download_and_verify_sha256() {
1646        // Hermetic test: local HTTP server serves the fixture archive + sha256.
1647        let _lock = ENV_MUTEX.lock().unwrap();
1648        let tmp = TempDir::new("ensure-http-ok");
1649        let version = "99.0.0";
1650        let meta = bundle_base_name(version).unwrap();
1651        let archive_file = format!("{}.{}", meta.name, meta.ext);
1652
1653        let serve_dir = tmp.path().join("serve");
1654        fs::create_dir_all(&serve_dir).unwrap();
1655
1656        let (archive_path, checksum) = build_fixture_archive(tmp.path(), &meta.name);
1657        fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
1658        fs::write(
1659            serve_dir.join(format!("{archive_file}.sha256")),
1660            format!("{checksum}  {archive_file}\n"),
1661        )
1662        .unwrap();
1663
1664        let server = TestServer::start(serve_dir);
1665        let (base_url, _handle) = server.serve_in_background();
1666
1667        let cache = tmp.path().join("cache");
1668        let _guard_cache =
1669            EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1670        let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1671        let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1672
1673        let result = ensure_binary(version, &EnsureOptions { verbose: true });
1674        assert!(result.is_ok(), "ensure_binary should succeed: {:?}", result);
1675        let launcher = result.unwrap();
1676        assert!(launcher.exists(), "launcher should exist after download+extract");
1677        assert!(
1678            fs::metadata(&launcher).unwrap().len() > 0,
1679            "launcher should be non-empty"
1680        );
1681    }
1682
1683    #[test]
1684    fn test_ensure_binary_sha256_mismatch_fails_and_cleans_up() {
1685        // Serve a valid archive but a WRONG sha256 checksum.
1686        let _lock = ENV_MUTEX.lock().unwrap();
1687        let tmp = TempDir::new("ensure-http-bad-sha");
1688        let version = "99.0.1";
1689        let meta = bundle_base_name(version).unwrap();
1690        let archive_file = format!("{}.{}", meta.name, meta.ext);
1691
1692        let serve_dir = tmp.path().join("serve");
1693        fs::create_dir_all(&serve_dir).unwrap();
1694
1695        let (archive_path, _checksum) = build_fixture_archive(tmp.path(), &meta.name);
1696        fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
1697        // Write a WRONG checksum.
1698        fs::write(
1699            serve_dir.join(format!("{archive_file}.sha256")),
1700            "0000000000000000000000000000000000000000000000000000000000000000  file\n",
1701        )
1702        .unwrap();
1703
1704        let server = TestServer::start(serve_dir);
1705        let (base_url, _handle) = server.serve_in_background();
1706
1707        let cache = tmp.path().join("cache");
1708        let _guard_cache =
1709            EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1710        let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1711        let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1712
1713        let result = ensure_binary(version, &EnsureOptions::default());
1714        assert!(result.is_err(), "should fail on checksum mismatch");
1715        let msg = format!("{}", result.unwrap_err());
1716        assert!(msg.contains("checksum mismatch"), "error message: {msg}");
1717
1718        // .part file should have been cleaned up.
1719        let ver_dir = cache.join(version);
1720        let part_file = PathBuf::from(format!(
1721            "{}.part",
1722            ver_dir.join(&archive_file).display()
1723        ));
1724        assert!(
1725            !part_file.exists(),
1726            ".part file should be cleaned up on failure"
1727        );
1728    }
1729
1730    #[test]
1731    fn test_ensure_binary_empty_sha256_fails() {
1732        // Serve a valid archive but an EMPTY sha256 checksum file.
1733        let _lock = ENV_MUTEX.lock().unwrap();
1734        let tmp = TempDir::new("ensure-http-empty-sha");
1735        let version = "99.0.2";
1736        let meta = bundle_base_name(version).unwrap();
1737        let archive_file = format!("{}.{}", meta.name, meta.ext);
1738
1739        let serve_dir = tmp.path().join("serve");
1740        fs::create_dir_all(&serve_dir).unwrap();
1741
1742        let (archive_path, _checksum) = build_fixture_archive(tmp.path(), &meta.name);
1743        fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
1744        // Empty checksum file.
1745        fs::write(
1746            serve_dir.join(format!("{archive_file}.sha256")),
1747            "",
1748        )
1749        .unwrap();
1750
1751        let server = TestServer::start(serve_dir);
1752        let (base_url, _handle) = server.serve_in_background();
1753
1754        let cache = tmp.path().join("cache");
1755        let _guard_cache =
1756            EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
1757        let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1758        let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
1759
1760        let result = ensure_binary(version, &EnsureOptions::default());
1761        assert!(result.is_err(), "should fail on empty checksum");
1762        let msg = format!("{}", result.unwrap_err());
1763        assert!(
1764            msg.contains("empty or unparseable"),
1765            "error message: {msg}"
1766        );
1767    }
1768
1769    // -----------------------------------------------------------------------
1770    // Version constant
1771    // -----------------------------------------------------------------------
1772
1773    #[test]
1774    fn test_version_is_valid_and_non_empty() {
1775        // VERSION is derived from Cargo.toml at compile time via env!().
1776        // We verify it is non-empty and passes validation; the exact value
1777        // is guaranteed by the Cargo build system.
1778        assert!(!VERSION.is_empty(), "VERSION should be non-empty");
1779        assert!(
1780            validate_version(VERSION).is_ok(),
1781            "VERSION should be a valid semver: {VERSION}"
1782        );
1783    }
1784
1785    // -----------------------------------------------------------------------
1786    // Integration test — skipped when no real bundle available
1787    // -----------------------------------------------------------------------
1788
1789    #[test]
1790    #[ignore] // requires a real released binary to be downloadable
1791    fn test_ensure_binary_live_download() {
1792        let _lock = ENV_MUTEX.lock().unwrap();
1793        let tmp = TempDir::new("live-dl");
1794        let _guard =
1795            EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
1796        let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
1797        let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
1798
1799        let result = ensure_binary(
1800            VERSION,
1801            &EnsureOptions { verbose: true },
1802        );
1803        match result {
1804            Ok(path) => {
1805                assert!(path.exists());
1806                assert!(fs::metadata(&path).unwrap().len() > 0);
1807            }
1808            Err(e) => {
1809                // If the release doesn't exist yet, that's expected.
1810                eprintln!("Live download failed (expected if no release): {e}");
1811            }
1812        }
1813    }
1814}