use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use sha2::{Digest, Sha256};
const REPO: &str = "mock-server/mockserver-monorepo";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
const HTTP_TIMEOUT_SECS: u64 = 600;
#[derive(Debug)]
pub enum LauncherError {
UnsupportedPlatform(String),
SkipDownload(String),
ChecksumMismatch {
expected: String,
actual: String,
},
ChecksumMissing(String),
Io(io::Error),
Http(String),
NotFound(String),
NoBundle(String),
ExtractionFailed(String),
LauncherMissing(String),
InvalidVersion(String),
}
impl std::fmt::Display for LauncherError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LauncherError::UnsupportedPlatform(msg) => {
write!(f, "unsupported platform: {msg}")
}
LauncherError::SkipDownload(msg) => write!(f, "{msg}"),
LauncherError::ChecksumMismatch { expected, actual } => {
write!(f, "checksum mismatch: expected {expected}, got {actual}")
}
LauncherError::ChecksumMissing(msg) => {
write!(f, "checksum file empty or unparseable: {msg}")
}
LauncherError::Io(e) => write!(f, "I/O error: {e}"),
LauncherError::Http(msg) => write!(f, "HTTP error: {msg}"),
LauncherError::NotFound(msg) => write!(f, "not found (HTTP 404): {msg}"),
LauncherError::NoBundle(msg) => write!(f, "{msg}"),
LauncherError::ExtractionFailed(msg) => {
write!(f, "extraction failed: {msg}")
}
LauncherError::LauncherMissing(msg) => {
write!(f, "launcher missing after extract: {msg}")
}
LauncherError::InvalidVersion(msg) => {
write!(f, "invalid version: {msg}")
}
}
}
}
impl std::error::Error for LauncherError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
LauncherError::Io(e) => Some(e),
_ => None,
}
}
}
impl From<io::Error> for LauncherError {
fn from(e: io::Error) -> Self {
LauncherError::Io(e)
}
}
pub type LauncherResult<T> = Result<T, LauncherError>;
fn validate_version(version: &str) -> LauncherResult<&str> {
if version.contains('/') || version.contains('\\') || version.contains("..") {
return Err(LauncherError::InvalidVersion(format!(
"version contains path separator or '..': {version}"
)));
}
let mut chars = version.chars().peekable();
fn digits(chars: &mut std::iter::Peekable<std::str::Chars>) -> bool {
let mut found = false;
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
chars.next();
found = true;
}
found
}
if !digits(&mut chars) {
return Err(LauncherError::InvalidVersion(version.to_string()));
}
if chars.next() != Some('.') {
return Err(LauncherError::InvalidVersion(version.to_string()));
}
if !digits(&mut chars) {
return Err(LauncherError::InvalidVersion(version.to_string()));
}
if chars.next() != Some('.') {
return Err(LauncherError::InvalidVersion(version.to_string()));
}
if !digits(&mut chars) {
return Err(LauncherError::InvalidVersion(version.to_string()));
}
if let Some(&c) = chars.peek() {
if c == '-' || c == '.' {
chars.next();
let mut found = false;
while chars
.peek()
.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '.')
{
chars.next();
found = true;
}
if !found {
return Err(LauncherError::InvalidVersion(version.to_string()));
}
}
}
if chars.next().is_some() {
return Err(LauncherError::InvalidVersion(version.to_string()));
}
Ok(version)
}
fn assert_within(base: &Path, child: &Path) -> LauncherResult<()> {
let resolved_base = fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf());
let resolved_child = if child.exists() {
fs::canonicalize(child).unwrap_or_else(|_| child.to_path_buf())
} else if let (Some(parent), Some(file_name)) = (child.parent(), child.file_name()) {
let resolved_parent =
fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
resolved_parent.join(file_name)
} else {
child.to_path_buf()
};
if !resolved_child.starts_with(&resolved_base) {
return Err(LauncherError::InvalidVersion(format!(
"resolved path {} escapes cache base {}",
resolved_child.display(),
resolved_base.display()
)));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Platform {
pub os_name: &'static str,
pub arch: &'static str,
pub ext: &'static str,
}
pub fn resolve_platform() -> LauncherResult<Platform> {
let os_name;
let ext;
if cfg!(target_os = "linux") {
os_name = "linux";
ext = "tar.gz";
} else if cfg!(target_os = "macos") {
os_name = "darwin";
ext = "tar.gz";
} else if cfg!(target_os = "windows") {
os_name = "windows";
ext = "zip";
} else {
return Err(LauncherError::UnsupportedPlatform(format!(
"unsupported OS: {}",
std::env::consts::OS
)));
}
let arch;
if cfg!(target_arch = "x86_64") {
arch = "x86_64";
} else if cfg!(target_arch = "aarch64") {
arch = "aarch64";
} else {
return Err(LauncherError::UnsupportedPlatform(format!(
"unsupported architecture: {}",
std::env::consts::ARCH
)));
}
Ok(Platform { os_name, arch, ext })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BundleName {
pub name: String,
pub ext: &'static str,
}
pub fn bundle_base_name(version: &str) -> LauncherResult<BundleName> {
let platform = resolve_platform()?;
Ok(BundleName {
name: format!(
"mockserver-{}-{}-{}",
version, platform.os_name, platform.arch
),
ext: platform.ext,
})
}
pub fn cache_dir() -> PathBuf {
if let Ok(dir) = std::env::var("MOCKSERVER_BINARY_CACHE") {
return PathBuf::from(dir);
}
let base = if cfg!(target_os = "windows") {
std::env::var("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("AppData")
.join("Local")
})
} else {
std::env::var("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".cache")
})
};
base.join("mockserver").join("binaries")
}
const SNAPSHOT_CDN: &str = "https://downloads.mock-server.com";
fn no_bundle_message(version: &str) -> String {
format!(
"no MockServer release bundle is published for version {version} \
(no downloadable asset at the GitHub release tag 'mockserver-{version}'). \
Use a MockServer version that ships self-contained bundles, \
or run MockServer via Docker (docker run mockserver/mockserver:mockserver-{version}), \
or use the Maven Central jar (org.mock-server:mockserver-netty:{version})."
)
}
pub fn is_snapshot(version: &str) -> bool {
version.to_ascii_uppercase().contains("-SNAPSHOT")
}
pub fn asset_url(version: &str, file: &str) -> String {
let base = std::env::var("MOCKSERVER_BINARY_BASE_URL").unwrap_or_else(|_| {
if is_snapshot(version) {
format!("{SNAPSHOT_CDN}/mockserver-{version}")
} else {
format!(
"https://github.com/{REPO}/releases/download/mockserver-{version}"
)
}
});
let base = base.trim_end_matches('/');
format!("{base}/{file}")
}
pub fn launcher_path(dir: &Path, bundle_name: &str) -> PathBuf {
let bin_name = if cfg!(target_os = "windows") {
"mockserver.bat"
} else {
"mockserver"
};
dir.join(bundle_name).join("bin").join(bin_name)
}
pub fn sha256_file(path: &Path) -> LauncherResult<String> {
let mut file = fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
pub fn sha256_bytes(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
format!("{:x}", hasher.finalize())
}
fn download(url: &str, dest: &Path) -> LauncherResult<()> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| LauncherError::Http(format!("failed to build HTTP client: {e}")))?;
let mut resp = client
.get(url)
.send()
.map_err(|e| LauncherError::Http(format!("GET {url} failed: {e}")))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(LauncherError::NotFound(format!("GET {url}")));
}
if !resp.status().is_success() {
return Err(LauncherError::Http(format!(
"GET {url} failed: HTTP {}",
resp.status()
)));
}
let mut file = fs::File::create(dest)?;
io::copy(&mut resp, &mut file)
.map_err(|e| LauncherError::Http(format!("streaming body to {}: {e}", dest.display())))?;
Ok(())
}
#[derive(Debug, Clone, Default)]
pub struct EnsureOptions {
pub verbose: bool,
}
pub fn ensure_binary(version: &str, opts: &EnsureOptions) -> LauncherResult<PathBuf> {
validate_version(version)?;
let meta = bundle_base_name(version)?;
let base = cache_dir();
let dir = base.join(version);
fs::create_dir_all(&base)?;
assert_within(&base, &dir)?;
let launcher = launcher_path(&dir, &meta.name);
if launcher.exists() {
let size = fs::metadata(&launcher).map(|m| m.len()).unwrap_or(0);
if size > 0 {
if opts.verbose {
eprintln!("Using cached binary: {}", launcher.display());
}
return Ok(launcher);
}
}
if std::env::var("MOCKSERVER_SKIP_BINARY_DOWNLOAD").is_ok() {
return Err(LauncherError::SkipDownload(format!(
"MOCKSERVER_SKIP_BINARY_DOWNLOAD is set but no cached binary at {}",
launcher.display()
)));
}
fs::create_dir_all(&dir)?;
let archive_file = format!("{}.{}", meta.name, meta.ext);
let archive = dir.join(&archive_file);
let partial = PathBuf::from(format!("{}.part", archive.display()));
let sha_file = PathBuf::from(format!("{}.sha256", archive.display()));
let url = asset_url(version, &archive_file);
if opts.verbose {
eprintln!("Downloading {url}");
}
let result = (|| -> LauncherResult<()> {
match download(&url, &partial) {
Ok(()) => {}
Err(LauncherError::NotFound(_)) => {
return Err(LauncherError::NoBundle(no_bundle_message(version)));
}
Err(e) => return Err(e),
}
let sha_url = asset_url(version, &format!("{archive_file}.sha256"));
download(&sha_url, &sha_file)?;
let sha_content = fs::read_to_string(&sha_file)?;
let expected = sha_content
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
if expected.is_empty() {
return Err(LauncherError::ChecksumMissing(format!(
"checksum file for {} is empty or unparseable",
meta.name
)));
}
let actual = sha256_file(&partial)?;
if expected != actual {
return Err(LauncherError::ChecksumMismatch { expected, actual });
}
if opts.verbose {
eprintln!("Checksum verified");
}
fs::rename(&partial, &archive)?;
Ok(())
})();
if let Err(e) = result {
let _ = fs::remove_file(&partial);
let _ = fs::remove_file(&sha_file);
return Err(e);
}
if opts.verbose {
eprintln!("Extracting {}", archive.display());
}
let tar_status = Command::new("tar")
.args([
"-xf",
&archive.to_string_lossy(),
"-C",
&dir.to_string_lossy(),
])
.status();
match tar_status {
Ok(status) if status.success() => {}
Ok(status) => {
return Err(LauncherError::ExtractionFailed(format!(
"tar exit code: {}",
status.code().unwrap_or(-1)
)));
}
Err(e) => {
return Err(LauncherError::ExtractionFailed(format!(
"could not run tar: {e}"
)));
}
}
if let Ok(entries) = fs::read_dir(&dir) {
for entry in entries.flatten() {
let p = entry.path();
let resolved = fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
let resolved_dir = fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
if !resolved.starts_with(&resolved_dir) {
return Err(LauncherError::ExtractionFailed(format!(
"extracted file escapes version directory: {}",
resolved.display()
)));
}
}
}
if !launcher.exists()
|| fs::metadata(&launcher).map(|m| m.len()).unwrap_or(0) == 0
{
return Err(LauncherError::LauncherMissing(format!(
"launcher missing or empty after extract: {}",
launcher.display()
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&launcher, fs::Permissions::from_mode(0o755))?;
}
prune_old_versions(&base, version);
Ok(launcher)
}
fn parse_semver_segments(s: &str) -> Option<(u64, u64, u64, String)> {
let mut parts = s.splitn(2, |c: char| c == '-' || (!c.is_ascii_digit() && c != '.'));
let version_part = parts.next()?;
let segments: Vec<&str> = version_part.split('.').collect();
if segments.len() < 3 {
return None;
}
let major = segments[0].parse::<u64>().ok()?;
let minor = segments[1].parse::<u64>().ok()?;
let patch = segments[2].parse::<u64>().ok()?;
let rest = s[version_part.len()..].to_string();
Some((major, minor, patch, rest))
}
fn compare_pre_release(a: &str, b: &str) -> std::cmp::Ordering {
match (a.is_empty(), b.is_empty()) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => a.cmp(b),
}
}
pub fn prune_old_versions(cache_base: &Path, current_version: &str) {
let entries = match fs::read_dir(cache_base) {
Ok(e) => e,
Err(_) => return, };
let mut version_dirs: Vec<(PathBuf, (u64, u64, u64, String))> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if ext == "part" || ext == "sha256" {
let _ = fs::remove_file(&path);
continue;
}
}
if !path.is_dir() {
continue;
}
let dir_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if dir_name == current_version {
clean_part_files(&path);
continue;
}
if path.parent() != Some(cache_base) {
continue;
}
if let Some(semver) = parse_semver_segments(&dir_name) {
version_dirs.push((path, semver));
}
}
version_dirs.sort_by(|a, b| {
let (a_maj, a_min, a_pat, ref a_rest) = a.1;
let (b_maj, b_min, b_pat, ref b_rest) = b.1;
(b_maj, b_min, b_pat)
.cmp(&(a_maj, a_min, a_pat))
.then_with(|| compare_pre_release(b_rest, a_rest))
});
for (path, _) in version_dirs.iter().skip(1) {
let _ = fs::remove_dir_all(path);
}
}
fn clean_part_files(dir: &Path) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "part") {
let _ = fs::remove_file(&path);
}
}
}
}
pub struct ServerHandle {
child: Child,
port: u16,
}
impl ServerHandle {
pub fn port(&self) -> u16 {
self.port
}
pub fn stop(&mut self) -> LauncherResult<()> {
self.child.kill().map_err(LauncherError::Io)?;
let _ = self.child.wait();
Ok(())
}
pub fn wait(&mut self) -> LauncherResult<std::process::ExitStatus> {
self.child.wait().map_err(LauncherError::Io)
}
}
impl Drop for ServerHandle {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
pub fn ensure_launcher() -> LauncherResult<PathBuf> {
ensure_binary(VERSION, &EnsureOptions::default())
}
pub fn start(port: u16) -> LauncherResult<ServerHandle> {
start_with_version(VERSION, port, &EnsureOptions::default())
}
pub fn start_with_version(
version: &str,
port: u16,
opts: &EnsureOptions,
) -> LauncherResult<ServerHandle> {
let launcher = ensure_binary(version, opts)?;
#[cfg(target_os = "windows")]
let mut cmd = {
use std::os::windows::process::CommandExt;
let mut c = Command::new("cmd");
c.args(["/c", &launcher.to_string_lossy(), "-serverPort", &port.to_string()]);
c.creation_flags(0x08000000);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = Command::new(&launcher);
c.arg("-serverPort").arg(port.to_string());
c
};
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let child = cmd.spawn().map_err(LauncherError::Io)?;
Ok(ServerHandle { child, port })
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
struct TempDir(PathBuf);
impl TempDir {
fn new(prefix: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"mockserver-test-{}-{}",
prefix,
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
struct EnvGuard {
key: String,
original: Option<String>,
}
impl EnvGuard {
fn new(key: &str, value: Option<&str>) -> Self {
let original = std::env::var(key).ok();
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
Self {
key: key.to_string(),
original,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.original {
Some(v) => std::env::set_var(&self.key, v),
None => std::env::remove_var(&self.key),
}
}
}
#[test]
fn test_validate_version_valid() {
assert!(validate_version("7.0.0").is_ok());
assert!(validate_version("6.1.0").is_ok());
assert!(validate_version("12.345.678").is_ok());
assert!(validate_version("1.2.3-beta.1").is_ok());
assert!(validate_version("1.2.3-rc1").is_ok());
assert!(validate_version("1.2.3.SNAPSHOT").is_ok());
}
#[test]
fn test_validate_version_rejects_path_separators() {
assert!(validate_version("../../../etc/passwd").is_err());
assert!(validate_version("7.0.0/../../bad").is_err());
assert!(validate_version("7.0.0\\..\\bad").is_err());
}
#[test]
fn test_validate_version_rejects_invalid_format() {
assert!(validate_version("").is_err());
assert!(validate_version("abc").is_err());
assert!(validate_version("7.0").is_err());
assert!(validate_version("7").is_err());
assert!(validate_version("7.0.0-").is_err()); }
#[test]
fn test_resolve_platform_returns_valid_triple() {
let p = resolve_platform().unwrap();
assert!(
["linux", "darwin", "windows"].contains(&p.os_name),
"unexpected OS: {}",
p.os_name
);
assert!(
["x86_64", "aarch64"].contains(&p.arch),
"unexpected arch: {}",
p.arch
);
if p.os_name == "windows" {
assert_eq!(p.ext, "zip");
} else {
assert_eq!(p.ext, "tar.gz");
}
}
#[cfg(target_os = "macos")]
#[test]
fn test_resolve_platform_macos() {
let p = resolve_platform().unwrap();
assert_eq!(p.os_name, "darwin");
assert_eq!(p.ext, "tar.gz");
}
#[cfg(target_os = "linux")]
#[test]
fn test_resolve_platform_linux() {
let p = resolve_platform().unwrap();
assert_eq!(p.os_name, "linux");
assert_eq!(p.ext, "tar.gz");
}
#[cfg(target_arch = "aarch64")]
#[test]
fn test_resolve_platform_aarch64() {
let p = resolve_platform().unwrap();
assert_eq!(p.arch, "aarch64");
}
#[cfg(target_arch = "x86_64")]
#[test]
fn test_resolve_platform_x86_64() {
let p = resolve_platform().unwrap();
assert_eq!(p.arch, "x86_64");
}
#[test]
fn test_bundle_base_name() {
let b = bundle_base_name("7.0.0").unwrap();
let platform = resolve_platform().unwrap();
assert_eq!(
b.name,
format!("mockserver-7.0.0-{}-{}", platform.os_name, platform.arch)
);
assert_eq!(b.ext, platform.ext);
}
#[test]
fn test_bundle_base_name_custom_version() {
let b = bundle_base_name("6.1.0").unwrap();
assert!(b.name.starts_with("mockserver-6.1.0-"));
}
#[test]
fn test_cache_dir_default_has_mockserver_binaries() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::new("MOCKSERVER_BINARY_CACHE", None);
let dir = cache_dir();
let path_str = dir.to_string_lossy();
assert!(
path_str.ends_with("mockserver/binaries")
|| path_str.ends_with("mockserver\\binaries"),
"cache_dir should end with mockserver/binaries, got: {path_str}"
);
}
#[test]
fn test_cache_dir_respects_env_override() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some("/custom/path"));
let dir = cache_dir();
assert_eq!(dir, PathBuf::from("/custom/path"));
}
#[cfg(not(target_os = "windows"))]
#[test]
fn test_cache_dir_respects_xdg_cache_home() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard1 = EnvGuard::new("MOCKSERVER_BINARY_CACHE", None);
let _guard2 = EnvGuard::new("XDG_CACHE_HOME", Some("/tmp/xdg-test-cache"));
let dir = cache_dir();
assert_eq!(
dir,
PathBuf::from("/tmp/xdg-test-cache/mockserver/binaries")
);
}
#[test]
fn test_asset_url_default() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
let url = asset_url("7.0.0", "mockserver-7.0.0-darwin-aarch64.tar.gz");
assert_eq!(
url,
"https://github.com/mock-server/mockserver-monorepo/releases/download/mockserver-7.0.0/mockserver-7.0.0-darwin-aarch64.tar.gz"
);
}
#[test]
fn test_asset_url_custom_base() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::new(
"MOCKSERVER_BINARY_BASE_URL",
Some("https://mirror.internal/releases/"),
);
let url = asset_url("7.0.0", "mockserver-7.0.0-linux-x86_64.tar.gz");
assert_eq!(
url,
"https://mirror.internal/releases/mockserver-7.0.0-linux-x86_64.tar.gz"
);
}
#[test]
fn test_asset_url_strips_trailing_slashes() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::new(
"MOCKSERVER_BINARY_BASE_URL",
Some("https://mirror.internal///"),
);
let url = asset_url("7.0.0", "file.tar.gz");
assert_eq!(url, "https://mirror.internal/file.tar.gz");
}
#[test]
fn test_is_snapshot() {
assert!(is_snapshot("7.0.0-SNAPSHOT"));
assert!(is_snapshot("7.0.0-snapshot"));
assert!(is_snapshot("7.0.0-Snapshot"));
assert!(is_snapshot("7.1.0-SNAPSHOT"));
assert!(!is_snapshot("7.0.0"));
assert!(!is_snapshot("7.0.0-beta.1"));
assert!(!is_snapshot("7.0.0-rc.1"));
}
#[test]
fn test_asset_url_snapshot_uses_cdn() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
let url = asset_url(
"7.1.0-SNAPSHOT",
"mockserver-7.1.0-SNAPSHOT-darwin-aarch64.tar.gz",
);
assert_eq!(
url,
"https://downloads.mock-server.com/mockserver-7.1.0-SNAPSHOT/mockserver-7.1.0-SNAPSHOT-darwin-aarch64.tar.gz"
);
}
#[test]
fn test_asset_url_release_uses_github() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
let url = asset_url("7.0.0", "mockserver-7.0.0-darwin-aarch64.tar.gz");
assert_eq!(
url,
"https://github.com/mock-server/mockserver-monorepo/releases/download/mockserver-7.0.0/mockserver-7.0.0-darwin-aarch64.tar.gz"
);
}
#[test]
fn test_asset_url_env_override_wins_over_snapshot() {
let _lock = ENV_MUTEX.lock().unwrap();
let _guard = EnvGuard::new(
"MOCKSERVER_BINARY_BASE_URL",
Some("https://custom-mirror.example.com/bins"),
);
let url = asset_url(
"7.1.0-SNAPSHOT",
"mockserver-7.1.0-SNAPSHOT-linux-x86_64.tar.gz",
);
assert_eq!(
url,
"https://custom-mirror.example.com/bins/mockserver-7.1.0-SNAPSHOT-linux-x86_64.tar.gz"
);
}
#[test]
fn test_launcher_path_unix() {
let dir = PathBuf::from("/cache/7.0.0");
let path = launcher_path(&dir, "mockserver-7.0.0-darwin-aarch64");
if cfg!(target_os = "windows") {
assert!(
path.ends_with("bin/mockserver.bat")
|| path.ends_with("bin\\mockserver.bat")
);
} else {
assert_eq!(
path,
PathBuf::from(
"/cache/7.0.0/mockserver-7.0.0-darwin-aarch64/bin/mockserver"
)
);
}
}
#[test]
fn test_sha256_bytes_known_value() {
let digest = sha256_bytes(b"hello\n");
assert_eq!(
digest,
"5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
);
}
#[test]
fn test_sha256_file_matches_sha256_bytes() {
let tmp = TempDir::new("sha256");
let file = tmp.path().join("test-data.bin");
let data = b"MockServer binary launcher test content";
fs::write(&file, data).unwrap();
let file_digest = sha256_file(&file).unwrap();
let mem_digest = sha256_bytes(data);
assert_eq!(file_digest, mem_digest);
}
#[test]
fn test_sha256_verification_correct_checksum() {
let tmp = TempDir::new("sha-ok");
let archive_data = b"fake-archive-content-12345";
let archive_path = tmp.path().join("archive.tar.gz");
fs::write(&archive_path, archive_data).unwrap();
let expected = sha256_bytes(archive_data);
let actual = sha256_file(&archive_path).unwrap();
assert_eq!(expected, actual, "checksum should match");
}
#[test]
fn test_sha256_verification_wrong_checksum() {
let tmp = TempDir::new("sha-bad");
let archive_data = b"correct content";
let archive_path = tmp.path().join("archive.tar.gz");
fs::write(&archive_path, archive_data).unwrap();
let wrong = sha256_bytes(b"DIFFERENT content");
let actual = sha256_file(&archive_path).unwrap();
assert_ne!(wrong, actual, "checksums should differ");
}
#[test]
fn test_skip_download_env_fails_when_no_cached_binary() {
let _lock = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new("skip-dl");
let _guard_cache =
EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", Some("1"));
let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
let result = ensure_binary("99.99.99", &EnsureOptions::default());
assert!(result.is_err());
let err = result.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("MOCKSERVER_SKIP_BINARY_DOWNLOAD"),
"error should mention env var: {msg}"
);
}
#[test]
fn test_skip_download_returns_cached_binary() {
let _lock = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new("skip-ok");
let version = "99.99.99";
let meta = bundle_base_name(version).unwrap();
let dir = tmp.path().join(version);
let launcher = launcher_path(&dir, &meta.name);
fs::create_dir_all(launcher.parent().unwrap()).unwrap();
fs::write(&launcher, "#!/bin/sh\necho mock").unwrap();
let _guard_cache =
EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", Some("1"));
let result = ensure_binary(version, &EnsureOptions::default());
assert!(result.is_ok());
assert_eq!(result.unwrap(), launcher);
}
#[test]
fn test_parse_semver_segments_valid() {
assert_eq!(
parse_semver_segments("7.0.0"),
Some((7, 0, 0, String::new()))
);
assert_eq!(
parse_semver_segments("12.345.678"),
Some((12, 345, 678, String::new()))
);
assert_eq!(
parse_semver_segments("1.2.3-beta.1"),
Some((1, 2, 3, "-beta.1".to_string()))
);
}
#[test]
fn test_parse_semver_segments_invalid() {
assert!(parse_semver_segments("abc").is_none());
assert!(parse_semver_segments("7.0").is_none());
assert!(parse_semver_segments("").is_none());
}
#[test]
fn test_compare_pre_release_release_is_greater_than_snapshot() {
assert_eq!(
compare_pre_release("", "-SNAPSHOT"),
std::cmp::Ordering::Greater,
"release (empty) should sort above -SNAPSHOT"
);
}
#[test]
fn test_compare_pre_release_snapshot_is_less_than_release() {
assert_eq!(
compare_pre_release("-SNAPSHOT", ""),
std::cmp::Ordering::Less,
"-SNAPSHOT should sort below release (empty)"
);
}
#[test]
fn test_compare_pre_release_both_empty() {
assert_eq!(
compare_pre_release("", ""),
std::cmp::Ordering::Equal,
);
}
#[test]
fn test_compare_pre_release_both_pre_releases() {
assert_eq!(
compare_pre_release("-alpha", "-beta"),
std::cmp::Ordering::Less,
);
assert_eq!(
compare_pre_release("-beta", "-alpha"),
std::cmp::Ordering::Greater,
);
assert_eq!(
compare_pre_release("-rc1", "-rc1"),
std::cmp::Ordering::Equal,
);
}
#[test]
fn test_prune_removes_old_versions_keeps_current_and_one_previous() {
let tmp = TempDir::new("prune");
let base = tmp.path();
let v1 = base.join("1.0.0");
let v2 = base.join("2.0.0");
let v3 = base.join("3.0.0");
fs::create_dir_all(&v1).unwrap();
fs::write(v1.join("marker"), "v1").unwrap();
fs::create_dir_all(&v2).unwrap();
fs::write(v2.join("marker"), "v2").unwrap();
fs::create_dir_all(&v3).unwrap();
fs::write(v3.join("marker"), "v3").unwrap();
fs::write(base.join("something.part"), "temp").unwrap();
prune_old_versions(base, "3.0.0");
assert!(v3.exists(), "current version should be kept");
assert!(v2.exists(), "one previous version should be kept");
assert!(!v1.exists(), "older versions should be pruned");
assert!(
!base.join("something.part").exists(),
".part files should be cleaned"
);
}
#[test]
fn test_prune_semver_aware_not_lexicographic() {
let tmp = TempDir::new("prune-semver");
let base = tmp.path();
fs::create_dir_all(base.join("2.0.0")).unwrap();
fs::create_dir_all(base.join("9.0.0")).unwrap();
fs::create_dir_all(base.join("10.0.0")).unwrap();
prune_old_versions(base, "11.0.0");
assert!(
base.join("10.0.0").exists(),
"semver-newest previous (10.0.0) should be kept"
);
assert!(
!base.join("9.0.0").exists(),
"9.0.0 should be pruned"
);
assert!(
!base.join("2.0.0").exists(),
"2.0.0 should be pruned"
);
}
#[test]
fn test_prune_with_single_old_version_keeps_it() {
let tmp = TempDir::new("prune-single");
let base = tmp.path();
let v1 = base.join("1.0.0");
let v2 = base.join("2.0.0");
fs::create_dir_all(&v1).unwrap();
fs::write(v1.join("marker"), "v1").unwrap();
fs::create_dir_all(&v2).unwrap();
fs::write(v2.join("marker"), "v2").unwrap();
prune_old_versions(base, "2.0.0");
assert!(v2.exists(), "current version should be kept");
assert!(v1.exists(), "single previous version should be kept");
}
#[test]
fn test_prune_cleans_part_files_in_current_version_dir() {
let tmp = TempDir::new("prune-part");
let base = tmp.path();
let current = base.join("5.0.0");
fs::create_dir_all(¤t).unwrap();
fs::write(current.join("archive.tar.gz.part"), "partial").unwrap();
fs::write(current.join("real-file.txt"), "keep").unwrap();
prune_old_versions(base, "5.0.0");
assert!(
!current.join("archive.tar.gz.part").exists(),
".part inside current should be cleaned"
);
assert!(
current.join("real-file.txt").exists(),
"non-.part files should be kept"
);
}
#[test]
fn test_prune_cleans_sha256_temp_files() {
let tmp = TempDir::new("prune-sha");
let base = tmp.path();
fs::create_dir_all(base.join("5.0.0")).unwrap();
fs::write(base.join("archive.tar.gz.sha256"), "temp").unwrap();
prune_old_versions(base, "5.0.0");
assert!(
!base.join("archive.tar.gz.sha256").exists(),
".sha256 files at cache root should be cleaned"
);
}
#[test]
fn test_prune_empty_cache_is_safe() {
let tmp = TempDir::new("prune-empty");
prune_old_versions(tmp.path(), "1.0.0");
}
#[test]
fn test_prune_nonexistent_cache_dir_is_safe() {
let nonexistent = PathBuf::from("/tmp/mockserver-test-nonexistent-dir-12345");
prune_old_versions(&nonexistent, "1.0.0");
}
#[test]
fn test_prune_with_many_old_versions() {
let tmp = TempDir::new("prune-many");
let base = tmp.path();
for i in 1..=5 {
let v = base.join(format!("{i}.0.0"));
fs::create_dir_all(&v).unwrap();
fs::write(v.join("marker"), format!("v{i}")).unwrap();
}
let current = base.join("6.0.0");
fs::create_dir_all(¤t).unwrap();
prune_old_versions(base, "6.0.0");
assert!(current.exists(), "current must be kept");
assert!(
base.join("5.0.0").exists(),
"newest previous version should be kept"
);
for i in 1..=4 {
let v = base.join(format!("{i}.0.0"));
assert!(!v.exists(), "version {i}.0.0 should be pruned");
}
}
#[test]
fn test_prune_keeps_release_over_snapshot() {
let tmp = TempDir::new("prune-rel-snap");
let base = tmp.path();
let release = base.join("7.0.0");
let snapshot = base.join("7.0.0-SNAPSHOT");
let current = base.join("8.0.0");
fs::create_dir_all(&release).unwrap();
fs::write(release.join("marker"), "release").unwrap();
fs::create_dir_all(&snapshot).unwrap();
fs::write(snapshot.join("marker"), "snapshot").unwrap();
fs::create_dir_all(¤t).unwrap();
prune_old_versions(base, "8.0.0");
assert!(current.exists(), "current version should be kept");
assert!(
release.exists(),
"release 7.0.0 should be kept (newer than SNAPSHOT)"
);
assert!(
!snapshot.exists(),
"7.0.0-SNAPSHOT should be pruned (older than release)"
);
}
#[test]
fn test_prune_release_never_deleted_in_favour_of_snapshot() {
let tmp = TempDir::new("prune-no-snap-win");
let base = tmp.path();
let release = base.join("6.1.0");
let snapshot = base.join("6.1.0-SNAPSHOT");
fs::create_dir_all(&release).unwrap();
fs::create_dir_all(&snapshot).unwrap();
prune_old_versions(base, "7.0.0");
assert!(
release.exists(),
"release 6.1.0 must be kept, not its SNAPSHOT"
);
assert!(
!snapshot.exists(),
"6.1.0-SNAPSHOT must be pruned when release exists"
);
}
struct TestServer {
base_url: String,
server: tiny_http::Server,
serve_dir: PathBuf,
}
impl TestServer {
fn start(serve_dir: PathBuf) -> Self {
let server =
tiny_http::Server::http("127.0.0.1:0").expect("failed to start test HTTP server");
let addr = server.server_addr().to_ip().unwrap();
let base_url = format!("http://127.0.0.1:{}", addr.port());
TestServer {
base_url,
server,
serve_dir,
}
}
fn serve_in_background(self) -> (String, std::thread::JoinHandle<()>) {
let base_url = self.base_url.clone();
let handle = std::thread::spawn(move || {
for _ in 0..10 {
let request = match self.server.recv_timeout(
std::time::Duration::from_secs(10),
) {
Ok(Some(r)) => r,
_ => break,
};
let url_path = request.url().to_string();
let file_name = url_path.trim_start_matches('/');
let file_path = self.serve_dir.join(file_name);
if file_path.exists() {
let data = fs::read(&file_path).unwrap();
let response = tiny_http::Response::from_data(data);
let _ = request.respond(response);
} else {
let response = tiny_http::Response::from_string("Not Found")
.with_status_code(tiny_http::StatusCode(404));
let _ = request.respond(response);
}
}
});
(base_url, handle)
}
}
fn build_fixture_archive(
work_dir: &Path,
bundle_name: &str,
) -> (PathBuf, String) {
let archive_root = work_dir.join("build");
let bin_dir = archive_root.join(bundle_name).join("bin");
fs::create_dir_all(&bin_dir).unwrap();
fs::write(bin_dir.join("mockserver"), "#!/bin/sh\necho mock").unwrap();
let ext = if cfg!(target_os = "windows") {
"zip"
} else {
"tar.gz"
};
let archive_file = format!("{bundle_name}.{ext}");
let archive_path = work_dir.join(&archive_file);
let status = Command::new("tar")
.args([
"-czf",
&archive_path.to_string_lossy(),
"-C",
&archive_root.to_string_lossy(),
bundle_name,
])
.status()
.unwrap();
assert!(status.success(), "tar create should succeed");
let checksum = sha256_file(&archive_path).unwrap();
(archive_path, checksum)
}
#[test]
fn test_ensure_binary_download_and_verify_sha256() {
let _lock = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new("ensure-http-ok");
let version = "99.0.0";
let meta = bundle_base_name(version).unwrap();
let archive_file = format!("{}.{}", meta.name, meta.ext);
let serve_dir = tmp.path().join("serve");
fs::create_dir_all(&serve_dir).unwrap();
let (archive_path, checksum) = build_fixture_archive(tmp.path(), &meta.name);
fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
fs::write(
serve_dir.join(format!("{archive_file}.sha256")),
format!("{checksum} {archive_file}\n"),
)
.unwrap();
let server = TestServer::start(serve_dir);
let (base_url, _handle) = server.serve_in_background();
let cache = tmp.path().join("cache");
let _guard_cache =
EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
let result = ensure_binary(version, &EnsureOptions { verbose: true });
assert!(result.is_ok(), "ensure_binary should succeed: {:?}", result);
let launcher = result.unwrap();
assert!(launcher.exists(), "launcher should exist after download+extract");
assert!(
fs::metadata(&launcher).unwrap().len() > 0,
"launcher should be non-empty"
);
}
#[test]
fn test_ensure_binary_sha256_mismatch_fails_and_cleans_up() {
let _lock = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new("ensure-http-bad-sha");
let version = "99.0.1";
let meta = bundle_base_name(version).unwrap();
let archive_file = format!("{}.{}", meta.name, meta.ext);
let serve_dir = tmp.path().join("serve");
fs::create_dir_all(&serve_dir).unwrap();
let (archive_path, _checksum) = build_fixture_archive(tmp.path(), &meta.name);
fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
fs::write(
serve_dir.join(format!("{archive_file}.sha256")),
"0000000000000000000000000000000000000000000000000000000000000000 file\n",
)
.unwrap();
let server = TestServer::start(serve_dir);
let (base_url, _handle) = server.serve_in_background();
let cache = tmp.path().join("cache");
let _guard_cache =
EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
let result = ensure_binary(version, &EnsureOptions::default());
assert!(result.is_err(), "should fail on checksum mismatch");
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains("checksum mismatch"), "error message: {msg}");
let ver_dir = cache.join(version);
let part_file = PathBuf::from(format!(
"{}.part",
ver_dir.join(&archive_file).display()
));
assert!(
!part_file.exists(),
".part file should be cleaned up on failure"
);
}
#[test]
fn test_ensure_binary_empty_sha256_fails() {
let _lock = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new("ensure-http-empty-sha");
let version = "99.0.2";
let meta = bundle_base_name(version).unwrap();
let archive_file = format!("{}.{}", meta.name, meta.ext);
let serve_dir = tmp.path().join("serve");
fs::create_dir_all(&serve_dir).unwrap();
let (archive_path, _checksum) = build_fixture_archive(tmp.path(), &meta.name);
fs::copy(&archive_path, serve_dir.join(&archive_file)).unwrap();
fs::write(
serve_dir.join(format!("{archive_file}.sha256")),
"",
)
.unwrap();
let server = TestServer::start(serve_dir);
let (base_url, _handle) = server.serve_in_background();
let cache = tmp.path().join("cache");
let _guard_cache =
EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
let result = ensure_binary(version, &EnsureOptions::default());
assert!(result.is_err(), "should fail on empty checksum");
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("empty or unparseable"),
"error message: {msg}"
);
}
#[test]
fn test_ensure_binary_missing_bundle_404_gives_actionable_error() {
let _lock = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new("ensure-http-404");
let version = "99.9.9";
let serve_dir = tmp.path().join("serve");
fs::create_dir_all(&serve_dir).unwrap();
let server = TestServer::start(serve_dir);
let (base_url, _handle) = server.serve_in_background();
let cache = tmp.path().join("cache");
let _guard_cache =
EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(cache.to_str().unwrap()));
let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", Some(&base_url));
let result = ensure_binary(version, &EnsureOptions::default());
assert!(result.is_err(), "should fail when no bundle exists");
let msg = format!("{}", result.unwrap_err());
assert!(msg.contains(version), "should name the version: {msg}");
assert!(
msg.contains("no MockServer release bundle"),
"should explain no bundle: {msg}"
);
assert!(
msg.contains("docker run mockserver/mockserver:mockserver-99.9.9"),
"should suggest Docker: {msg}"
);
assert!(
msg.contains("org.mock-server:mockserver-netty:99.9.9"),
"should suggest Maven Central jar: {msg}"
);
}
#[test]
fn test_version_is_valid_and_non_empty() {
assert!(!VERSION.is_empty(), "VERSION should be non-empty");
assert!(
validate_version(VERSION).is_ok(),
"VERSION should be a valid semver: {VERSION}"
);
}
#[test]
#[ignore] fn test_ensure_binary_live_download() {
let _lock = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new("live-dl");
let _guard =
EnvGuard::new("MOCKSERVER_BINARY_CACHE", Some(tmp.path().to_str().unwrap()));
let _guard_skip = EnvGuard::new("MOCKSERVER_SKIP_BINARY_DOWNLOAD", None);
let _guard_url = EnvGuard::new("MOCKSERVER_BINARY_BASE_URL", None);
let result = ensure_binary(
VERSION,
&EnsureOptions { verbose: true },
);
match result {
Ok(path) => {
assert!(path.exists());
assert!(fs::metadata(&path).unwrap().len() > 0);
}
Err(e) => {
eprintln!("Live download failed (expected if no release): {e}");
}
}
}
}