diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorApplyRemediationsCliSupport.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorApplyRemediationsCliSupport.java index cc066a3b33c..b7ffa17a161 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorApplyRemediationsCliSupport.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/_common/util/AviatorApplyRemediationsCliSupport.java @@ -16,6 +16,7 @@ import java.util.Set; import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.util.WindowsPathValidator; /** * Shared option validation for SSC/FoD apply-remediations commands (CLI surface only). @@ -26,6 +27,7 @@ private AviatorApplyRemediationsCliSupport() {} public static void requireSourceDir(String sourceCodeDirectory) { FcliSimpleException.throwIf(sourceCodeDirectory == null || sourceCodeDirectory.isBlank(), "--source-dir must specify a valid directory path"); + WindowsPathValidator.validate("--source-dir", sourceCodeDirectory); } /** diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java index ce625fb26fb..a1a43a86c90 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommand.java @@ -30,6 +30,7 @@ import com.fortify.cli.aviator.ssc.cli.mixin.SSCRemediationsSelectionMode; import com.fortify.cli.aviator.ssc.helper.SinceOptionHelper; import com.fortify.cli.common.cli.mixin.CommonOptionMixins; +import com.fortify.cli.common.cli.util.WindowsFileConverter; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -53,7 +54,8 @@ public class AviatorSSCDownloadRemediationsCacheCommand extends AbstractSSCJsonN @Mixin private AviatorSSCRemediationsCacheDownloadSelectorMixin artifactSelector; @Mixin private CommonOptionMixins.RequireConfirmation requireConfirmation; - @Option(names = {"-f", "--file"}, required = true, paramLabel = "") + @Option(names = {"-f", "--file"}, required = true, paramLabel = "", + converter = WindowsFileConverter.class) private File outputFile; @Override diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java index bd002c31ee8..5c1e0a29c6d 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/mixin/AviatorSSCApplyRemediationsSourceMixin.java @@ -15,6 +15,7 @@ import java.nio.file.Path; import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCRemediationsSelectorArgGroups.OnlineSelectionArgGroup; +import com.fortify.cli.common.cli.util.WindowsPathConverter; import com.fortify.cli.common.exception.FcliSimpleException; import lombok.Getter; @@ -38,7 +39,8 @@ public static class SourceArgGroup { /** Shared/arg-group option: keep descriptionKey (default picocli key uses FQCN). */ @Option(names = {"--from-cache"}, required = true, paramLabel = "", - descriptionKey = "fcli.aviator.ssc.apply-remediations.from-cache") + descriptionKey = "fcli.aviator.ssc.apply-remediations.from-cache", + converter = WindowsPathConverter.class) private Path fromCache; } diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java index 4e2b9608c0b..851f6151867 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -20,6 +20,8 @@ import java.nio.file.Path; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import com.fortify.cli.aviator.ssc.cli.mixin.AviatorSSCApplyRemediationsSourceMixin; import com.fortify.cli.common.exception.FcliSimpleException; @@ -50,6 +52,22 @@ void issueIdsRequireFromCache() { assertThrows(FcliSimpleException.class, command::getJsonNode); } + @Test + @EnabledOnOs(OS.WINDOWS) + void sourceDirRejectsCorruptionMarker() { + AviatorSSCApplyRemediationsCommand command = parse( + "--artifact-id", "1", "--source-dir", "C:\\temp\\?\\source"); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void fromCacheRejectsCorruptionMarker() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--from-cache", "C:\\temp\\?\\cache.zip")); + } + private static AviatorSSCApplyRemediationsCommand parse(String... args) { AviatorSSCApplyRemediationsCommand command = new AviatorSSCApplyRemediationsCommand(); new CommandLine(command).parseArgs(args); diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java index 9d14f52efb3..7dbc5896ff6 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCDownloadRemediationsCacheCommandTest.java @@ -15,6 +15,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import com.fortify.cli.common.exception.FcliSimpleException; @@ -40,6 +42,13 @@ void testArtifactIdRejectsAppVersion() { assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null)); } + @Test + @EnabledOnOs(OS.WINDOWS) + void fileRejectsCorruptionMarker() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--artifact-id", "1", "-f", "C:\\temp\\?\\cache.zip")); + } + private static AviatorSSCDownloadRemediationsCacheCommand parse(String... args) { AviatorSSCDownloadRemediationsCacheCommand command = new AviatorSSCDownloadRemediationsCacheCommand(); new CommandLine(command).parseArgs(args); diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/cli/util/WindowsFileConverter.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/cli/util/WindowsFileConverter.java new file mode 100644 index 00000000000..53d89897bbb --- /dev/null +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/cli/util/WindowsFileConverter.java @@ -0,0 +1,26 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.cli.util; + +import java.io.File; + +import com.fortify.cli.common.util.WindowsPathValidator; + +import picocli.CommandLine.ITypeConverter; + +public final class WindowsFileConverter implements ITypeConverter { + @Override + public File convert(String value) { + return WindowsPathValidator.toPath("The supplied path", value).toFile(); + } +} diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/cli/util/WindowsPathConverter.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/cli/util/WindowsPathConverter.java new file mode 100644 index 00000000000..c59ee589597 --- /dev/null +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/cli/util/WindowsPathConverter.java @@ -0,0 +1,26 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.cli.util; + +import java.nio.file.Path; + +import com.fortify.cli.common.util.WindowsPathValidator; + +import picocli.CommandLine.ITypeConverter; + +public final class WindowsPathConverter implements ITypeConverter { + @Override + public Path convert(String value) { + return WindowsPathValidator.toPath("The supplied path", value); + } +} diff --git a/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/WindowsPathValidator.java b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/WindowsPathValidator.java new file mode 100644 index 00000000000..5e591250b9a --- /dev/null +++ b/fcli-core/fcli-common-core/src/main/java/com/fortify/cli/common/util/WindowsPathValidator.java @@ -0,0 +1,51 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.util; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; + +import com.fortify.cli.common.exception.FcliSimpleException; + +/** Validates path values that may have been corrupted by Windows command-line decoding. */ +public final class WindowsPathValidator { + private static final char REPLACEMENT_CHARACTER = '\uFFFD'; + + private WindowsPathValidator() {} + + public static boolean hasUnsupportedCharacters(String value) { + return PlatformHelper.isWindows() + && value != null + && (value.indexOf('?') >= 0 || value.indexOf(REPLACEMENT_CHARACTER) >= 0); + } + + public static void validate(String optionName, String value) { + FcliSimpleException.throwIf( + hasUnsupportedCharacters(value), + "%s contains '?' or the Unicode replacement character (U+FFFD). " + + "This may indicate corruption during Windows command-line decoding; " + + "please rerun the command with a valid Windows path", + optionName); + } + + public static Path toPath(String optionName, String value) { + validate(optionName, value); + try { + return Path.of(value); + } catch (InvalidPathException e) { + throw new FcliSimpleException( + "%s is not a valid file path on this platform; please provide a supported file path", + optionName); + } + } +} diff --git a/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/util/WindowsPathValidatorTest.java b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/util/WindowsPathValidatorTest.java new file mode 100644 index 00000000000..fc33e661628 --- /dev/null +++ b/fcli-core/fcli-common-core/src/test/java/com/fortify/cli/common/util/WindowsPathValidatorTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2021-2026 Open Text. + * + * The only warranties for products and services of Open Text + * and its affiliates and licensors ("Open Text") are as may + * be set forth in the express warranty statements accompanying + * such products and services. Nothing herein should be construed + * as constituting an additional warranty. Open Text shall not be + * liable for technical or editorial errors or omissions contained + * herein. The information contained herein is subject to change + * without notice. + */ +package com.fortify.cli.common.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import com.fortify.cli.common.cli.util.WindowsFileConverter; +import com.fortify.cli.common.cli.util.WindowsPathConverter; +import com.fortify.cli.common.exception.FcliSimpleException; + +class WindowsPathValidatorTest { + private static final String CORRUPTED_PATH = "C:\\temp\\?\\source"; + private static final String REPLACEMENT_PATH = "C:\\temp\\\uFFFD\\source"; + + @Test + @EnabledOnOs(OS.WINDOWS) + void rejectsQuestionMarkPath() { + assertTrue(WindowsPathValidator.hasUnsupportedCharacters(CORRUPTED_PATH)); + assertThrows(FcliSimpleException.class, () -> new WindowsPathConverter().convert(CORRUPTED_PATH)); + assertThrows(FcliSimpleException.class, () -> new WindowsFileConverter().convert(CORRUPTED_PATH)); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void rejectsReplacementCharacterPath() { + assertTrue(WindowsPathValidator.hasUnsupportedCharacters(REPLACEMENT_PATH)); + FcliSimpleException exception = assertThrows( + FcliSimpleException.class, () -> WindowsPathValidator.validate("--source-dir", REPLACEMENT_PATH)); + assertTrue(exception.getMessage().contains("Windows command-line decoding")); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void acceptsUnicodePathWithoutCorruptionMarkers() { + assertDoesNotThrow(() -> WindowsPathValidator.validate("--source-dir", "C:\\temp\\Fortify-test\\source")); + } +} diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java index d37186bce89..d98d5acd30a 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cli/mixin/FoDAviatorApplyRemediationsSourceMixin.java @@ -14,6 +14,7 @@ import java.nio.file.Path; +import com.fortify.cli.common.cli.util.WindowsPathConverter; import com.fortify.cli.fod._common.cli.mixin.FoDDelimiterMixin; import com.fortify.cli.fod._common.cli.mixin.IFoDDelimiterMixinAware; import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; @@ -59,7 +60,8 @@ static class SourceArgGroup { /** Shared description key: command-local option on an ArgGroup (default picocli key would use FQCN). */ @Option(names = {"--from-cache"}, required = true, paramLabel = "", - descriptionKey = "fcli.fod.aviator.apply-remediations.from-cache") + descriptionKey = "fcli.fod.aviator.apply-remediations.from-cache", + converter = WindowsPathConverter.class) private Path fromCache; } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java index 87c4688d5f0..6b5a7979aff 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/aviator/cmd/FoDAviatorDownloadRemediationsCacheCommand.java @@ -25,6 +25,7 @@ import com.fortify.cli.aviator._common.remediations_cache.RemediationsCacheWriter; import com.fortify.cli.aviator.config.AviatorLoggerImpl; import com.fortify.cli.common.cli.mixin.CommonOptionMixins; +import com.fortify.cli.common.cli.util.WindowsFileConverter; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; @@ -50,7 +51,8 @@ public class FoDAviatorDownloadRemediationsCacheCommand extends AbstractFoDJsonN @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; @Mixin private CommonOptionMixins.RequireConfirmation requireConfirmation; - @Option(names = {"-f", "--file"}, required = true, paramLabel = "") + @Option(names = {"-f", "--file"}, required = true, paramLabel = "", + converter = WindowsFileConverter.class) private File outputFile; @Override diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java index f1dedd1956e..0a1098030c8 100644 --- a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorApplyRemediationsCommandTest.java @@ -20,6 +20,8 @@ import java.nio.file.Path; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.fod.aviator.cli.mixin.FoDAviatorApplyRemediationsSourceMixin; @@ -64,6 +66,22 @@ void blankSourceDirIsRejected() throws Exception { assertThrows(FcliSimpleException.class, command::getJsonNode); } + @Test + @EnabledOnOs(OS.WINDOWS) + void sourceDirRejectsCorruptionMarker() { + FoDAviatorApplyRemediationsCommand command = parse( + "--release", "1", "--source-dir", "C:\\temp\\?\\source"); + + assertThrows(FcliSimpleException.class, command::getJsonNode); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void fromCacheRejectsCorruptionMarker() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--from-cache", "C:\\temp\\?\\cache.zip")); + } + private static FoDAviatorApplyRemediationsCommand parse(String... args) { FoDAviatorApplyRemediationsCommand command = new FoDAviatorApplyRemediationsCommand(); new CommandLine(command).parseArgs(args); diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java index 37b9dde1028..7d5d99dfc13 100644 --- a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/aviator/FoDAviatorDownloadRemediationsCacheCommandTest.java @@ -15,6 +15,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; import com.fortify.cli.fod.aviator.cmd.FoDAviatorDownloadRemediationsCacheCommand; @@ -33,6 +35,13 @@ void fileIsRequired() { () -> parse("--release", "1")); } + @Test + @EnabledOnOs(OS.WINDOWS) + void fileRejectsCorruptionMarker() { + assertThrows(CommandLine.ParameterException.class, + () -> parse("--release", "1", "-f", "C:\\temp\\?\\cache.zip")); + } + private static void parse(String... args) { new CommandLine(new FoDAviatorDownloadRemediationsCacheCommand()).parseArgs(args); }