From bf45f0b00d4877b5d0e55ece8906a35cdfb7e13c Mon Sep 17 00:00:00 2001 From: Ashley Scopes <73482956+ascopes@users.noreply.github.com> Date: Sat, 21 Sep 2024 16:03:52 +0100 Subject: [PATCH] GH-394: Use Java argument files for command line arguments This is far more reliable in cross-platform parsing consistency, and mostly superceeds the use of Shlex, although we still rely on it to deal with simple path quoting. We may be able to refactor this out entirely in the future. The change also helps support GH-397's implementation which introduces a higher risk of bugs slipping in through the unreliable batch specifications for argument parsing. --- .../src/it/gh-277-plugin-ordering/test.groovy | 2 +- .../it/gh-359-modular-jar-plugin/test.groovy | 2 + .../plugins/BinaryPluginResolver.java | 2 +- .../plugins/JvmPluginResolver.java | 210 ++++++++++-------- .../protoc/ProtocResolver.java | 2 +- .../utils/ArgumentFileBuilder.java | 84 +++++++ .../protobufmavenplugin/utils/Shlex.java | 5 + .../SystemPathBinaryResolver.java | 5 +- .../utils/ArgumentFileBuilderTest.java | 107 +++++++++ 9 files changed, 316 insertions(+), 103 deletions(-) create mode 100644 protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilder.java rename protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/{dependencies => utils}/SystemPathBinaryResolver.java (94%) create mode 100644 protobuf-maven-plugin/src/test/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilderTest.java diff --git a/protobuf-maven-plugin/src/it/gh-277-plugin-ordering/test.groovy b/protobuf-maven-plugin/src/it/gh-277-plugin-ordering/test.groovy index 757f5812..dad3da9a 100644 --- a/protobuf-maven-plugin/src/it/gh-277-plugin-ordering/test.groovy +++ b/protobuf-maven-plugin/src/it/gh-277-plugin-ordering/test.groovy @@ -24,7 +24,7 @@ Path baseDirectory = basedir.toPath().toAbsolutePath() Path logFile = baseDirectory.resolve("build.log") List logLines = Files.readAllLines(logFile) -int indexOfMatch(List logLines, String pattern) { +static int indexOfMatch(List logLines, String pattern) { for (int i = 0; i < logLines.size(); ++i) { if (logLines.get(i).matches(pattern)) { return i diff --git a/protobuf-maven-plugin/src/it/gh-359-modular-jar-plugin/test.groovy b/protobuf-maven-plugin/src/it/gh-359-modular-jar-plugin/test.groovy index f045f29a..cb1506dd 100644 --- a/protobuf-maven-plugin/src/it/gh-359-modular-jar-plugin/test.groovy +++ b/protobuf-maven-plugin/src/it/gh-359-modular-jar-plugin/test.groovy @@ -54,6 +54,8 @@ assertThat(expectedGeneratedFile) // Verify we invoked the JVM with a module path. assertThat(Files.list(expectedScriptsDirectory)) .singleElement(InstanceOfAssertFactories.PATH) + .isDirectory() + .extracting({ dir -> dir.resolve("args.txt") }, InstanceOfAssertFactories.PATH) .isRegularFile() .content() .contains("--module-path") diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/BinaryPluginResolver.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/BinaryPluginResolver.java index b5d16e1d..583f3d57 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/BinaryPluginResolver.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/BinaryPluginResolver.java @@ -19,10 +19,10 @@ import io.github.ascopes.protobufmavenplugin.dependencies.MavenArtifactPathResolver; import io.github.ascopes.protobufmavenplugin.dependencies.PlatformClassifierFactory; import io.github.ascopes.protobufmavenplugin.dependencies.ResolutionException; -import io.github.ascopes.protobufmavenplugin.dependencies.SystemPathBinaryResolver; import io.github.ascopes.protobufmavenplugin.dependencies.UrlResourceFetcher; import io.github.ascopes.protobufmavenplugin.utils.Digests; import io.github.ascopes.protobufmavenplugin.utils.FileUtils; +import io.github.ascopes.protobufmavenplugin.utils.SystemPathBinaryResolver; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/JvmPluginResolver.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/JvmPluginResolver.java index bcd0f4d4..79c02ffd 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/JvmPluginResolver.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/plugins/JvmPluginResolver.java @@ -16,24 +16,30 @@ package io.github.ascopes.protobufmavenplugin.plugins; +import static java.nio.charset.StandardCharsets.ISO_8859_1; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.StandardOpenOption.CREATE_NEW; + import io.github.ascopes.protobufmavenplugin.dependencies.DependencyResolutionDepth; import io.github.ascopes.protobufmavenplugin.dependencies.MavenArtifactPathResolver; import io.github.ascopes.protobufmavenplugin.dependencies.ResolutionException; import io.github.ascopes.protobufmavenplugin.generation.TemporarySpace; +import io.github.ascopes.protobufmavenplugin.utils.ArgumentFileBuilder; import io.github.ascopes.protobufmavenplugin.utils.Digests; import io.github.ascopes.protobufmavenplugin.utils.FileUtils; import io.github.ascopes.protobufmavenplugin.utils.HostSystem; import io.github.ascopes.protobufmavenplugin.utils.Shlex; +import io.github.ascopes.protobufmavenplugin.utils.SystemPathBinaryResolver; import java.io.IOException; import java.lang.module.ModuleFinder; import java.lang.module.ModuleReference; import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.Set; @@ -46,79 +52,89 @@ import org.slf4j.LoggerFactory; /** - * Wraps a JVM-based plugin invocation using an OS-native script that calls Java. + * Component that takes a reference to a pure-Java {@code protoc} plugin and wraps it in a shell + * script or batch file to invoke it as if it were a single executable binary. + * + *

