//! Isolated local Tomcat installations and deployments. Never writes to CATALINA_HOME. use crate::RunConfig; use ide_core::{IdeError, IdeResult}; use quick_xml::{ Reader, Writer, events::{BytesStart, Event}, }; use serde::{Deserialize, Serialize}; use specta::Type; use std::{ fs, io::Read, net::TcpListener, path::{Path, PathBuf}, }; fn invalid(e: impl std::fmt::Display) -> IdeError { IdeError::invalid(e.to_string()) } #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct TomcatHome { pub version: String, } pub fn validate_home(home: &Path) -> IdeResult { if !(home.join("bin/catalina.sh").is_file() || home.join("bin/catalina.bat").is_file()) || !home.join("lib/catalina.jar").is_file() { return Err(invalid("Tomcat home must contain bin/catalina.sh or bin/catalina.bat and lib/catalina.jar")); } let mut jar = zip::ZipArchive::new(fs::File::open(home.join("lib/catalina.jar"))?).map_err(invalid)?; let mut properties = String::new(); jar.by_name("org/apache/catalina/util/ServerInfo.properties").map_err(invalid)?.read_to_string(&mut properties)?; let version = properties.lines().find_map(|l| l.strip_prefix("server.info=")).unwrap_or("Apache Tomcat").trim().to_owned(); Ok(TomcatHome { version }) } pub fn base_dir(cache: &Path, id: &str) -> PathBuf { // Stable and collision-free for configuration IDs, including Windows path characters. let key: String = id.as_bytes().iter().map(|b| format!("{b:02x}")).collect(); cache.join("tomcat").join(key) } pub fn free_port() -> IdeResult { Ok(TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port()) } pub fn context_name(context: &str) -> IdeResult { if context.is_empty() || context == "/" { return Ok("ROOT".into()); } if !context.starts_with('/') || context.ends_with('/') || context[1..].split('/').any(|p| { p.is_empty() || p == "." || p == ".." || !p.chars().all(|c| c.is_alphanumeric() || "-_".contains(c)) }) { return Err(invalid("Context path must start with / and contain only letters, digits, /, - or _")); } Ok(context[1..].replace('/', "#")) } fn escape(text: &str) -> String { quick_xml::escape::escape(text).into_owned() } pub fn context_xml(doc_base: &Path, reload: bool) -> String { format!("\n", escape(&doc_base.to_string_lossy())) } pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult { let mut reader = Reader::from_str(xml); let mut writer = Writer::new(Vec::new()); let mut http_found = false; loop { let event = reader.read_event().map_err(invalid)?; if matches!(event, Event::Eof) { break; } let event = match event { Event::Start(ref tag) | Event::Empty(ref tag) => { let name = tag.name().as_ref().to_owned(); let mut attrs: Vec<(String, String)> = tag .attributes() .map(|a| { let a = a.map_err(invalid)?; Ok(( a.key.as_ref().to_owned(), a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map_err(invalid)?.into_owned(), )) }) .collect::>()?; let mut set = |key: &str, value: String| { attrs.retain(|(k, _)| k != key); attrs.push((key.into(), value)); }; match name.as_str() { "Server" => { set("port", shutdown.to_string()); set("address", "127.0.0.1".into()); set("shutdown", secret.into()); } "Connector" => { let protocol = tag .try_get_attribute("protocol") .map_err(invalid)? .map(|a| a.value.into_owned()) .unwrap_or_default(); let is_http = !http_found && (protocol.is_empty() || protocol.contains("HTTP") || protocol.contains("Http")); set("port", if is_http { http } else { free_port()? }.to_string()); if is_http { http_found = true; } } "Engine" => set("name", "Catalina".into()), "Host" => { set("name", "localhost".into()); set("appBase", "webapps".into()); set("autoDeploy", "true".into()); set("deployOnStartup", "true".into()); } _ => {} } let mut new = BytesStart::new(name); for (k, v) in &attrs { new.push_attribute((k.as_str(), v.as_str())); } if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) } } e => e.into_owned(), }; writer.write_event(event).map_err(invalid)?; } if !http_found { return Err(invalid("Tomcat server.xml has no HTTP connector")); } String::from_utf8(writer.into_inner()).map_err(invalid) } fn copy_tree(from: &Path, to: &Path) -> IdeResult<()> { fs::create_dir_all(to)?; for entry in fs::read_dir(from)? { let entry = entry?; let target = to.join(entry.file_name()); if entry.file_type()?.is_dir() { copy_tree(&entry.path(), &target)?; } else { fs::copy(entry.path(), target)?; } } Ok(()) } /// Synchronize an archive into a private exploded deployment, preserving unchanged class timestamps. pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResult<()> { let mut zip = zip::ZipArchive::new(fs::File::open(war)?).map_err(invalid)?; fs::create_dir_all(destination)?; let mut expected = std::collections::HashSet::new(); for i in 0..zip.len() { let mut entry = zip.by_index(i).map_err(invalid)?; let path = entry.enclosed_name().ok_or_else(|| invalid("WAR contains an invalid path"))?; expected.insert(path.clone()); if entry.is_dir() { continue; } if resources_only && (path.extension().is_some_and(|e| e == "class") || path.starts_with("WEB-INF/lib")) { continue; } let target = destination.join(&path); let mut bytes = Vec::new(); entry.read_to_end(&mut bytes)?; if fs::read(&target).ok().as_deref() != Some(&bytes) { fs::create_dir_all(target.parent().unwrap())?; fs::write(target, bytes)?; } } fn remove_stale( root: &Path, dir: &Path, expected: &std::collections::HashSet, resources: bool, ) -> IdeResult<()> { for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); if entry.file_type()?.is_dir() { remove_stale(root, &path, expected, resources)?; } else { let rel = path.strip_prefix(root).map_err(invalid)?; if !expected.contains(rel) && !(resources && (rel.extension().is_some_and(|e| e == "class") || rel.starts_with("WEB-INF/lib"))) { fs::remove_file(path)?; } } } Ok(()) } remove_stale(destination, destination, &expected, resources_only) } /// Updates the exploded artifact deployment from the module's own class output directories and /// its web resources (`src/main/webapp`), file by file and only what changed, instead of /// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute. /// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output /// isn't on the classpath, or a module it depends on was compiled since the last update, whose /// archive in `WEB-INF/lib` would be stale) — rebuild the WAR instead. pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resources_only: bool) -> IdeResult { let dest = base.join("deployment"); let manifest = base.join("synced-files.json"); let since = [base.join("artifact-path.txt"), manifest.clone()] .iter() .filter_map(|p| fs::metadata(p).and_then(|m| m.modified()).ok()) .max(); let Some(since) = since else { return Ok(false) }; if !dest.join("WEB-INF").is_dir() { return Ok(false); } let (own, others): (Vec<&PathBuf>, Vec<&PathBuf>) = class_paths.iter().filter(|p| p.is_dir()).partition(|p| p.starts_with(module)); if own.is_empty() || others.iter().any(|d| modified_since(d, since)) { return Ok(false); } let previous: Vec = fs::read(&manifest).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); let mut current = std::collections::BTreeSet::new(); let classes = dest.join("WEB-INF/classes"); for dir in own { sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?; } let webapp = module.join("src/main/webapp"); if webapp.is_dir() { sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?; } for rel in previous.iter().filter(|p| !current.contains(*p)) { let path = dest.join(rel); if path.is_file() { fs::remove_file(path)?; } } fs::write(manifest, serde_json::to_vec(¤t).map_err(invalid)?)?; Ok(true) } fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool { let Ok(entries) = fs::read_dir(dir) else { return false }; entries.flatten().any(|e| match e.file_type() { Ok(t) if t.is_dir() => modified_since(&e.path(), since), Ok(t) if t.is_file() => e.metadata().and_then(|m| m.modified()).is_ok_and(|m| m > since), _ => false, }) } /// Copies changed files of `from` (below `root`) to `to`; unchanged files keep their timestamps, /// so Tomcat only reloads when a class really changed. Records each source file as `prefix/rel`. fn sync_tree( root: &Path, from: &Path, to: &Path, prefix: &Path, resources_only: bool, seen: &mut std::collections::BTreeSet, ) -> IdeResult<()> { for entry in fs::read_dir(from)? { let entry = entry?; let path = entry.path(); let kind = entry.file_type()?; if kind.is_dir() { sync_tree(root, &path, to, prefix, resources_only, seen)?; continue; } if !kind.is_file() { continue; } let rel = path.strip_prefix(root).map_err(invalid)?; seen.insert(prefix.join(rel).to_string_lossy().replace('\\', "/")); if resources_only && rel.extension().is_some_and(|e| e == "class") { continue; } let target = to.join(rel); let bytes = fs::read(&path)?; if fs::read(&target).ok().as_deref() != Some(&bytes) { fs::create_dir_all(target.parent().unwrap_or(to))?; fs::write(target, bytes)?; } } Ok(()) } pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: bool) -> IdeResult { let p = &config.extras.params; match p.get("deployment").map(String::as_str).unwrap_or("artifact") { "artifact" => { if let Some(id) = p.get("artifact").filter(|s| !s.is_empty()) && fs::read_to_string(base.join("artifact-id.txt")).ok().as_ref() != Some(id) { return Err(invalid("Build the selected WAR artifact before launching Tomcat")); } let war = fs::read_to_string(base.join("artifact-path.txt")) .map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?; let war = PathBuf::from(war.trim()); if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) { return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display()))); } let dest = base.join("deployment"); explode(&war, &dest, resources_only)?; Ok(dest) } kind @ ("war" | "directory") => { let value = p .get("deploymentPath") .filter(|v| !v.trim().is_empty()) .ok_or_else(|| invalid("Choose a WAR file or exploded directory"))?; let path = root.join(value); let path = dunce::canonicalize(&path) .map_err(|e| invalid(format!("Invalid Tomcat deployment: {} ({e})", path.display())))?; if (kind == "war" && (!path.is_file() || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")))) || (kind == "directory" && !path.is_dir()) { return Err(invalid(format!("Invalid Tomcat deployment: {}", path.display()))); } Ok(path) } _ => Err(invalid("Unknown Tomcat deployment type")), } } pub fn effective_context(config: &RunConfig, doc: &Path) -> String { config.extras.params.get("contextPath").filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| { let name = config .extras .params .get("artifactName") .cloned() .unwrap_or_else(|| doc.file_stem().unwrap_or_default().to_string_lossy().into_owned()); if name == "ROOT" { "/".into() } else { format!("/{name}") } }) } /// Platform-neutral plan; Windows uses the process manager's raw cmd quoting for batch scripts. pub fn script_command(home: &Path, windows: bool, verb: &str) -> (String, Vec) { if windows { (format!("\"{}\" {verb}", home.join("bin/catalina.bat").display()), vec![]) } else { ("sh".into(), vec![home.join("bin/catalina.sh").to_string_lossy().into_owned(), verb.into()]) } } pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<()> { // Batch scripts and their environment variables require ordinary Windows paths. let home = dunce::canonicalize(root.join( config.extras.params.get("tomcatHome").ok_or_else(|| invalid("Choose a Tomcat installation directory"))?, ))?; validate_home(&home)?; let base = base_dir(cache, &config.id); fs::create_dir_all(&base)?; let base = dunce::canonicalize(&base)?; let doc = deployment(root, &base, config, false)?; let context = effective_context(config, &doc); let context = context_name(&context)?; let port: u16 = config.extras.params.get("httpPort").map(String::as_str).unwrap_or("8080").parse().map_err(invalid)?; if port == 0 { return Err(invalid("HTTP port must be between 1 and 65535")); } let shutdown = free_port()?; let secret = format!( "OPENIDEA_{}_{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(invalid)?.as_nanos() ); // Refresh only the IDE-owned configuration, leaving logs and deployed artifacts available. if base.join("conf").exists() { fs::remove_dir_all(base.join("conf"))?; } copy_tree(&home.join("conf"), &base.join("conf"))?; let contexts = base.join("conf/Catalina/localhost"); if contexts.exists() { fs::remove_dir_all(&contexts)?; } for dir in ["logs", "temp", "work", "webapps", "conf/Catalina/localhost"] { fs::create_dir_all(base.join(dir))?; } let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?; fs::write(base.join("conf/server.xml"), server)?; let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true"); fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?; config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned()); config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned()); if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) { let jre = dunce::canonicalize(root.join(jre))?; config.env.insert("JAVA_HOME".into(), jre.to_string_lossy().into_owned()); config.env.insert("JRE_HOME".into(), jre.to_string_lossy().into_owned()); } else if let Some(java) = config.env.get("JAVA_HOME").cloned() { config.env.insert("JRE_HOME".into(), java); } let vm = config.extras.params.get("vmOptions").cloned().unwrap_or_default(); let jmx = free_port()?; let mut opts = config.env.get("CATALINA_OPTS").cloned().unwrap_or_default(); opts.push_str(&format!(" {vm} -Dcom.sun.management.jmxremote.port={jmx} -Dcom.sun.management.jmxremote.rmi.port={jmx} -Dcom.sun.management.jmxremote.host=127.0.0.1 -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false")); if debug { opts.push_str(" -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:0"); } config.env.insert("CATALINA_OPTS".into(), opts); (config.command, config.args) = script_command(&home, cfg!(windows), "run"); config.extras.stop_command = Some(if cfg!(windows) { script_command(&home, true, "stop").0 } else { format!("sh '{}' stop", home.join("bin/catalina.sh").to_string_lossy().replace('\'', "'\\''")) }); config.extras.emulate_terminal = false; Ok(()) } pub fn update(root: &Path, cache: &Path, config: &RunConfig, mode: &str) -> IdeResult<()> { if !matches!(mode, "resources" | "classes" | "redeploy") { return Err(invalid("Unknown Tomcat update operation")); } let base = base_dir(cache, &config.id); let doc = deployment(root, &base, config, mode == "resources")?; let context = context_name(&effective_context(config, &doc))?; let file = base.join("conf/Catalina/localhost").join(format!("{context}.xml")); if !file.is_file() { return Err(invalid("Start this Tomcat configuration before updating it")); } if mode == "redeploy" { let content = fs::read(&file)?; fs::write(file, content)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::io::Write; fn archive(path: &Path, entries: &[(&str, &str)]) { fs::create_dir_all(path.parent().unwrap()).unwrap(); let mut zip = zip::ZipWriter::new(fs::File::create(path).unwrap()); for (name, text) in entries { zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); zip.write_all(text.as_bytes()).unwrap(); } zip.finish().unwrap(); } fn config() -> RunConfig { RunConfig { id: "local:demo".into(), name: "Tomcat".into(), kind: "tomcat".into(), command: "catalina".into(), args: vec!["run".into()], cwd: None, env: Default::default(), detected: false, is_test: false, debug_adapter: Some("java-debug".into()), allow_parallel: false, extras: Default::default(), } } #[test] fn validates_home_and_reads_version() { let tmp = tempfile::tempdir().unwrap(); assert!(validate_home(tmp.path()).is_err()); fs::create_dir_all(tmp.path().join("bin")).unwrap(); fs::write(tmp.path().join("bin/catalina.bat"), "").unwrap(); archive( &tmp.path().join("lib/catalina.jar"), &[("org/apache/catalina/util/ServerInfo.properties", "server.info=Apache Tomcat/10.1.61\n")], ); assert_eq!(validate_home(tmp.path()).unwrap().version, "Apache Tomcat/10.1.61"); } #[test] fn patches_ports_without_changing_comments_and_escapes_context() { let xml = ""; let patched = patch_server_xml(xml, 18080, 18005, "SECRET").unwrap(); assert!(patched.contains("port=\"18080\"")); assert!(patched.contains("port=\"18005\"")); assert!(!patched.contains("port=\"8009\"")); assert!(patched.contains("")); assert_eq!(context_name("/").unwrap(), "ROOT"); assert_eq!(context_name("/a/b").unwrap(), "a#b"); for path in ["../escape", "/../escape", "/x#y", "/a//b", "/a/"] { assert!(context_name(path).is_err()); } assert!(context_xml(Path::new("/tmp/a & b/\"web\""), false).contains("a & b/"web"")); } #[test] fn constructs_platform_commands_without_splitting_paths() { let home = if cfg!(windows) { Path::new("C:/some path/tomcat") } else { Path::new("/some path/tomcat") }; assert_eq!( script_command(home, false, "run"), ("sh".into(), vec![home.join("bin/catalina.sh").to_string_lossy().into_owned(), "run".into()]) ); let (command, args) = script_command(home, true, "run"); assert_eq!(command, format!("\"{}\" run", home.join("bin/catalina.bat").display())); assert!(args.is_empty()); } #[test] fn resolves_manual_deployments_and_synchronizes_war_resources() { let tmp = tempfile::tempdir().unwrap(); let war = tmp.path().join("web app.war"); archive(&war, &[("index.html", "old"), ("WEB-INF/classes/App.class", "class1"), ("removed.txt", "delete")]); let base = tmp.path().join("cache"); fs::create_dir_all(&base).unwrap(); fs::write(base.join("artifact-path.txt"), war.to_string_lossy().as_bytes()).unwrap(); let mut c = config(); let dest = deployment(tmp.path(), &base, &c, false).unwrap(); archive(&war, &[("index.html", "new"), ("WEB-INF/classes/App.class", "class2")]); deployment(tmp.path(), &base, &c, true).unwrap(); assert_eq!(fs::read_to_string(dest.join("index.html")).unwrap(), "new"); assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/App.class")).unwrap(), "class1"); assert!(!dest.join("removed.txt").exists()); deployment(tmp.path(), &base, &c, false).unwrap(); assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/App.class")).unwrap(), "class2"); c.extras.params.insert("deployment".into(), "war".into()); c.extras.params.insert("deploymentPath".into(), "web app.war".into()); assert_eq!(deployment(tmp.path(), &base, &c, false).unwrap(), dunce::canonicalize(&war).unwrap()); fs::create_dir_all(tmp.path().join("WebContent")).unwrap(); c.extras.params.insert("deployment".into(), "directory".into()); c.extras.params.insert("deploymentPath".into(), "WebContent".into()); assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent")); } #[test] fn updates_the_exploded_deployment_from_module_outputs() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path(); let (base, module) = (root.join("base"), root.join("web")); let (dest, out, dep) = (base.join("deployment"), module.join("target/classes"), root.join("core/target/classes")); let webapp = module.join("src/main/webapp"); for dir in [dest.join("WEB-INF/classes/a"), out.join("a"), webapp.clone(), dep.clone()] { fs::create_dir_all(dir).unwrap(); } for (path, text) in [ (dest.join("WEB-INF/classes/a/Same.class"), "same"), (dest.join("WEB-INF/classes/a/App.class"), "old"), (dest.join("index.jsp"), "old"), (out.join("a/Same.class"), "same"), (out.join("a/App.class"), "new"), (out.join("app.properties"), "new"), (webapp.join("index.jsp"), "new"), ] { fs::write(path, text).unwrap(); } let paths = [out.clone(), dep.clone(), root.join("lib.jar")]; // Not deployed from the artifact yet. assert!(!sync_outputs(&base, &module, &paths, false).unwrap()); fs::write(base.join("artifact-path.txt"), "web.war").unwrap(); let same = || fs::metadata(dest.join("WEB-INF/classes/a/Same.class")).unwrap().modified().unwrap(); let before = same(); assert!(sync_outputs(&base, &module, &paths, true).unwrap()); assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "old"); assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/app.properties")).unwrap(), "new"); assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "new"); assert!(sync_outputs(&base, &module, &paths, false).unwrap()); assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "new"); assert_eq!(same(), before); fs::remove_file(out.join("app.properties")).unwrap(); assert!(sync_outputs(&base, &module, &paths, false).unwrap()); assert!(!dest.join("WEB-INF/classes/app.properties").exists()); assert!(dest.join("WEB-INF/classes/a/Same.class").exists()); // A dependency module compiled since: its archive in WEB-INF/lib is stale. let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60); fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap(); assert!(!sync_outputs(&base, &module, &paths, false).unwrap()); } #[test] fn deployment_errors_identify_the_checked_path() { let tmp = tempfile::tempdir().unwrap(); let base = tmp.path().join("cache"); fs::create_dir_all(&base).unwrap(); for path in ["missing.war", r"C:\工作\timesheet\target\timesheet.war", r"\\server\share\timesheet.war"] { fs::write(base.join("artifact-path.txt"), format!("{path}\r\n")).unwrap(); let error = deployment(tmp.path(), &base, &config(), false).unwrap_err().to_string(); assert!(error.contains(path), "{error}"); } for kind in ["war", "directory"] { let mut c = config(); c.extras.params.insert("deployment".into(), kind.into()); c.extras.params.insert("deploymentPath".into(), "missing".into()); let error = deployment(tmp.path(), &base, &c, false).unwrap_err().to_string(); assert!(error.contains(&tmp.path().join("missing").display().to_string()), "{error}"); c.extras.params.insert("deploymentPath".into(), "cache".into()); if kind == "war" { assert!(deployment(tmp.path(), &base, &c, false).unwrap_err().to_string().contains("cache")); } } } #[test] fn prepares_isolated_base_and_jdwp_only_for_run() { let tmp = tempfile::tempdir().unwrap(); check_prepared_paths(tmp.path()); } #[cfg(windows)] #[test] fn prepares_plain_windows_paths_from_verbatim_root() { let tmp = tempfile::tempdir().unwrap(); let root = PathBuf::from(format!(r"\\?\{}", dunce::canonicalize(tmp.path()).unwrap().display())); check_prepared_paths(&root); } fn check_prepared_paths(root: &Path) { let home = root.join("Tomcat Home"); fs::create_dir_all(home.join("bin")).unwrap(); fs::create_dir_all(home.join("conf")).unwrap(); fs::create_dir_all(root.join("WebRoot")).unwrap(); fs::write(home.join("bin/catalina.sh"), "").unwrap(); fs::write(home.join("bin/catalina.bat"), "").unwrap(); archive( &home.join("lib/catalina.jar"), &[("org/apache/catalina/util/ServerInfo.properties", "server.info=Apache Tomcat/10.1")], ); let server = ""; fs::write(home.join("conf/server.xml"), server).unwrap(); let mut c = config(); for (k, v) in [ ("tomcatHome", "Tomcat Home"), ("deployment", "directory"), ("deploymentPath", "WebRoot"), ("contextPath", "/app"), ("tomcatDebug", "true"), ("vmOptions", "-Xmx256m"), ] { c.extras.params.insert(k.into(), v.into()); } c.env.insert("JAVA_HOME".into(), "/jdk with spaces".into()); let cache = root.join("cache"); prepare(root, &cache, &mut c).unwrap(); assert_eq!(fs::read_to_string(home.join("conf/server.xml")).unwrap(), server); let base = PathBuf::from(&c.env["CATALINA_BASE"]); let context = base.join("conf/Catalina/localhost/app.xml"); let doc = dunce::canonicalize(root.join("WebRoot")).unwrap(); assert_eq!(fs::read_to_string(&context).unwrap(), context_xml(&doc, false)); assert_eq!(c.env["CATALINA_HOME"], dunce::canonicalize(&home).unwrap().to_string_lossy()); assert_eq!(base, dunce::canonicalize(base_dir(&cache, &c.id)).unwrap()); assert!(c.env["CATALINA_OPTS"].contains("suspend=n,address=127.0.0.1:0")); assert!(c.env["CATALINA_OPTS"].contains("-Xmx256m")); assert!(!c.env.get("JAVA_OPTS").is_some_and(|s| s.contains("jdwp"))); assert_eq!(c.env["JRE_HOME"], "/jdk with spaces"); let stop = c.extras.stop_command.as_ref().unwrap(); assert!(stop.contains("stop")); let script = if cfg!(windows) { "catalina.bat" } else { "catalina.sh" }; assert!(stop.contains(script)); fs::create_dir_all(root.join("JDK Home")).unwrap(); c.extras.params.insert("jre".into(), "JDK Home".into()); prepare(root, &cache, &mut c).unwrap(); let jre = dunce::canonicalize(root.join("JDK Home")).unwrap(); for key in ["JAVA_HOME", "JRE_HOME"] { assert_eq!(c.env[key], jre.to_string_lossy()); } assert!(!c.command.contains(r"\\?\")); assert!(c.args.iter().all(|arg| !arg.contains(r"\\?\"))); assert!(!c.extras.stop_command.as_ref().unwrap().contains(r"\\?\")); assert!(c.env.values().all(|value| !value.contains(r"\\?\"))); assert!(!fs::read_to_string(context).unwrap().contains(r"\\?\")); } }