diff --git a/launcher-package/integration-test/src/test/scala/RunnerScriptTest.scala b/launcher-package/integration-test/src/test/scala/RunnerScriptTest.scala index af54da662..a570dc47b 100644 --- a/launcher-package/integration-test/src/test/scala/RunnerScriptTest.scala +++ b/launcher-package/integration-test/src/test/scala/RunnerScriptTest.scala @@ -194,4 +194,59 @@ object RunnerScriptTest extends verify.BasicTestSuite with ShellScriptUtil: s"Machine config should appear before project config. machineIndex=$machineIndex, projectIndex=$projectIndex" ) + testOutput( + "command line options override project .sbtopts", + sbtOptsFileContents = + "-J-Xmx2g\n-J-XX:ReservedCodeCacheSize=1g\n-J-XX:MaxMetaspaceSize=2g\n-J-Xss512m\n-J-XX:+UseG1GC" + )("-d", "-v", "-mem", "12288"): (out: List[String]) => + if (isWindows) cancel("Test not supported on windows") + else + val cmdLineStart = out.indexWhere(_.contains("Executing command line")) + assert(cmdLineStart >= 0, "Command line section not found") + + val cmdLine = out.drop(cmdLineStart + 1).takeWhile(!_.trim.isEmpty) + val xmxCliIndex = cmdLine.indexWhere(_.contains("-Xmx12288m")) + val xmxSbtoptsIndex = cmdLine.indexWhere(_.contains("-Xmx2g")) + val g1Index = cmdLine.indexWhere(_.contains("-XX:+UseG1GC")) + + assert(xmxCliIndex >= 0, "CLI memory setting not found in command line") + assert(xmxSbtoptsIndex < 0, "sbtopts -Xmx2g should be overridden by CLI") + assert(g1Index >= 0, "sbtopts non-memory option not found in command line") + assert( + g1Index < xmxCliIndex, + s"sbtopts options should appear before CLI memory settings. g1Index=$g1Index, xmxCliIndex=$xmxCliIndex" + ) + + // Test for issue #7289: Special characters in .jvmopts should not cause shell expansion + testOutput( + "sbt with special characters in .jvmopts (pipes, wildcards, ampersands)", + jvmoptsFileContents = + "-Dtest.pipes=host1|host2|host3\n-Dtest.wildcards=path/*/pattern\n-Dtest.ampersand=value&other", + windowsSupport = false, + )("-v"): (out: List[String]) => + // Verify that properties with special characters are handled correctly + // The pipe characters should be treated literally, not as shell operators + assert( + out.contains[String]("-Dtest.pipes=host1|host2|host3"), + "Property with pipes should be handled correctly" + ) + assert( + out.contains[String]("-Dtest.wildcards=path/*/pattern"), + "Property with wildcards should be handled correctly" + ) + assert( + out.contains[String]("-Dtest.ampersand=value&other"), + "Property with ampersands should be handled correctly" + ) + // Verify no shell errors occurred (no "command not found" messages or "unexpected" errors) + val errorMessages = out.filter(line => + line.contains("command not found") || + line.contains("was unexpected at this time") || + line.contains("syntax error") + ) + assert( + errorMessages.isEmpty, + s"Should not have shell expansion errors, but found: ${errorMessages.mkString(", ")}" + ) + end RunnerScriptTest diff --git a/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala b/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala index ce6a10c11..3077e506a 100644 --- a/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala +++ b/launcher-package/integration-test/src/test/scala/ShellScriptUtil.scala @@ -35,96 +35,116 @@ trait ShellScriptUtil extends BasicTestSuite { sbtOptsFileContents: String = "", javaToolOptions: String = "", distSbtoptsContents: String = "", - machineSbtoptsContents: String = "" + machineSbtoptsContents: String = "", + jvmoptsFileContents: String = "", + windowsSupport: Boolean = true, )(args: String*)(f: List[String] => Any) = - test(name) { - val workingDirectory = Files.createTempDirectory("sbt-launcher-package-test").toFile - retry(() => IO.copyDirectory(new File("launcher-package/citest"), workingDirectory)) + if !windowsSupport && isWindows then + test(name): + cancel("test not supported on Windows") + else + test(name) { + val workingDirectory = Files.createTempDirectory("sbt-launcher-package-test").toFile + retry(() => IO.copyDirectory(new File("launcher-package/citest"), workingDirectory)) - var sbtHome: Option[File] = None - var configHome: Option[File] = None - var tempSbtHome: Option[File] = None - var testSbtScript: File = sbtScript - try - val sbtOptsFile = new File(workingDirectory, ".sbtopts") - sbtOptsFile.createNewFile() - val writer = new PrintWriter(sbtOptsFile) - try { - writer.write(sbtOptsFileContents) - } finally { - writer.close() - } + var sbtHome: Option[File] = None + var configHome: Option[File] = None + var tempSbtHome: Option[File] = None + var testSbtScript: File = sbtScript + try + val sbtOptsFile = new File(workingDirectory, ".sbtopts") + sbtOptsFile.createNewFile() + val writer = new PrintWriter(sbtOptsFile) + try { + writer.write(sbtOptsFileContents) + } finally { + writer.close() + } - val envVars = scala.collection.mutable.Map[String, String]() + // Create .jvmopts file if contents provided + if (jvmoptsFileContents.nonEmpty) { + val jvmoptsFile = new File(workingDirectory, ".jvmopts") + jvmoptsFile.createNewFile() + val jvmoptsWriter = new PrintWriter(jvmoptsFile) + try { + jvmoptsWriter.write(jvmoptsFileContents) + } finally { + jvmoptsWriter.close() + } + } - // Set up dist sbtopts if provided - // Note: sbt script derives sbt_home from script location, not SBT_HOME env var - // Copy the sbt staging directory to a temp location to avoid modifying the staging directory - if (distSbtoptsContents.nonEmpty) { - val originalSbtHome = sbtScript.getParentFile.getParentFile - val tempSbtHomeDir = Files.createTempDirectory("sbt-home-test").toFile - tempSbtHome = Some(tempSbtHomeDir) - // Copy the entire sbt home directory structure - retry(() => IO.copyDirectory(originalSbtHome, tempSbtHomeDir)) - // Get the script from the copied directory - val binDir = new File(tempSbtHomeDir, "bin") - testSbtScript = new File(binDir, sbtScript.getName) - // Create dist sbtopts in the copied directory - val distSbtoptsDir = new File(tempSbtHomeDir, "conf") - distSbtoptsDir.mkdirs() - val distSbtoptsFile = new File(distSbtoptsDir, "sbtopts") - IO.write(distSbtoptsFile, distSbtoptsContents) - // Store reference for cleanup - sbtHome = Some(tempSbtHomeDir) - } + val envVars = scala.collection.mutable.Map[String, String]() - // Ensure no machine sbtopts exists when testing dist-only (unless explicitly provided) - // The script only loads dist if machine doesn't exist - if (distSbtoptsContents.nonEmpty && machineSbtoptsContents.isEmpty && configHome.isEmpty) { - // Set XDG_CONFIG_HOME to a temp directory without sbtopts to prevent default machine sbtopts from being found - val emptyConfigHome = Files.createTempDirectory("empty-config-home").toFile - envVars("XDG_CONFIG_HOME") = emptyConfigHome.getAbsolutePath - // Also unset SBT_ETC_FILE if it exists - sys.env.get("SBT_ETC_FILE").foreach(_ => envVars("SBT_ETC_FILE") = "") - // Store for cleanup - configHome = Some(emptyConfigHome) - } + // Set up dist sbtopts if provided + // Note: sbt script derives sbt_home from script location, not SBT_HOME env var + // Copy the sbt staging directory to a temp location to avoid modifying the staging directory + if (distSbtoptsContents.nonEmpty) { + val originalSbtHome = sbtScript.getParentFile.getParentFile + val tempSbtHomeDir = Files.createTempDirectory("sbt-home-test").toFile + tempSbtHome = Some(tempSbtHomeDir) + // Copy the entire sbt home directory structure + retry(() => IO.copyDirectory(originalSbtHome, tempSbtHomeDir)) + // Get the script from the copied directory + val binDir = new File(tempSbtHomeDir, "bin") + testSbtScript = new File(binDir, sbtScript.getName) + // Create dist sbtopts in the copied directory + val distSbtoptsDir = new File(tempSbtHomeDir, "conf") + distSbtoptsDir.mkdirs() + val distSbtoptsFile = new File(distSbtoptsDir, "sbtopts") + IO.write(distSbtoptsFile, distSbtoptsContents) + // Store reference for cleanup + sbtHome = Some(tempSbtHomeDir) + } - // Set up machine sbtopts if provided - if (machineSbtoptsContents.nonEmpty) { - val configHomeDir = Files.createTempDirectory("config-home").toFile - configHome = Some(configHomeDir) - val machineSbtoptsDir = new File(configHomeDir, "sbt") - machineSbtoptsDir.mkdirs() - val machineSbtoptsFile = new File(machineSbtoptsDir, "sbtopts") - IO.write(machineSbtoptsFile, machineSbtoptsContents) - envVars("XDG_CONFIG_HOME") = configHomeDir.getAbsolutePath - } + // Ensure no machine sbtopts exists when testing dist-only (unless explicitly provided) + // The script only loads dist if machine doesn't exist + if ( + distSbtoptsContents.nonEmpty && machineSbtoptsContents.isEmpty && configHome.isEmpty + ) { + // Set XDG_CONFIG_HOME to a temp directory without sbtopts to prevent default machine sbtopts from being found + val emptyConfigHome = Files.createTempDirectory("empty-config-home").toFile + envVars("XDG_CONFIG_HOME") = emptyConfigHome.getAbsolutePath + // Also unset SBT_ETC_FILE if it exists + sys.env.get("SBT_ETC_FILE").foreach(_ => envVars("SBT_ETC_FILE") = "") + // Store for cleanup + configHome = Some(emptyConfigHome) + } - val path = sys.env.getOrElse("PATH", sys.env("Path")) - envVars("JAVA_OPTS") = javaOpts - envVars("SBT_OPTS") = sbtOpts - envVars("JAVA_TOOL_OPTIONS") = javaToolOptions - if (isWindows) - envVars("JAVACMD") = new File(javaBinDir, "java").getAbsolutePath() - else - envVars("PATH") = javaBinDir + File.pathSeparator + path + // Set up machine sbtopts if provided + if (machineSbtoptsContents.nonEmpty) { + val configHomeDir = Files.createTempDirectory("config-home").toFile + configHome = Some(configHomeDir) + val machineSbtoptsDir = new File(configHomeDir, "sbt") + machineSbtoptsDir.mkdirs() + val machineSbtoptsFile = new File(machineSbtoptsDir, "sbtopts") + IO.write(machineSbtoptsFile, machineSbtoptsContents) + envVars("XDG_CONFIG_HOME") = configHomeDir.getAbsolutePath + } - val out = scala.sys.process - .Process( - Seq(testSbtScript.getAbsolutePath) ++ args, - workingDirectory, - envVars.toSeq* - ) - .!! - .linesIterator - .toList - f(out) - () - finally - IO.delete(workingDirectory) - // Clean up temporary sbt home directory if we created one - tempSbtHome.foreach(IO.delete) - configHome.foreach(IO.delete) - } + val path = sys.env.getOrElse("PATH", sys.env("Path")) + envVars("JAVA_OPTS") = javaOpts + envVars("SBT_OPTS") = sbtOpts + envVars("JAVA_TOOL_OPTIONS") = javaToolOptions + if (isWindows) + envVars("JAVACMD") = new File(javaBinDir, "java").getAbsolutePath() + else + envVars("PATH") = javaBinDir + File.pathSeparator + path + + val out = scala.sys.process + .Process( + Seq(testSbtScript.getAbsolutePath) ++ args, + workingDirectory, + envVars.toSeq* + ) + .!! + .linesIterator + .toList + f(out) + () + finally + IO.delete(workingDirectory) + // Clean up temporary sbt home directory if we created one + tempSbtHome.foreach(IO.delete) + configHome.foreach(IO.delete) + } } diff --git a/sbt b/sbt index 461037385..b9990485f 100755 --- a/sbt +++ b/sbt @@ -767,8 +767,11 @@ process_args () { loadConfigFile() { # Make sure the last line is read even if it doesn't have a terminating \n + # Output lines literally without shell expansion to handle special characters safely cat "$1" | sed $'/^\#/d;s/\r$//' | while read -r line || [[ -n "$line" ]]; do - eval echo $line + # Use printf with properly quoted variable to prevent shell expansion + # This safely handles special characters like |, *, &, etc. + printf '%s\n' "$line" done } @@ -853,17 +856,23 @@ runNativeClient() { original_args=("$@") +sbt_file_opts=() + # Pull in the machine-wide settings configuration. if [[ -f "$machine_sbt_opts_file" ]]; then - set -- "$@" $(loadConfigFile "$machine_sbt_opts_file") + sbt_file_opts+=($(loadConfigFile "$machine_sbt_opts_file")) else # Otherwise pull in the default settings configuration. - [[ -f "$dist_sbt_opts_file" ]] && set -- "$@" $(loadConfigFile "$dist_sbt_opts_file") + [[ -f "$dist_sbt_opts_file" ]] && sbt_file_opts+=($(loadConfigFile "$dist_sbt_opts_file")) fi # Pull in the project-level config file, if it exists (highest priority, overrides machine/dist). -# Append so it appears last in command line and wins for duplicate properties. -[[ -f "$sbt_opts_file" ]] && set -- "$@" $(loadConfigFile "$sbt_opts_file") +[[ -f "$sbt_opts_file" ]] && sbt_file_opts+=($(loadConfigFile "$sbt_opts_file")) + +# Prepend sbtopts so command line args appear last and win for duplicate properties. +if (( ${#sbt_file_opts[@]} > 0 )); then + set -- "${sbt_file_opts[@]}" "$@" +fi # Pull in the project-level java config, if it exists. [[ -f ".jvmopts" ]] && export JAVA_OPTS="$JAVA_OPTS $(loadConfigFile .jvmopts)"