The aim is to support enabling protoc to invoke executables without creating native + * binaries first, which is error-prone and increases the complexity of this Maven plugin + * significantly. * - *

This script can be marked as executable and passed to the {@code protoc} invocation - * as a path to ensure the script gets called correctly. By doing this, we avoid the need to build - * OS-native executables during the protobuf compilation process. + *

This implementation is a rewrite as of v2.6.0 that now uses Java argument files to + * deal with argument quoting in a platform-agnostic way, since the specification for batch files is + * very poorly documented and full of edge cases that could cause builds to fail. * * @author Ashley Scopes + * @since 2.6.0 */ @Named public final class JvmPluginResolver { private static final Set ALLOWED_SCOPES = Set.of("compile", "runtime", "system"); - private static final Logger log = LoggerFactory.getLogger(BinaryPluginResolver.class); + private static final Logger log = LoggerFactory.getLogger(JvmPluginResolver.class); private final HostSystem hostSystem; private final MavenArtifactPathResolver artifactPathResolver; private final TemporarySpace temporarySpace; + private final SystemPathBinaryResolver pathResolver; @Inject public JvmPluginResolver( HostSystem hostSystem, MavenArtifactPathResolver artifactPathResolver, - TemporarySpace temporarySpace + TemporarySpace temporarySpace, + SystemPathBinaryResolver pathResolver ) { this.hostSystem = hostSystem; this.artifactPathResolver = artifactPathResolver; this.temporarySpace = temporarySpace; + this.pathResolver = pathResolver; } - public Collection resolveMavenPlugins( - Collection plugins + public Collection resolveMavenPlugins( + Collection pluginDescriptors ) throws IOException, ResolutionException { var resolvedPlugins = new ArrayList(); - for (var plugin : plugins) { - if (plugin.isSkip()) { - log.info("Skipping plugin {}", plugin); + for (var pluginDescriptor : pluginDescriptors) { + if (pluginDescriptor.isSkip()) { + log.info("Skipping plugin {}", pluginDescriptor); continue; } - resolvedPlugins.add(resolve(plugin)); + var resolvedPlugin = resolveMavenPlugin(pluginDescriptor); + resolvedPlugins.add(resolvedPlugin); } - return resolvedPlugins; + return Collections.unmodifiableList(resolvedPlugins); } - private ResolvedProtocPlugin resolve( - MavenProtocPlugin plugin + private ResolvedProtocPlugin resolveMavenPlugin( + MavenProtocPlugin pluginDescriptor ) throws IOException, ResolutionException { log.debug( "Resolving JVM-based Maven protoc plugin {} and generating OS-specific boostrap scripts", - plugin + pluginDescriptor ); - var pluginId = pluginIdDigest(plugin); - var argLine = resolveAndBuildArgLine(plugin); + var id = hashPlugin(pluginDescriptor); + var argLine = buildArgLine(pluginDescriptor); + var javaPath = hostSystem.getJavaExecutablePath(); + var scratchDir = temporarySpace.createTemporarySpace("plugins", "jvm", id); var scriptPath = hostSystem.isProbablyWindows() - ? writeWindowsBatchScript(pluginId, argLine) - : writeShellScript(pluginId, argLine); + ? writeWindowsScripts(id, javaPath, scratchDir, argLine) + : writePosixScripts(id, javaPath, scratchDir, argLine); return ImmutableResolvedProtocPlugin .builder() - .id(pluginId) + .id(id) .path(scriptPath) - .options(plugin.getOptions()) - .order(plugin.getOrder()) + .options(pluginDescriptor.getOptions()) + .order(pluginDescriptor.getOrder()) .build(); } - private List resolveAndBuildArgLine( - MavenProtocPlugin plugin - ) throws ResolutionException, IOException { - + private ArgumentFileBuilder buildArgLine(MavenProtocPlugin plugin) + throws ResolutionException, IOException { // Expectation: this always has at least one item in it, and the first item is the plugin // artifact itself. var dependencies = artifactPathResolver @@ -128,10 +144,11 @@ private List resolveAndBuildArgLine( ALLOWED_SCOPES, false, true - ); + ) + .stream() + .collect(Collectors.toUnmodifiableList()); - var args = new ArrayList(); - args.add(hostSystem.getJavaExecutablePath().toString()); + var args = new ArgumentFileBuilder(); // JVM tuning flags to improve the performance of short-lived processes. args.add("-Xshare:auto"); @@ -153,7 +170,7 @@ private List resolveAndBuildArgLine( args.add(determineMainClass(plugin, dependencies.get(0))); - return Collections.unmodifiableList(args); + return args; } private String determineMainClass(MavenProtocPlugin plugin, Path pluginPath) throws IOException { @@ -227,82 +244,81 @@ private String buildJavaPath(Iterable iterable) { return sb.toString(); } - private String pluginIdDigest(MavenProtocPlugin plugin) { - return Digests.sha1(plugin.toString()); + private List findJavaModules(List paths) { + // TODO: is using a module finder here an overkill? + return ModuleFinder.of(paths.toArray(Path[]::new)) + .findAll() + .stream() + .map(ModuleReference::location) + .flatMap(Optional::stream) + .map(Path::of) + .map(FileUtils::normalize) + .peek(modulePath -> log.debug("Looks like {} is a JPMS module!", modulePath)) + // Sort as the order of output is arbitrary, and this ensures reproducible builds. + .sorted(Comparator.comparing(Path::toString)) + .collect(Collectors.toUnmodifiableList()); } - private Path resolvePluginScriptPath() { - return temporarySpace.createTemporarySpace("plugins", "jvm"); - } + private Path writePosixScripts( + String id, + Path javaExecutable, + Path scratchDir, + ArgumentFileBuilder argumentFileBuilder + ) throws IOException, ResolutionException { + var sh = pathResolver.resolve("sh").orElseThrow(); + var argLineFile = writeArgLineFile(id, UTF_8, scratchDir, argumentFileBuilder); + var file = scratchDir.resolve("invoke.sh"); - private Path writeWindowsBatchScript( - String pluginId, - List argLine - ) throws IOException { - var fullScriptPath = resolvePluginScriptPath().resolve(pluginId + ".bat"); - - var script = String.join( - "\r\n", - "@echo off", - "", - ":: ##################################################", - ":: ### Generated by ascopes/protobuf-maven-plugin ###", - ":: ### Users should not invoke this script ###", - ":: ### directly, unless they know what they are ###", - ":: ### doing. ###", - ":: ##################################################", - "", - Shlex.quoteBatchArgs(argLine), - "" // Trailing newline. - ); + try (var writer = Files.newBufferedWriter(file, UTF_8, CREATE_NEW)) { + writer.write("#!"); + writer.write(sh.toString()); + writer.write("\n"); - writeScript(fullScriptPath, script, StandardCharsets.ISO_8859_1); - return fullScriptPath; + writer.write("set -o errexit"); + writer.write("\n"); + + writer.write(Shlex.quoteShellArgs(List.of(javaExecutable.toString(), "@" + argLineFile))); + writer.write("\n"); + } + FileUtils.makeExecutable(file); + + return file; } - private Path writeShellScript( - String pluginId, - List argLine + private Path writeWindowsScripts( + String id, + Path javaExecutable, + Path scratchDir, + ArgumentFileBuilder argumentFileBuilder ) throws IOException { - var fullScriptPath = resolvePluginScriptPath().resolve(pluginId + ".sh"); - - var script = String.join( - "\n", - "#!/usr/bin/env sh", - "", - "##################################################", - "### Generated by ascopes/protobuf-maven-plugin ###", - "### Users should not invoke this script ###", - "### directly unless they know what they are ###", - "### doing. ###", - "##################################################", - "", - "set -eu", - "", - Shlex.quoteShellArgs(argLine), - "" // Trailing newline - ); + var argLineFile = writeArgLineFile(id, ISO_8859_1, scratchDir, argumentFileBuilder); + var file = scratchDir.resolve("invoke.bat"); + + try (var writer = Files.newBufferedWriter(file, ISO_8859_1, CREATE_NEW)) { + writer.write("@echo off"); + writer.write("\r\n"); - writeScript(fullScriptPath, script, StandardCharsets.UTF_8); - return fullScriptPath; + writer.write(Shlex.quoteBatchArgs(List.of(javaExecutable.toString(), "@" + argLineFile))); + writer.write("\r\n"); + } + + return file; } - private void writeScript(Path path, String content, Charset charset) throws IOException { - log.debug("Writing the following script to {} as {}:\n{}", path, charset, content); - Files.writeString(path, content, charset); - FileUtils.makeExecutable(path); + private Path writeArgLineFile( + String id, + Charset charset, + Path scratchDir, + ArgumentFileBuilder argumentFileBuilder + ) throws IOException { + var file = scratchDir.resolve("args.txt"); + try (var writer = Files.newBufferedWriter(file, charset, CREATE_NEW)) { + argumentFileBuilder.write(writer); + } + return file; } - private List findJavaModules(List paths) { - // TODO: is using a module finder here an overkill? - return ModuleFinder.of(paths.toArray(Path[]::new)) - .findAll() - .stream() - .map(ModuleReference::location) - .flatMap(Optional::stream) - .map(Path::of) - .map(FileUtils::normalize) - .peek(modulePath -> log.debug("Looks like {} is a JPMS module!", modulePath)) - .collect(Collectors.toUnmodifiableList()); + private String hashPlugin(MavenProtocPlugin plugin) { + return Digests.sha1(plugin.toString()); } } diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/protoc/ProtocResolver.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/protoc/ProtocResolver.java index 9fe8f393..f9fde9b4 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/protoc/ProtocResolver.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/protoc/ProtocResolver.java @@ -20,10 +20,10 @@ import io.github.ascopes.protobufmavenplugin.dependencies.MavenArtifactPathResolver; import io.github.ascopes.protobufmavenplugin.dependencies.PlatformClassifierFactory; import io.github.ascopes.protobufmavenplugin.dependencies.ResolutionException; -import io.github.ascopes.protobufmavenplugin.dependencies.SystemPathBinaryResolver; import io.github.ascopes.protobufmavenplugin.dependencies.UrlResourceFetcher; import io.github.ascopes.protobufmavenplugin.utils.FileUtils; import io.github.ascopes.protobufmavenplugin.utils.HostSystem; +import io.github.ascopes.protobufmavenplugin.utils.SystemPathBinaryResolver; import java.io.IOException; import java.net.URL; import java.nio.file.Path; diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilder.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilder.java new file mode 100644 index 00000000..6eed0bbd --- /dev/null +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilder.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2023 - 2024, Ashley Scopes. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.ascopes.protobufmavenplugin.utils; + +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.List; + +/** + * Builder for Java argument files that deals with the quoting and escaping rules Java expects. + * + *

See + * https://github.com/openjdk/jdk/blob/2461263aac35b25e2a48b6fc84da49e4b553dbc3/src/java.base/share/native/libjli/args.c#L165-L355 + * for the Java implementation. + * + * @author Ashley Scopes + * @since 2.6.0 + */ +@SuppressWarnings("JavadocLinkAsPlainText") +public final class ArgumentFileBuilder { + private final List arguments; + + public ArgumentFileBuilder() { + arguments = new ArrayList<>(); + } + + public ArgumentFileBuilder add(Object argument) { + arguments.add(argument.toString()); + return this; + } + + public void write(Writer writer) throws IOException { + for (var argument : arguments) { + if (argument.chars().noneMatch(c -> " \n\r\t'\"".indexOf(c) >= 0)) { + writer.append(argument).append("\n"); + continue; + } + + writer.append('"'); + for (var i = 0; i < argument.length(); ++i) { + var nextChar = argument.charAt(i); + switch (nextChar) { + case '"': + writer.append("\\\""); + break; + case '\'': + writer.append("\\'"); + break; + case '\\': + writer.append("\\\\"); + break; + case '\n': + writer.append("\\n"); + break; + case '\r': + writer.append("\\r"); + break; + case '\t': + writer.append("\\t"); + break; + default: + writer.append(nextChar); + break; + } + } + writer.append("\"\n"); + } + } +} diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/Shlex.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/Shlex.java index 82a50359..e660a620 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/Shlex.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/Shlex.java @@ -30,6 +30,11 @@ * *

Long lines will be split up with line continuations. * + *

I'd eventually like to totally get rid of this class if at all possible. It is (mostly) + * superseded by {@link ArgumentFileBuilder} which enables packaging arguments in a + * platform-agnostic way, but still requires this class to deal with quoting the generated scripts + * referencing those files (albeit with a lower risk of error). + * * @author Ashley Scopes */ public final class Shlex { diff --git a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/dependencies/SystemPathBinaryResolver.java b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/SystemPathBinaryResolver.java similarity index 94% rename from protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/dependencies/SystemPathBinaryResolver.java rename to protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/SystemPathBinaryResolver.java index 48764dca..47c8267a 100644 --- a/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/dependencies/SystemPathBinaryResolver.java +++ b/protobuf-maven-plugin/src/main/java/io/github/ascopes/protobufmavenplugin/utils/SystemPathBinaryResolver.java @@ -14,10 +14,9 @@ * limitations under the License. */ -package io.github.ascopes.protobufmavenplugin.dependencies; +package io.github.ascopes.protobufmavenplugin.utils; -import io.github.ascopes.protobufmavenplugin.utils.FileUtils; -import io.github.ascopes.protobufmavenplugin.utils.HostSystem; +import io.github.ascopes.protobufmavenplugin.dependencies.ResolutionException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; diff --git a/protobuf-maven-plugin/src/test/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilderTest.java b/protobuf-maven-plugin/src/test/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilderTest.java new file mode 100644 index 00000000..1563fe6a --- /dev/null +++ b/protobuf-maven-plugin/src/test/java/io/github/ascopes/protobufmavenplugin/utils/ArgumentFileBuilderTest.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2023 - 2024, Ashley Scopes. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.github.ascopes.protobufmavenplugin.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("ArgumentFileBuilder tests") +class ArgumentFileBuilderTest { + + @DisplayName("Arguments are converted to a string argument file in the expected format") + @MethodSource("argumentFileCases") + @ParameterizedTest(name = "for argument list {0}") + void argumentsAreConvertedToStringArgumentFileInExpectedFormat( + List givenArguments, + String expectedResult + ) throws IOException { + // Given + var builder = new ArgumentFileBuilder(); + + // When + for (var argument : givenArguments) { + builder.add(argument); + } + + String actualResult; + try (var stringWriter = new StringWriter()) { + builder.write(stringWriter); + actualResult = stringWriter.toString(); + } + + // Then + assertThat(actualResult).isEqualTo(expectedResult); + } + + static Stream argumentFileCases() { + return Stream.of( + // No arguments + arguments( + List.of(), + "" + ), + // Single basic string + arguments( + List.of("-Xmx300m"), + lines("-Xmx300m") + ), + // Complex basic string + arguments( + List.of("-Xms100m", "-Xmx1G", "-XX:+UseZGC", "-ea", "org.example.GreetMe", "Bob", "Jo"), + lines("-Xms100m", "-Xmx1G", "-XX:+UseZGC", "-ea", "org.example.GreetMe", "Bob", "Jo") + ), + // Arguments containing special whitespace characters + arguments( + List.of("start", "foo foo", "bar\r\nbar", "thing", "baz\tbaz", "end"), + lines("start", "\"foo foo\"", "\"bar\\r\\nbar\"", "thing", "\"baz\\tbaz\"", "end") + ), + // Escaping of escape sequences + arguments( + List.of("escaping-the-escape-sequence", "foo \\n bar"), + lines("escaping-the-escape-sequence", "\"foo \\\\n bar\"") + ), + // Escaping of single quotes + arguments( + List.of("xxx", "who'se'n't does this?", "yyy"), + lines("xxx", "\"who\\'se\\'n\\'t does this?\"", "yyy") + ), + // Escaping of double quotes + arguments( + List.of("xxx", "who\"se\"n\"t does this?", "yyy"), + lines("xxx", "\"who\\\"se\\\"n\\\"t does this?\"", "yyy") + ), + // Arguments containing non-string characters + arguments( + List.of(69, 420, "Lol"), + lines("69", "420", "Lol") + ) + ); + } + + static String lines(String... lines) { + return String.join("\n", lines) + "\n"; + } +}