diff --git a/fcli-core/fcli-app/build.gradle.kts b/fcli-core/fcli-app/build.gradle.kts index 12c38c2a221..aed1897c733 100644 --- a/fcli-core/fcli-app/build.gradle.kts +++ b/fcli-core/fcli-app/build.gradle.kts @@ -21,6 +21,7 @@ references@ for (r in refs) { dependencies { runtimeOnly("org.slf4j:jcl-over-slf4j") runtimeOnly("org.fusesource.jansi:jansi") + implementation("net.java.dev.jna:jna") annotationProcessor("info.picocli:picocli-codegen") } diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/FortifyCLI.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/FortifyCLI.java index f2ab435cb79..64357947ccf 100644 --- a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/FortifyCLI.java +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/FortifyCLI.java @@ -13,6 +13,7 @@ package com.fortify.cli.app; import com.fortify.cli.app.runner.DefaultFortifyCLIRunner; +import com.fortify.cli.app.runner.util.WindowsCommandLineArgs; import com.fortify.cli.common.util.ConsoleHelper; /** @@ -28,7 +29,7 @@ public class FortifyCLI { * @param args Command line options passed to Fortify CLI */ public static final void main(String[] args) { - System.exit(execute(args)); + System.exit(execute(WindowsCommandLineArgs.fixIfNeeded(args))); } private static final int execute(String[] args) { diff --git a/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/WindowsCommandLineArgs.java b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/WindowsCommandLineArgs.java new file mode 100644 index 00000000000..3c1653d03fb --- /dev/null +++ b/fcli-core/fcli-app/src/main/java/com/fortify/cli/app/runner/util/WindowsCommandLineArgs.java @@ -0,0 +1,195 @@ +/* + * 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.app.runner.util; + +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Locale; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.WString; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.win32.W32APIOptions; + +/** + * Restores Windows command-line arguments that were corrupted by lossy + * encoding of characters outside the active ANSI code page. + * + *
On Windows, {@code java -jar} (and some Graal native images) can replace + * non-ANSI code points in argv with {@code '?'} or U+FFFD before + * {@code main} runs. That breaks path options such as {@code --from-cache}, + * {@code -f}, and {@code --source-dir} for any script not covered by the + * process code page (CJK, Arabic, Cyrillic, emoji, etc.).
+ * + *This helper re-reads the process command line via + * {@code GetCommandLineW} / {@code CommandLineToArgvW} and replaces only + * tokens that look like a lossy form of the wide originals. It is a no-op on + * non-Windows, when re-parse fails or token counts differ, and never throws. + * On systems that already deliver correct argv (including UTF-8 + * {@code sun.jnu.encoding}), merge is a no-op because tokens match the wide + * form.
+ */ +public final class WindowsCommandLineArgs { + private static final Logger LOG = LoggerFactory.getLogger(WindowsCommandLineArgs.class); + private static final char REPLACEMENT = '\uFFFD'; + + private WindowsCommandLineArgs() {} + + /** + * Return {@code args} with Unicode restored where a wide re-parse shows + * lossy corruption; otherwise return {@code args} unchanged. + */ + public static String[] fixIfNeeded(String[] args) { + if (args == null || args.length == 0) { + return args; + } + if (!isWindows()) { + return args; + } + if (!hasPotentialWindowsArgCorruption(args)) { + return args; + } + try { + String[] wideAppArgs = readWideApplicationArgs(args.length); + if (wideAppArgs == null || wideAppArgs.length != args.length) { + return args; + } + return mergeCorruptedArgs(args, wideAppArgs, jnuCharset()); + } catch (Throwable t) { + LOG.debug("Windows wide argv recovery skipped: {}", t.toString()); + return args; + } + } + + static boolean hasPotentialWindowsArgCorruption(String[] args) { + if (args == null) { + return false; + } + for (String arg : args) { + if (arg != null && (arg.indexOf('?') >= 0 || arg.indexOf(REPLACEMENT) >= 0)) { + return true; + } + } + return false; + } + + static String[] mergeCorruptedArgs(String[] jvmArgs, String[] wideArgs, Charset jnu) { + String[] result = Arrays.copyOf(jvmArgs, jvmArgs.length); + boolean changed = false; + for (int i = 0; i < jvmArgs.length; i++) { + if (isLikelyCorruptedArg(jvmArgs[i], wideArgs[i], jnu)) { + result[i] = wideArgs[i]; + changed = true; + } + } + if (changed) { + LOG.debug("Restored Unicode command-line argument(s) via Windows wide API"); + } + return result; + } + + static boolean isLikelyCorruptedArg(String jvmArg, String wideArg, Charset jnu) { + if (jvmArg == null || wideArg == null || jvmArg.equals(wideArg)) { + return false; + } + return isLikelyJnuCorruption(jvmArg, wideArg, jnu) + || isLikelyReplacementCharCorruption(jvmArg, wideArg); + } + + static boolean isLikelyJnuCorruption(String jvmArg, String wideArg, Charset jnu) { + if (jvmArg == null || wideArg == null || jvmArg.equals(wideArg)) { + return false; + } + String roundTrip = new String(wideArg.getBytes(jnu), jnu); + return jvmArg.equals(roundTrip); + } + + static boolean isLikelyReplacementCharCorruption(String jvmArg, String wideArg) { + if (jvmArg == null || wideArg == null || jvmArg.length() != wideArg.length()) { + return false; + } + if (jvmArg.indexOf(REPLACEMENT) < 0) { + return false; + } + for (int i = 0; i < wideArg.length(); i++) { + char w = wideArg.charAt(i); + char j = jvmArg.charAt(i); + char expected = w <= 0x7F ? w : REPLACEMENT; + if (j != expected) { + return false; + } + } + return true; + } + + static boolean isWindows() { + String os = System.getProperty("os.name", ""); + return os.toLowerCase(Locale.ROOT).contains("win"); + } + + static Charset jnuCharset() { + String name = System.getProperty("sun.jnu.encoding"); + if (name == null || name.isBlank()) { + return Charset.defaultCharset(); + } + try { + return Charset.forName(name); + } catch (Exception e) { + return Charset.defaultCharset(); + } + } + + private static String[] readWideApplicationArgs(int appArgCount) { + if (appArgCount <= 0) { + return new String[0]; + } + IntByReference argc = new IntByReference(); + WString cmdLine = Kernel32.INSTANCE.GetCommandLineW(); + if (cmdLine == null) { + return null; + } + Pointer argv = Shell32.INSTANCE.CommandLineToArgvW(cmdLine, argc); + if (argv == null) { + return null; + } + try { + int n = argc.getValue(); + if (n < appArgCount) { + return null; + } + String[] all = argv.getWideStringArray(0, n); + return Arrays.copyOfRange(all, all.length - appArgCount, all.length); + } finally { + Kernel32.INSTANCE.LocalFree(argv); + } + } + + private interface Kernel32 extends Library { + Kernel32 INSTANCE = Native.load("kernel32", Kernel32.class, W32APIOptions.DEFAULT_OPTIONS); + + WString GetCommandLineW(); + + Pointer LocalFree(Pointer hMem); + } + + private interface Shell32 extends Library { + Shell32 INSTANCE = Native.load("shell32", Shell32.class, W32APIOptions.DEFAULT_OPTIONS); + + Pointer CommandLineToArgvW(WString lpCmdLine, IntByReference pNumArgs); + } +} diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jna/jni-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jna/jni-config.json new file mode 100644 index 00000000000..ac3c6ec043b --- /dev/null +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jna/jni-config.json @@ -0,0 +1,44 @@ +[ + { + "name": "com.sun.jna.Native", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.sun.jna.Pointer", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.sun.jna.Structure", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.sun.jna.Callback", + "allDeclaredMethods": true, + "allPublicMethods": true + }, + { + "name": "java.lang.Class", + "allDeclaredMethods": true, + "allPublicMethods": true + }, + { + "name": "java.lang.reflect.Method", + "allDeclaredMethods": true, + "allPublicMethods": true + } +] diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jna/reflect-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jna/reflect-config.json new file mode 100644 index 00000000000..111e07ddd3b --- /dev/null +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jna/reflect-config.json @@ -0,0 +1,78 @@ +[ + { + "name": "com.sun.jna.CallbackReference", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true + }, + { + "name": "com.sun.jna.Klass", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true + }, + { + "name": "com.sun.jna.Native", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.sun.jna.Structure", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.sun.jna.Structure$FFIType$size_t", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.sun.jna.Pointer", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.sun.jna.WString", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true + }, + { + "name": "com.sun.jna.ptr.IntByReference", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allDeclaredFields": true, + "allPublicFields": true + }, + { + "name": "com.fortify.cli.app.runner.util.WindowsCommandLineArgs$Kernel32", + "allDeclaredMethods": true, + "allPublicMethods": true + }, + { + "name": "com.fortify.cli.app.runner.util.WindowsCommandLineArgs$Shell32", + "allDeclaredMethods": true, + "allPublicMethods": true + } +] diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jna/resource-config.json b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jna/resource-config.json new file mode 100644 index 00000000000..3242c92f0d4 --- /dev/null +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/jna/resource-config.json @@ -0,0 +1,15 @@ +{ + "resources": { + "includes": [ + { + "pattern": "\\Qcom/sun/jna/win32-x86-64/jnidispatch.dll\\E" + }, + { + "pattern": "\\Qcom/sun/jna/win32-x86/jnidispatch.dll\\E" + }, + { + "pattern": "\\Qcom/sun/jna/win32-aarch64/jnidispatch.dll\\E" + } + ] + } +} diff --git a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/static/native-image.properties b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/static/native-image.properties index 5ca4a2db913..41768f1dca8 100644 --- a/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/static/native-image.properties +++ b/fcli-core/fcli-app/src/main/resources/META-INF/native-image/fcli/fcli-app/static/native-image.properties @@ -4,6 +4,7 @@ Args=--enable-http --enable-https \ --initialize-at-run-time=io.grpc.netty.shaded.io.netty \ --initialize-at-run-time=ch.qos.logback \ --initialize-at-run-time=org.slf4j \ + --initialize-at-run-time=com.sun.jna \ --initialize-at-build-time=org.codehaus.stax2.typed.Base64Variants,org.codehaus.stax2.typed.Base64Variant \ --enable-native-access=ALL-UNNAMED \ -H:+IncludeAllLocales \ diff --git a/fcli-core/fcli-app/src/test/java/com/fortify/cli/app/runner/util/WindowsCommandLineArgsTest.java b/fcli-core/fcli-app/src/test/java/com/fortify/cli/app/runner/util/WindowsCommandLineArgsTest.java new file mode 100644 index 00000000000..0da0cc020bf --- /dev/null +++ b/fcli-core/fcli-app/src/test/java/com/fortify/cli/app/runner/util/WindowsCommandLineArgsTest.java @@ -0,0 +1,137 @@ +/* + * 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.app.runner.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.Charset; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; + +class WindowsCommandLineArgsTest { + + private static final Charset CP1252 = Charset.forName("windows-1252"); + private static final char REPLACEMENT = '\uFFFD'; + + @Test + void isLikelyJnuCorruptionDetectsUnmappablePath() { + String wide = "C:\\Users\\test\\\u4e2d\u6587\\\u0627\u0644\u0639\u0631\u0628\u064a\\cache.zip"; + String jvm = new String(wide.getBytes(CP1252), CP1252); + + assertTrue(jvm.contains("?")); + assertTrue(WindowsCommandLineArgs.isLikelyJnuCorruption(jvm, wide, CP1252)); + assertTrue(WindowsCommandLineArgs.isLikelyCorruptedArg(jvm, wide, CP1252)); + assertFalse(WindowsCommandLineArgs.isLikelyJnuCorruption(wide, wide, CP1252)); + assertFalse(WindowsCommandLineArgs.isLikelyJnuCorruption(jvm, "other", CP1252)); + } + + @Test + void isLikelyReplacementCharCorruptionDetectsNonAsciiLoss() { + String wide = "C:\\tmp\\\u4e2d\u6587\\\u0440\u0443\\cache.zip"; + String graal = replaceNonAscii(wide); + + assertTrue(graal.indexOf(REPLACEMENT) >= 0); + assertTrue(WindowsCommandLineArgs.isLikelyReplacementCharCorruption(graal, wide)); + assertTrue(WindowsCommandLineArgs.isLikelyCorruptedArg(graal, wide, CP1252)); + assertFalse(WindowsCommandLineArgs.isLikelyReplacementCharCorruption(wide, wide)); + assertFalse(WindowsCommandLineArgs.isLikelyReplacementCharCorruption("C:\\tmp\\??\\cache.zip", wide)); + } + + @Test + void mergeCorruptedArgsRestoresJnuCorruptedTokens() { + String widePath = "C:\\tmp\\\u65e5\u672c\u8a9e\\cache.zip"; + String jvmPath = new String(widePath.getBytes(CP1252), CP1252); + String[] jvm = {"aviator", "ssc", "apply-remediations", "--from-cache", jvmPath}; + String[] wide = {"aviator", "ssc", "apply-remediations", "--from-cache", widePath}; + + String[] fixed = WindowsCommandLineArgs.mergeCorruptedArgs(jvm, wide, CP1252); + + assertEquals(widePath, fixed[4]); + assertEquals("aviator", fixed[0]); + assertEquals("--from-cache", fixed[3]); + } + + @Test + void mergeCorruptedArgsRestoresReplacementCharTokens() { + String widePath = "C:\\tmp\\\u4e2d\u6587\\cache.zip"; + String[] jvm = {"aviator", "--from-cache", replaceNonAscii(widePath)}; + String[] wide = {"aviator", "--from-cache", widePath}; + + String[] fixed = WindowsCommandLineArgs.mergeCorruptedArgs(jvm, wide, CP1252); + + assertEquals(widePath, fixed[2]); + assertEquals("aviator", fixed[0]); + } + + @Test + void mergeCorruptedArgsKeepsJvmArgsWhenWideIsUnrelated() { + String[] jvm = {"aviator", "--from-cache", "C:\\plain\\cache.zip"}; + String[] wide = {"org.gradle.worker.internal.WorkerProcess", "something", "else"}; + + String[] fixed = WindowsCommandLineArgs.mergeCorruptedArgs(jvm, wide, CP1252); + + assertArrayEquals(jvm, fixed); + } + + @Test + void hasPotentialWindowsArgCorruptionReturnsFalseForPlainArgs() { + String[] args = {"--help", "aviator", "ssc", "apply-remediations"}; + + assertFalse(WindowsCommandLineArgs.hasPotentialWindowsArgCorruption(args)); + assertFalse(WindowsCommandLineArgs.hasPotentialWindowsArgCorruption(null)); + } + + @Test + void hasPotentialWindowsArgCorruptionDetectsLossMarkers() { + assertTrue(WindowsCommandLineArgs.hasPotentialWindowsArgCorruption(new String[] {"C:\\tmp\\??\\cache.zip"})); + assertTrue(WindowsCommandLineArgs.hasPotentialWindowsArgCorruption(new String[] {"C:\\tmp\\" + REPLACEMENT + "\\cache.zip"})); + } + + @Test + void fixIfNeededReturnsSameArrayForPlainArgsWithoutLoadingWideApi() { + String[] args = {"aviator", "ssc", "--help"}; + + assertSame(args, WindowsCommandLineArgs.fixIfNeeded(args)); + } + + @Test + void fixIfNeededReturnsSameArrayWhenEmpty() { + String[] empty = new String[0]; + assertSame(empty, WindowsCommandLineArgs.fixIfNeeded(empty)); + assertSame(null, WindowsCommandLineArgs.fixIfNeeded(null)); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void fixIfNeededDoesNotThrowOnWindows() { + String[] args = {"aviator", "ssc", "--help"}; + String[] fixed = WindowsCommandLineArgs.fixIfNeeded(args); + assertEquals(args.length, fixed.length); + assertArrayEquals(args, fixed); + } + + private static String replaceNonAscii(String wide) { + StringBuilder sb = new StringBuilder(wide.length()); + for (int i = 0; i < wide.length(); i++) { + char c = wide.charAt(i); + sb.append(c <= 0x7F ? c : REPLACEMENT); + } + return sb.toString(); + } +} diff --git a/fcli-other/fcli-bom/build.gradle.kts b/fcli-other/fcli-bom/build.gradle.kts index 53e12bf35bb..1aaa1785fa6 100644 --- a/fcli-other/fcli-bom/build.gradle.kts +++ b/fcli-other/fcli-bom/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { api("com.formkiq:graalvm-annotations:1.2.0") api("com.formkiq:graalvm-annotations-processor:1.5.2") api("org.fusesource.jansi:jansi:2.4.3") + api("net.java.dev.jna:jna:5.17.0") api("org.slf4j:slf4j-api:2.0.17") api("org.slf4j:jcl-over-slf4j:2.0.17") api("ch.qos.logback:logback-classic:1.5.23")