diff --git a/Cargo.lock b/Cargo.lock index aa95db97..e90629b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,13 +106,12 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ec5f6c2f8bc326c994cb9e241cc257ddaba9afa8555a43cffbb5dd86efaa37" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" dependencies = [ "compression-codecs", "compression-core", - "futures-core", "pin-project-lite", "tokio", ] @@ -151,9 +150,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.2" +version = "1.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +checksum = "e84ce723ab67259cfeb9877c6a639ee9eb7a27b28123abd71db7f0d5d0cc9d86" dependencies = [ "aws-lc-sys", "zeroize", @@ -161,9 +160,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +checksum = "43a442ece363113bd4bd4c8b18977a7798dd4d3c3383f34fb61936960e8f4ad8" dependencies = [ "cc", "cmake", @@ -243,9 +242,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" @@ -301,9 +300,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.51" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "jobserver", @@ -331,9 +330,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -379,9 +378,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "cmake" @@ -410,9 +409,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.35" +version = "0.4.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f7ac3e5b97fdce45e8922fb05cae2c37f7bbd63d30dd94821dacfd8f3f2bf2" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" dependencies = [ "compression-core", "flate2", @@ -839,15 +838,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -1024,9 +1023,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1426,9 +1425,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1518,9 +1517,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1588,9 +1587,9 @@ checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" [[package]] name = "libc" -version = "0.2.179" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libm" @@ -2008,7 +2007,7 @@ dependencies = [ [[package]] name = "oci-client" version = "0.15.0" -source = "git+https://github.com/toksdotdev/rust-oci-client.git?branch=toks%2Fstring-impl-for-enums#e165654aecf842b3dd57d8b31268378e8d1884c0" +source = "git+https://github.com/toksdotdev/rust-oci-client.git?branch=toks%2Fstring-impl-for-enums#0c0e352487c407a662de1b645bbc7ab2b87c1191" dependencies = [ "bytes", "chrono", @@ -2423,7 +2422,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2443,7 +2442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -2452,14 +2451,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2488,7 +2487,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 2.0.17", ] @@ -2649,7 +2648,7 @@ dependencies = [ "anyhow", "async-trait", "futures", - "getrandom 0.2.16", + "getrandom 0.2.17", "http", "hyper", "reqwest 0.12.28", @@ -2678,7 +2677,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2790,9 +2789,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -2827,9 +2826,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -3412,9 +3411,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.113" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -3687,9 +3686,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap", "serde_core", @@ -3738,9 +3737,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -4042,9 +4041,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] @@ -4057,9 +4056,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -4070,11 +4069,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -4083,9 +4083,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4093,9 +4093,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -4106,9 +4106,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -4142,9 +4142,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -4592,9 +4592,9 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" @@ -4637,18 +4637,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.32" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fabae64378cb18147bb18bca364e63bdbe72a0ffe4adf0addfec8aa166b2c56" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.32" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c2d862265a8bb4471d87e033e730f536e2a285cc7cb05dbce09a2a97075f90" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", @@ -4717,6 +4717,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" diff --git a/microsandbox-cli/bin/msbserver.rs b/microsandbox-cli/bin/msbserver.rs index 590559a3..f2d82489 100644 --- a/microsandbox-cli/bin/msbserver.rs +++ b/microsandbox-cli/bin/msbserver.rs @@ -43,9 +43,15 @@ pub async fn main() -> MicrosandboxCliResult<()> { // Get project directory from config let project_dir = config.get_project_dir().clone(); + let port_range = config.get_port_range().clone(); - // Initialize the port manager - let port_manager = PortManager::new(project_dir).await.map_err(|e| { + // Initialize the port manager with the configured port range + let port_manager = if let Some(range) = port_range { + PortManager::new_with_range(project_dir, Some(range)).await + } else { + PortManager::new(project_dir).await + } + .map_err(|e| { eprintln!("Error initializing port manager: {}", e); e })?; diff --git a/microsandbox-server/lib/config.rs b/microsandbox-server/lib/config.rs index cd5ed245..da106860 100644 --- a/microsandbox-server/lib/config.rs +++ b/microsandbox-server/lib/config.rs @@ -14,6 +14,7 @@ use std::{ net::{IpAddr, SocketAddr}, + ops::Range, path::PathBuf, }; @@ -56,6 +57,9 @@ pub struct Config { /// Address to listen on addr: SocketAddr, + + /// Port range for sandbox allocation (if set) + port_range: Option>, } //-------------------------------------------------------------------------------------------------- @@ -91,6 +95,8 @@ impl Config { let project_dir = project_dir.unwrap_or_else(|| env::get_microsandbox_home_path().join(PROJECTS_SUBDIR)); + // Load sandbox port range from environment variables + let port_range = env::get_sandbox_port_range(); Ok(Self { key, project_dir, @@ -98,6 +104,7 @@ impl Config { host: host_ip, port, addr, + port_range, }) } } diff --git a/microsandbox-server/lib/port.rs b/microsandbox-server/lib/port.rs index 6d21afa3..da266ac6 100644 --- a/microsandbox-server/lib/port.rs +++ b/microsandbox-server/lib/port.rs @@ -18,6 +18,7 @@ use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, + ops::Range, path::{Path, PathBuf}, }; use tokio::{fs, sync::Mutex}; @@ -64,6 +65,9 @@ pub struct PortManager { /// Path to the port mappings file file_path: PathBuf, + + /// Optional port range for sandbox port allocation (upper bound exclusive) + port_range: Option>, } //-------------------------------------------------------------------------------------------------- @@ -158,12 +162,30 @@ impl BiPortMapping { impl PortManager { /// Create a new port manager pub async fn new(project_dir: impl AsRef) -> MicrosandboxServerResult { + Self::new_with_range(project_dir, None).await + } + + /// Create a new port manager with an optional port range + pub async fn new_with_range( + project_dir: impl AsRef, + port_range: Option>, + ) -> MicrosandboxServerResult { let file_path = project_dir.as_ref().join(PORTAL_PORTS_FILE); let mappings = Self::load_mappings(&file_path).await?; + if let Some(range) = port_range.as_ref() { + info!( + "Port manager initialized with port range: {}..{} (upper exclusive)", + range.start, range.end + ); + } else { + debug!("Port manager initialized with dynamic port allocation"); + } + Ok(Self { mappings, file_path, + port_range, }) } @@ -240,8 +262,8 @@ impl PortManager { // Get a lock to ensure only one thread gets a port at a time let _lock = PORT_ASSIGNMENT_LOCK.lock().await; - // Get a truly available port from the OS - let port = self.get_available_port_from_os()?; + // Get an available port (from range or from OS) + let port = self.get_available_port()?; // Save the mapping self.mappings.insert(key.to_string(), port); @@ -272,7 +294,34 @@ impl PortManager { TcpListener::bind(addr).is_ok() } - /// Get an available port from the OS + /// Get an available port from the OS or from the configured port range + fn get_available_port(&self) -> MicrosandboxServerResult { + // If a port range is configured, try to find an available port within it + if let Some(range) = self.port_range.as_ref() { + debug!( + "Attempting to find an available port in range {}..{} (upper exclusive)", + range.start, range.end + ); + + for port in range.clone() { + if self.verify_port_availability(port) { + debug!("Found available port {} in configured range", port); + return Ok(port); + } + } + + // If no port is available in the range, log a warning and try dynamic allocation + warn!( + "No available ports found in configured range {}..{} (upper exclusive), falling back to OS allocation", + range.start, range.end + ); + } + + // Fall back to dynamic port allocation from the OS + self.get_available_port_from_os() + } + + /// Get a truly available port from the OS by binding to port 0 fn get_available_port_from_os(&self) -> MicrosandboxServerResult { // Bind to port 0 to let the OS assign an available port let addr = SocketAddr::new(LOCALHOST_IP, 0); diff --git a/microsandbox-utils/lib/env.rs b/microsandbox-utils/lib/env.rs index 1796f53d..fb01dc2f 100644 --- a/microsandbox-utils/lib/env.rs +++ b/microsandbox-utils/lib/env.rs @@ -1,6 +1,6 @@ //! Utility functions for working with environment variables. -use std::path::PathBuf; +use std::{ops::Range, path::PathBuf}; use crate::{DEFAULT_MICROSANDBOX_HOME, DEFAULT_OCI_REGISTRY}; @@ -20,6 +20,9 @@ pub const MSBRUN_EXE_ENV_VAR: &str = "MSBRUN_EXE"; /// Environment variable for the msbserver binary path pub const MSBSERVER_EXE_ENV_VAR: &str = "MSBSERVER_EXE"; +/// Environment variable for the sandbox port range (`..[=]`) +pub const MSB_PORT_RANGE_ENV_VAR: &str = "MSB_PORT_RANGE"; + //-------------------------------------------------------------------------------------------------- // Functions //-------------------------------------------------------------------------------------------------- @@ -45,3 +48,55 @@ pub fn get_oci_registry() -> String { DEFAULT_OCI_REGISTRY.to_string() } } + +/// Returns the port range for sandbox port allocation. +/// If MSB_PORT_RANGE is set and matches `..[=]`, +/// returns `Some(lower..upper)` (upper exclusive). Otherwise, returns None for dynamic allocation. +pub fn get_sandbox_port_range() -> Option> { + let range = std::env::var(MSB_PORT_RANGE_ENV_VAR).ok()?; + parse_sandbox_port_range(&range) +} + +fn parse_sandbox_port_range(range: &str) -> Option> { + let (lower_raw, upper_raw) = range.split_once("..")?; + let lower = lower_raw.parse::().ok()?; + let upper_part = upper_raw.strip_prefix('=').unwrap_or(upper_raw); + let upper = upper_part.parse::().ok()?; + + (lower < upper).then_some(lower..upper) +} + +#[cfg(test)] +mod tests { + use super::parse_sandbox_port_range; + + #[test] + fn test_parse_sandbox_port_range_with_exclusive_syntax() { + assert_eq!(parse_sandbox_port_range("3000..4000"), Some(3000..4000)); + } + + #[test] + fn test_parse_sandbox_port_range_with_inclusive_syntax() { + assert_eq!(parse_sandbox_port_range("3000..=4000"), Some(3000..4000)); + } + + #[test] + fn test_parse_sandbox_port_range_with_missing_lower_is_invalid() { + assert_eq!(parse_sandbox_port_range("..=4000"), None); + } + + #[test] + fn test_parse_sandbox_port_range_with_invalid_order_is_invalid() { + assert_eq!(parse_sandbox_port_range("4000..3000"), None); + } + + #[test] + fn test_parse_sandbox_port_range_requested_cases() { + // Includes '=' and lower < upper + assert_eq!(parse_sandbox_port_range("1000..=2000"), Some(1000..2000)); + // Only upper exists (missing lower) should be invalid + assert_eq!(parse_sandbox_port_range("..2000"), None); + // Does not include '=' and lower < upper + assert_eq!(parse_sandbox_port_range("1000..2000"), Some(1000..2000)); + } +}