diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0a9d8f8bd..5791c3692a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,12 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true - + env: native_image_opts: --verbose -H:Log=registerResource:verbose -H:+PrintClassInitialization -march=compatibility graal_distribution: graalvm-community graal_java_version: 21 - + jobs: build: name: Build @@ -35,7 +35,7 @@ jobs: skip-github-pull-request: true target-branch: ${{ github.ref_name }} release-type: simple - + - name: PROD - Define release info if: steps.create_prod_release.outputs.release_created run: | @@ -43,7 +43,7 @@ jobs: version=${{steps.create_prod_release.outputs.version}} major=${{steps.create_prod_release.outputs.major}} minor=${{steps.create_prod_release.outputs.minor}} - patch=${{steps.create_prod_release.outputs.patch}} + patch=${{steps.create_prod_release.outputs.patch}} echo DO_BUILD=true >> $GITHUB_ENV echo DO_RELEASE=true >> $GITHUB_ENV echo DO_PROD_RELEASE=true >> $GITHUB_ENV @@ -52,7 +52,7 @@ jobs: echo VERSION_MAJOR=${major} >> $GITHUB_ENV echo VERSION_MINOR=${minor} >> $GITHUB_ENV echo VERSION_PATCH=${patch} >> $GITHUB_ENV - + - name: DEV - Define release info if: (startsWith(github.ref, 'refs/heads/') || github.event_name == 'pull_request') && !env.DO_PROD_RELEASE run: | @@ -76,29 +76,29 @@ jobs: echo VERSION_MAJOR=${major} >> $GITHUB_ENV echo VERSION_MINOR=${minor} >> $GITHUB_ENV echo VERSION_PATCH=${patch} >> $GITHUB_ENV - + if git ls-remote --exit-code origin refs/tags/${tag} >/dev/null 2>&1; then echo "Found tag ${tag}, development release will be published" echo DO_RELEASE=true >> $GITHUB_ENV echo DO_DEV_RELEASE=true >> $GITHUB_ENV - else + else echo "Tag ${tag} does not exist, no development release will be published" fi - + - name: Build release ${{env.RELEASE_VERSION}} if: env.DO_BUILD run: ./gradlew clean build dist distThirdPartyReleaseAsset distFtest -Pversion=${{env.RELEASE_VERSION}} -PautoFormat=false - + - name: Check fcli version if: env.DO_BUILD run: java -jar build/libs/fcli.jar --version | tee /dev/stderr | grep -E '[0-9]+\.[0-9]+\.[0-9]+' >/dev/null || (echo "fcli --version doesn't output proper version number"; exit 1) - + - name: Publish build artifacts uses: actions/upload-artifact@v4 with: name: build-output path: build/dist/**/* - + outputs: do_release: ${{ env.DO_RELEASE }} do_prod_release: ${{ env.DO_PROD_RELEASE }} @@ -128,30 +128,31 @@ jobs: java-version: ${{ env.graal_java_version }} native-image-musl: true github-token: ${{ secrets.GITHUB_TOKEN }} - + - uses: actions/download-artifact@v4 with: path: ./artifacts name: build-output - + # For Linux, we build a statically linked native image, to allow for building a 'FROM scratch' - # Docker image, and to avoid libc version issues. Since Jansi is not supported on statically - # linked images (see https://github.com/fusesource/jansi/issues/246), we set a system property - # to indicate that FortifyCLI shouldn't try to invoke AnsiConsole::systemInstall/Uninstall. In + # Docker image, and to avoid libc version issues. Since Jansi is not supported on statically + # linked images (see https://github.com/fusesource/jansi/issues/246), we set a system property + # to indicate that FortifyCLI shouldn't try to invoke AnsiConsole::systemInstall/Uninstall. In # order for FortifyCLI to be able to see this system property, we need to initialize this class - # at build time (see https://www.graalvm.org/22.1/reference-manual/native-image/Properties/). + # at build time (see https://www.graalvm.org/22.1/reference-manual/native-image/Properties/). # We also exclude the native Jansi library resources, as these are now no longer needed. - name: Create native fcli run: native-image ${{ env.native_image_opts }} --static --libc=musl -Djansi.disable=true --initialize-at-build-time=com.fortify.cli.common.util.JAnsiConfig -H:ExcludeResources="org/fusesource/jansi/internal/native/.*" -jar ./artifacts/release-assets/fcli.jar fcli - - name: Compress native fcli - uses: svenstaro/upx-action@v2 - with: - files: fcli - + run: | + if ! command -v upx >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y upx-ucl || sudo apt-get install -y upx + fi + upx --best --lzma fcli - name: Basic test of native fcli run: ./fcli --help && ./fcli get --help && ./fcli action help ci - + - name: Check fcli version run: ./fcli --version | tee /dev/stderr | grep -E '[0-9]+\.[0-9]+\.[0-9]+' >/dev/null || (echo "fcli --version doesn't output proper version number"; exit 1) @@ -183,14 +184,14 @@ jobs: name: build-output # For MacOS, we build a dynamically linked image. Jansi by default provides a resource-config.json - # file to include native libraries for all platforms; we override this to include only the MacOS + # file to include native libraries for all platforms; we override this to include only the MacOS # libraries - name: Create native fcli run: native-image ${{ env.native_image_opts }} -march=compatibility -H:ExcludeResources="org/fusesource/jansi/internal/native/Windows/.*" -H:ExcludeResources="org/fusesource/jansi/internal/native/Linux/.*" -H:ExcludeResources="org/fusesource/jansi/internal/native/FreeBSD/.*" -jar ./artifacts/release-assets/fcli.jar fcli # Disabled for now, as compressed binaries crash on macOS Ventura or above #- name: Compress native fcli - # uses: svenstaro/upx-action@v2 + # (UPX compression disabled on macOS) # with: # files: fcli @@ -220,7 +221,7 @@ jobs: with: path: ./artifacts name: build-output - + # For Windows, we build a dynamically linked image. Jansi by default provides a resource-config.json # file to include native libraries for all platforms; we override this to include only the 64-bit # Windows library @@ -233,13 +234,13 @@ jobs: # We don't compress the Windows binary for now as this is incompatible with current Graal version. # See https://github.com/fortify/fcli/issues/148 # - name: Compress native fcli -# uses: svenstaro/upx-action@v2 +# (UPX compression disabled on Windows) # with: # files: fcli.exe - name: Basic test of native fcli run: | - .\fcli.exe --help + .\fcli.exe --help .\fcli.exe get --help .\fcli.exe action help ci @@ -250,7 +251,7 @@ jobs: with: path: ./artifacts/**/fcli-windows.zip name: fcli-windows - + combine-artifacts: needs: [build, native_linux, native_mac, native_win] runs-on: ubuntu-latest @@ -262,13 +263,13 @@ jobs: merge-multiple: true - run: | cd ./artifacts/release-assets - for f in *; do + for f in *; do sha256sum ${f} > ${f}.sha256 done if [ -z "$SIGN_KEY" ] || [ -z "$SIGN_PASSPHRASE" ]; then echo "Signatures for fcli artifacts haven't been calculated; SIGN_KEY and/or SIGN_PASSPHRASE secrets not present" else - for f in *; do + for f in *; do openssl dgst -sha256 -passin env:SIGN_PASSPHRASE -sign <(echo "${SIGN_KEY}") -out ${f}.rsa_sha256 ${f} done fi @@ -279,7 +280,7 @@ jobs: with: path: ./artifacts name: combined-artifacts - + ftest: name: Trigger functional tests needs: [build, native_linux, native_mac, native_win, combine-artifacts] @@ -291,7 +292,7 @@ jobs: # allows for running the workflow file from the same branch as the ci workflow. - name: Check-out source code uses: actions/checkout@v4 - - run: | + - run: | # Use branch output from build job if available, otherwise fall back to GITHUB_REF if [ -n "${{ needs.build.outputs.branch }}" ]; then ref="${{ needs.build.outputs.branch }}" @@ -301,7 +302,7 @@ jobs: gh workflow run functional-tests.yml --ref "${ref}" -f runId=${GITHUB_RUN_ID} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + release: name: release if: needs.build.outputs.do_release @@ -310,13 +311,13 @@ jobs: steps: - name: Check-out source code uses: actions/checkout@v4 - + - name: Download artifacts uses: actions/download-artifact@v4 with: path: ./artifacts name: combined-artifacts - + - name: PROD - Prepare release PR if: contains(github.ref, 'refs/heads/rel/') uses: googleapis/release-please-action@v4 @@ -324,23 +325,24 @@ jobs: skip-github-release: true target-branch: ${{ github.ref_name }} release-type: simple - - - name: DEV - Prepare GitHub release + + - name: DEV - Prepare GitHub release if: needs.build.outputs.do_dev_release run: | gh release delete ${{ needs.build.outputs.release_tag }} -y || true gh release create ${{ needs.build.outputs.release_tag }} -p -t "Development Release - ${{ needs.build.outputs.branch }} branch" -n 'See `Assets` section below for latest build artifacts' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: DEV - Update ${{ needs.build.outputs.release_tag }} tag - uses: richardsimko/update-tag@v1 if: needs.build.outputs.do_dev_release - with: - tag_name: ${{ needs.build.outputs.release_tag }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -fa "${{ needs.build.outputs.release_tag }}" -m "Update tag to ${GITHUB_SHA}" + git push origin "refs/tags/${{ needs.build.outputs.release_tag }}" --force + - name: Upload assets to release if: needs.build.outputs.do_release run: | @@ -348,14 +350,14 @@ jobs: gh release upload "${{ needs.build.outputs.release_tag }}" $files --clobber env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + semantic_versions: name: Publish semantic versions if: needs.build.outputs.do_prod_release needs: [build, native_linux, native_mac, native_win, combine-artifacts, release] strategy: - matrix: - semantic_version: + matrix: + semantic_version: - v${{needs.build.outputs.version_major}}.${{needs.build.outputs.version_minor}} - v${{needs.build.outputs.version_major}} # TODO If we publish a feature/patch for an older major version, @@ -366,20 +368,20 @@ jobs: steps: - name: Check-out source code uses: actions/checkout@v4 - + - name: Download artifacts uses: actions/download-artifact@v4 with: path: ./artifacts name: combined-artifacts - - name: PROD - Publish ${{ matrix.semantic_version }} tag - uses: richardsimko/update-tag@v1 - with: - tag_name: ${{ matrix.semantic_version }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -fa "${{ matrix.semantic_version }}" -m "Update semantic tag to ${{ needs.build.outputs.release_tag }}" + git push origin "refs/tags/${{ matrix.semantic_version }}" --force - name: PROD - Publish semantic release ${{ matrix.semantic_version }} run: | gh release delete ${{ matrix.semantic_version }} -y || true @@ -388,7 +390,7 @@ jobs: gh release upload "${{ matrix.semantic_version }}" $files --clobber env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - + docker: name: Trigger Docker builds needs: [build, release, semantic_versions] @@ -397,7 +399,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Trigger Docker image builds in fcli-docker repository - run: | + run: | gh workflow run docker.yml \ --repo fortify/fcli-docker \ --ref main \ @@ -406,8 +408,8 @@ jobs: -f "isLatest=${{ needs.build.outputs.do_prod_release }}" echo "✓ Docker build triggered in fortify/fcli-docker repository" env: - GITHUB_TOKEN: ${{ secrets.FCLI_DOCKER_TRIGGER_TOKEN }} - + GITHUB_TOKEN: ${{ secrets.FCLI_DOCKER_TRIGGER_TOKEN }} + publishPages: name: publishPages if: needs.build.outputs.do_release @@ -418,29 +420,29 @@ jobs: uses: actions/checkout@v4 with: ref: gh-pages - + - name: Download artifacts uses: actions/download-artifact@v4 with: path: ./tmp name: build-output - + - name: Update documentation from artifact run: | # Extract top-level documentation resources # TODO Should we do this only when building a release, or also for dev_v3.x, # or for all (dev & release) versions like we do now? unzip -o tmp/docs-gh-pages-static.zip -d "." - + # Define the output directory, based on tag/branch name versionDir=${{ needs.build.outputs.release_tag }} - + # Delete, recreate and fill the directory for the current tag/branch name, # while leaving documentation for other tags/branches intact (as checked out above) rm -rf "${versionDir}" mkdir -p "${versionDir}" unzip tmp/docs-gh-pages-versioned.zip -d "${versionDir}" - + # Update symlinks based on available versions, processing versions in ascending order # to replace previous links with a newer version if appropriate. For example, 'latest' # will first point to the oldest version, then replaced with second-oldest version, ..., @@ -450,22 +452,23 @@ jobs: ln -sfT $line $(echo "$line" | cut -d. -f 1) # v ln -sfT $line $(echo "$line" | cut -d. -f 1,2) # v. done - + # Same for dev_*, but only generating latest_dev symlink ls -d dev_* | sort -V | while read line; do ln -sfT $line latest_dev done - + # Recreate version data files, which may be empty if no versions available mkdir -p _data/versions touch _data/versions/release.yml touch _data/versions/dev.yml ls -d v*.*.* | sort -rV | while read line; do echo "- '$line'"; done > _data/versions/release.yml ls -d dev_* | sort | while read line; do echo "- '$line'"; done > _data/versions/dev.yml - + git config user.name github-actions git config user.email github-actions@fortify.com git add . git commit -m "Update documentation for ${{ needs.build.outputs.release_tag }}" git push - \ No newline at end of file + + diff --git a/fcli-core/fcli-action/src/main/java/com/fortify/cli/generic_action/_main/cli/cmd/GenericActionCommands.java b/fcli-core/fcli-action/src/main/java/com/fortify/cli/generic_action/_main/cli/cmd/GenericActionCommands.java index cce3909ff3..72048fb763 100644 --- a/fcli-core/fcli-action/src/main/java/com/fortify/cli/generic_action/_main/cli/cmd/GenericActionCommands.java +++ b/fcli-core/fcli-action/src/main/java/com/fortify/cli/generic_action/_main/cli/cmd/GenericActionCommands.java @@ -13,6 +13,8 @@ package com.fortify.cli.generic_action._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.generic_action.action.cli.cmd.GenericActionAsciidocCommand; import com.fortify.cli.generic_action.action.cli.cmd.GenericActionGetCommand; import com.fortify.cli.generic_action.action.cli.cmd.GenericActionHelpCommand; @@ -24,6 +26,7 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) @Command( name = "action", resourceBundle = "com.fortify.cli.generic_action.i18n.GenericActionMessages", diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java index 89b2fac434..79a02d5bc8 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/_main/cli/cmd/AviatorCommands.java @@ -19,9 +19,14 @@ import com.fortify.cli.aviator.ssc.cli.cmd.AviatorSSCCommands; import com.fortify.cli.aviator.token.cli.cmd.AviatorTokenCommands; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) +@RelatedModules({"ssc"}) @Command( name = "aviator", resourceBundle = "com.fortify.cli.aviator.i18n.AviatorMessages", diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ModuleType.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ModuleType.java new file mode 100644 index 0000000000..daefd8377c --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ModuleType.java @@ -0,0 +1,25 @@ +/* + * 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; + +/** + * Enum that holds the type of a module as a product module (SSC, FoD, Aviator, SC-SAST, SC-DAST) + * or a non-product/other module (util, tool, license, actions, config, ...) + * + * @author Sangamesh Vijaykumar + */ + +public enum ModuleType { + PRODUCT, // SSC, FoD, Aviator, SC-SAST, SC-DAST + OTHER // util, tool, license, actions, config, ... +} diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ProductModule.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ProductModule.java new file mode 100644 index 0000000000..78f7c3edf9 --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ProductModule.java @@ -0,0 +1,31 @@ +/* + * 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 static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Marks a module as a product module (SSC, FoD, Aviator, SC-SAST, SC-DAST) + * or a non-product/other module (util, tool, license, actions, config, ...). + * + * @author Sangamesh Vijaykumar + */ +@Retention(RUNTIME) +@Target(ElementType.TYPE) +public @interface ProductModule { + ModuleType value(); +} diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/RelatedModules.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/RelatedModules.java new file mode 100644 index 0000000000..e5d636693c --- /dev/null +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/RelatedModules.java @@ -0,0 +1,31 @@ +/* + * 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.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines which base modules a module is related to. + * Example: @RelatedModules({"ssc","fod"}) on the tool/util module. + * + * @author Sangamesh Vijaykumar + */ + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface RelatedModules { + String[] value(); // base modules this module is related to, e.g. {"ssc","fod"} +} diff --git a/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/_main/cli/cmd/ConfigCommands.java b/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/_main/cli/cmd/ConfigCommands.java index ec8c458e2d..b4247d7f77 100644 --- a/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/_main/cli/cmd/ConfigCommands.java +++ b/fcli-core/fcli-config/src/main/java/com/fortify/cli/config/_main/cli/cmd/ConfigCommands.java @@ -13,6 +13,8 @@ package com.fortify.cli.config._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.config.language.cli.cmd.LanguageCommands; import com.fortify.cli.config.proxy.cli.cmd.ProxyCommands; import com.fortify.cli.config.publickey.cli.cmd.PublicKeyCommands; @@ -20,6 +22,7 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) @Command( name = "config", aliases = "cfg", diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java index c6186f4ad3..b6f9bbcd01 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionLoginCommand.java @@ -28,7 +28,7 @@ import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; -@Command(name = OutputHelperMixins.Login.CMD_NAME, sortOptions = false) +@Command(name = OutputHelperMixins.Login.CMD_NAME, sortOptions = false, preprocessor = FoDSessionTenantIgnoringPreprocessor.class) public class FoDSessionLoginCommand extends AbstractSessionLoginCommand { @Getter @Mixin private OutputHelperMixins.Login outputHelper; @Getter private FoDSessionHelper sessionHelper = FoDSessionHelper.instance(); diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessor.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessor.java new file mode 100644 index 0000000000..a78b8e6a83 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessor.java @@ -0,0 +1,132 @@ +/* + * 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.fod._common.session.cli.cmd; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.Stack; + +import com.formkiq.graalvm.annotations.Reflectable; + +import picocli.CommandLine.IParameterPreprocessor; +import picocli.CommandLine.Model.ArgSpec; +import picocli.CommandLine.Model.CommandSpec; + +/** + * Removes --tenant/-t from command line arguments when client credentials are used. + * This allows tenant to stay mandatory in the user credential argument group while + * accepting tenant as a no-op for client credential authentication. + * + * @author Sangamesh Vijayakumar + */ +@Reflectable +public final class FoDSessionTenantIgnoringPreprocessor implements IParameterPreprocessor { + private static final String TENANT_PRIMARY_NAME = "--tenant"; + private static final String CLIENT_ID_PRIMARY_NAME = "--client-id"; + private static final String CLIENT_SECRET_PRIMARY_NAME = "--client-secret"; + + @Override + public boolean preprocess(Stack args, CommandSpec commandSpec, ArgSpec argSpec, Map info) { + if (argSpec != null || args == null || args.isEmpty() || commandSpec == null) { + return false; + } + + var tenantNames = resolveOptionNames(commandSpec, TENANT_PRIMARY_NAME, "-t"); + var clientIdNames = resolveOptionNames(commandSpec, CLIENT_ID_PRIMARY_NAME); + var clientSecretNames = resolveOptionNames(commandSpec, CLIENT_SECRET_PRIMARY_NAME); + + var cliArgs = new ArrayList<>(args); + if (!hasClientCredentials(cliArgs, clientIdNames, clientSecretNames)) { + return false; + } + + var filtered = filterOutTenantOptions(cliArgs, tenantNames); + if (filtered.size() != cliArgs.size()) { + args.clear(); + args.addAll(filtered); + } + return false; + } + + private static Set resolveOptionNames(CommandSpec commandSpec, String primaryName, String... fallbackNames) { + var optionSpec = commandSpec.findOption(primaryName); + var names = new LinkedHashSet(); + if (optionSpec != null) { + names.addAll(Arrays.asList(optionSpec.names())); + } else { + names.add(primaryName); + names.addAll(Arrays.asList(fallbackNames)); + } + return names; + } + + private static boolean hasClientCredentials(List cliArgs, Set clientIdNames, Set clientSecretNames) { + return hasAnyOption(cliArgs, clientIdNames) || hasAnyOption(cliArgs, clientSecretNames); + } + + private static boolean hasAnyOption(List cliArgs, Set optionNames) { + return cliArgs.stream().anyMatch(arg -> isOptionToken(arg, optionNames)); + } + + private static List filterOutTenantOptions(List cliArgs, Set tenantNames) { + // The Picocli Stack stores args in reverse order relative to the original command line + // (first arg is at the top / last index). We reverse to process in original left-to-right + // order so that i+1 correctly identifies the space-separated value token. + var orderedArgs = new ArrayList<>(cliArgs); + Collections.reverse(orderedArgs); + + var filteredInOrder = new ArrayList(); + for (int i = 0; i < orderedArgs.size(); i++) { + var arg = orderedArgs.get(i); + if (isExactOptionToken(arg, tenantNames)) { + if (i + 1 < orderedArgs.size() && !orderedArgs.get(i + 1).startsWith("-")) { + i++; // Skip the separate option value token. + } + continue; + } + if (isInlineOptionToken(arg, tenantNames) || isCompactShortOptionToken(arg, tenantNames)) { + continue; + } + filteredInOrder.add(arg); + } + + // Reverse back to Stack order before returning. + Collections.reverse(filteredInOrder); + return filteredInOrder; + } + + private static boolean isOptionToken(String arg, Set optionNames) { + return isExactOptionToken(arg, optionNames) + || isInlineOptionToken(arg, optionNames) + || isCompactShortOptionToken(arg, optionNames); + } + + private static boolean isExactOptionToken(String arg, Set optionNames) { + return optionNames.contains(arg); + } + + private static boolean isInlineOptionToken(String arg, Set optionNames) { + return optionNames.stream().anyMatch(name -> arg.startsWith(name + "=")); + } + + private static boolean isCompactShortOptionToken(String arg, Set optionNames) { + return optionNames.stream() + .filter(name -> name.startsWith("-") && !name.startsWith("--") && name.length() == 2) + .anyMatch(name -> arg.startsWith(name) && arg.length() > name.length() && arg.charAt(name.length()) != '='); + } +} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java index 6d3e604323..3250f0e659 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/mixin/FoDSessionLoginOptions.java @@ -16,7 +16,6 @@ import org.apache.commons.lang3.StringUtils; -import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.log.LogSensitivityLevel; import com.fortify.cli.common.log.MaskValue; import com.fortify.cli.common.rest.cli.mixin.UrlConfigOptions; @@ -43,18 +42,21 @@ public static class FoDAuthOptions { @Getter private FoDCredentialOptions credentialOptions = new FoDCredentialOptions(); @Option(names="--scopes", defaultValue="api-tenant", split=",") @Getter private String[] scopes; - @Option(names = {"-t", "--tenant"}, required = false) - @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TENANT") - @Getter private String tenant; // Optional: required only for user credentials } public static class FoDCredentialOptions { @ArgGroup(exclusive = false, multiplicity = "1", order = 1) - @Getter private UserCredentialOptions userCredentialOptions = new UserCredentialOptions(); + @Getter private FoDUserCredentialOptions userCredentialOptions = new FoDUserCredentialOptions(); @ArgGroup(exclusive = false, multiplicity = "1", order = 2) @Getter private FoDClientCredentialOptions clientCredentialOptions = new FoDClientCredentialOptions(); } + public static class FoDUserCredentialOptions extends UserCredentialOptions { + @Option(names = {"-t", "--tenant"}, required = true) + @MaskValue(sensitivity = LogSensitivityLevel.low, description = "FOD TENANT") + @Getter private String tenant; + } + public static class FoDClientCredentialOptions implements IFoDClientCredentials { @Option(names = {"--client-id"}, required = true) @MaskValue(sensitivity = LogSensitivityLevel.medium, description = "FOD CLIENT ID") @@ -64,7 +66,7 @@ public static class FoDClientCredentialOptions implements IFoDClientCredentials @Getter private String clientSecret; } - public UserCredentialOptions getUserCredentialOptions() { + public FoDUserCredentialOptions getUserCredentialOptions() { return Optional.ofNullable(authOptions) .map(FoDAuthOptions::getCredentialOptions) .map(FoDCredentialOptions::getUserCredentialOptions) @@ -79,16 +81,17 @@ public FoDClientCredentialOptions getClientCredentialOptions() { } public final boolean hasUserCredentials() { - return getUserCredentialOptions()!=null; + var userCredentialOptions = getUserCredentialOptions(); + return userCredentialOptions!=null + && StringUtils.isNotBlank(userCredentialOptions.getTenant()) + && StringUtils.isNotBlank(userCredentialOptions.getUser()) + && userCredentialOptions.getPassword()!=null + && userCredentialOptions.getPassword().length > 0; } public final BasicFoDUserCredentials getUserCredentials() { var u = getUserCredentialOptions(); - var t = Optional.ofNullable(authOptions).map(FoDAuthOptions::getTenant).orElse(null); - if ( u==null || StringUtils.isBlank(t) || StringUtils.isBlank(u.getUser()) || u.getPassword()==null ) { - throw new FcliSimpleException("--tenant, --user and --password must all be specified for user credential authentication"); - } - return BasicFoDUserCredentials.builder().tenant(t).user(u.getUser()).password(u.getPassword()).build(); + return BasicFoDUserCredentials.builder().tenant(u.getTenant()).user(u.getUser()).password(u.getPassword()).build(); } public final boolean hasClientCredentials() { @@ -133,9 +136,6 @@ public static final class Builder { public Builder user(String user){ this.user=user; return this; } public Builder password(char[] password){ this.password=password; return this; } public BasicFoDUserCredentials build(){ - if ( StringUtils.isBlank(tenant) || StringUtils.isBlank(user) || password==null || password.length==0 ) { - throw new FcliSimpleException("--tenant, --user and --password must all be specified for user credential authentication"); - } return new BasicFoDUserCredentials(this); } } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_main/cli/cmd/FoDCommands.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_main/cli/cmd/FoDCommands.java index 666231550e..45149230ff 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_main/cli/cmd/FoDCommands.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_main/cli/cmd/FoDCommands.java @@ -13,6 +13,8 @@ package com.fortify.cli.fod._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.fod._common.session.cli.cmd.FoDSessionCommands; import com.fortify.cli.fod.access_control.cli.cmd.FoDAccessControlCommands; import com.fortify.cli.fod.action.cli.cmd.FoDActionCommands; @@ -30,6 +32,7 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) @Command( name = "fod", resourceBundle = "com.fortify.cli.fod.i18n.FoDMessages", diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueListCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueListCommand.java index c017709fe5..3dda2fa84a 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueListCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueListCommand.java @@ -22,7 +22,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.json.JsonHelper; import com.fortify.cli.common.json.producer.AbstractObjectNodeProducer.AbstractObjectNodeProducerBuilder; import com.fortify.cli.common.json.producer.IObjectNodeProducer; @@ -49,6 +48,7 @@ import kong.unirest.HttpRequest; import kong.unirest.UnirestInstance; import lombok.Getter; +import picocli.CommandLine.ArgGroup; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; @@ -57,8 +57,8 @@ public class FoDIssueListCommand extends AbstractFoDOutputCommand implements IServerSideQueryParamGeneratorSupplier { @Getter @Mixin private OutputHelperMixins.List outputHelper; @Mixin private FoDDelimiterMixin delimiterMixin; // injected in resolvers - @Mixin private FoDAppResolverMixin.OptionalOption appResolver; - @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.OptionalOption releaseResolver; + @ArgGroup(exclusive = true, multiplicity = "1", order = 1) + @Getter private TargetSpecifierArgGroup targetSpecifier = new TargetSpecifierArgGroup(); @Mixin private FoDFiltersParamMixin filterParamMixin; @Mixin private FoDIssueEmbedMixin embedMixin; @Mixin private FoDIssueIncludeMixin includeMixin; @@ -75,23 +75,34 @@ public class FoDIssueListCommand extends AbstractFoDOutputCommand implements ISe .add("severityString","severityString") .add("category","category"); + public static class TargetSpecifierArgGroup { + @ArgGroup(exclusive = false, multiplicity = "1", order = 1) @Getter private AppTarget app = new AppTarget(); + @ArgGroup(exclusive = false, multiplicity = "1", order = 2) @Getter private ReleaseTarget release = new ReleaseTarget(); + } + + public static class AppTarget extends FoDAppResolverMixin.AbstractFoDAppResolverMixin { + @Option(names = { "--app" }, required = true, descriptionKey = "fcli.fod.app.app-name-or-id") @Getter private String appNameOrId; + } + + public static class ReleaseTarget extends FoDReleaseByQualifiedNameOrIdResolverMixin.AbstractFoDQualifiedReleaseNameOrIdResolverMixin { + @Option(names = { "--release", "--rel" }, required = true, paramLabel = "id|app[:ms]:rel", descriptionKey = "fcli.fod.release.resolver.name-or-id") @Getter private String qualifiedReleaseNameOrId; + } + @Override protected IObjectNodeProducer getObjectNodeProducer(UnirestInstance unirest) { - boolean releaseSpecified = releaseResolver.getQualifiedReleaseNameOrId() != null; - boolean appSpecified = appResolver.getAppNameOrId() != null; - if ( releaseSpecified && appSpecified ) { - throw new FcliSimpleException("Cannot specify both an application and release"); - } - if ( !releaseSpecified && !appSpecified ) { - throw new FcliSimpleException("Either an application or release must be specified"); + var appGroup = targetSpecifier.getApp(); + var releaseGroup = targetSpecifier.getRelease(); + + boolean appSpecified = appGroup != null && appGroup.getAppNameOrId() != null; + boolean releaseSpecified = releaseGroup != null && releaseGroup.getQualifiedReleaseNameOrId() != null; + + if (releaseSpecified) { + releaseGroup.setDelimiterMixin(delimiterMixin); } + var result = releaseSpecified - ? singleReleaseProducerBuilder(unirest, releaseResolver.getReleaseId(unirest)) - : applicationProducerBuilder(unirest, appResolver.getAppId(unirest)); - // For consistent output, we should remove releaseId/releaseName when listing across multiple releases, - // but that breaks existing scripts that may rely on those fields, so for now, we only do this in - // applicationProducerBuilder(). TODO: Change in in fcli v4.0. - // return result.recordTransformer(this::removeReleaseProperties).build(); + ? singleReleaseProducerBuilder(unirest, releaseGroup.getReleaseId(unirest)) + : applicationProducerBuilder(unirest, appGroup.getAppId(unirest)); return result.build(); } @@ -225,17 +236,15 @@ private JsonNode enrichIssueRecord(UnirestInstance unirest, String releaseName, } private boolean isEffectiveFastOutput() { - boolean appSpecified = appResolver.getAppNameOrId() != null; - boolean releaseSpecified = releaseResolver.getQualifiedReleaseNameOrId() != null; - if ( !appSpecified || releaseSpecified ) { return false; } + var appGroup = targetSpecifier.getApp(); + var releaseGroup = targetSpecifier.getRelease(); + + boolean appSpecified = appGroup != null && appGroup.getAppNameOrId() != null; + boolean releaseSpecified = releaseGroup != null && releaseGroup.getQualifiedReleaseNameOrId() != null; + if (!appSpecified || releaseSpecified) { return false; } boolean fastOutputStyle = outputHelper.getRecordWriterStyle().isFastOutput(); boolean streamingSupported = outputHelper.isStreamingOutputSupported(); - boolean recordConsumerConfigured = getRecordConsumer()!=null; - // Effective fast output requires: - // - application specified (multiple releases) - // - fast output style - // - no aggregation (merging requires full set) - // - streaming output or record consumer configured + boolean recordConsumerConfigured = getRecordConsumer() != null; return fastOutputStyle && !aggregate && (streamingSupported || recordConsumerConfigured); } diff --git a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/oss_scan/cli/cmd/FoDOssComponentsListCommand.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/oss_scan/cli/cmd/FoDOssComponentsListCommand.java index 7fb80227dc..2ba7cf06af 100644 --- a/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/oss_scan/cli/cmd/FoDOssComponentsListCommand.java +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/oss_scan/cli/cmd/FoDOssComponentsListCommand.java @@ -39,6 +39,7 @@ import kong.unirest.HttpResponse; import kong.unirest.UnirestInstance; import lombok.Getter; +import picocli.CommandLine.ArgGroup; import picocli.CommandLine.Command; import picocli.CommandLine.Mixin; import picocli.CommandLine.Option; @@ -47,24 +48,40 @@ @CommandGroup("oss-components") public final class FoDOssComponentsListCommand extends AbstractFoDJsonNodeOutputCommand { private static final Logger LOG = LoggerFactory.getLogger(FoDOssComponentsListCommand.class); - @Getter - @Mixin - private OutputHelperMixins.TableWithQuery outputHelper; - @Mixin - private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins - @Mixin - private FoDAppResolverMixin.OptionalOption appResolver; - @Mixin - private FoDReleaseByQualifiedNameOrIdResolverMixin.OptionalOption releaseResolver; - @Option(names = "--scan-types", required = true, split = ",", defaultValue = "Debricked") - private FoDOpenSourceScanType[] scanTypes; + @Getter @Mixin private OutputHelperMixins.TableWithQuery outputHelper; + @Mixin private FoDDelimiterMixin delimiterMixin; + @ArgGroup(exclusive = true, multiplicity = "1", order = 1) @Getter private TargetSpecifierArgGroup targetSpecifier = new TargetSpecifierArgGroup(); + @Option(names = "--scan-types", required = true, split = ",", defaultValue = "Debricked") private FoDOpenSourceScanType[] scanTypes; + + public static class TargetSpecifierArgGroup { + @ArgGroup(exclusive = false, multiplicity = "1", order = 1) @Getter private AppTarget app = new AppTarget(); + @ArgGroup(exclusive = false, multiplicity = "1", order = 2) @Getter private ReleaseTarget release = new ReleaseTarget(); + } + + public static class AppTarget extends FoDAppResolverMixin.AbstractFoDAppResolverMixin { + @Option(names = { "--app" }, required = true, descriptionKey = "fcli.fod.app.app-name-or-id") @Getter private String appNameOrId; + } + + public static class ReleaseTarget extends FoDReleaseByQualifiedNameOrIdResolverMixin.AbstractFoDQualifiedReleaseNameOrIdResolverMixin { + @Option(names = { "--release", "--rel" }, required = true, paramLabel = "id|app[:ms]:rel", descriptionKey = "fcli.fod.release.resolver.name-or-id") @Getter private String qualifiedReleaseNameOrId; + } @Override public JsonNode getJsonNode(UnirestInstance unirest) { ArrayNode result = JsonHelper.getObjectMapper().createArrayNode(); + + var appGroup = targetSpecifier.getApp(); + var releaseGroup = targetSpecifier.getRelease(); + + final String applicationId = (appGroup != null && appGroup.getAppNameOrId() != null) + ? appGroup.getAppId(unirest) + : null; + final String releaseId = (releaseGroup != null && releaseGroup.getQualifiedReleaseNameOrId() != null) + ? releaseGroup.getReleaseId(unirest) + : null; + Stream.of(scanTypes) - .map(t -> getForOpenSourceScanType(unirest, t, releaseResolver.getReleaseId(unirest), - appResolver.getAppId(unirest), false)) + .map(t -> getForOpenSourceScanType(unirest, t, releaseId, applicationId, false)) .forEach(result::addAll); return result; } @@ -100,7 +117,7 @@ private ArrayNode getForOpenSourceScanType(UnirestInstance unirest, FoDOpenSourc if (failOnError) { throw e; } - LOG.error("Error retrieving OSS components for release " + releaseResolver.getReleaseId(unirest) + LOG.error("Error retrieving OSS components for release " + releaseId + " and scan type " + scanType.name() + ": " + e.getMessage()); return JsonHelper.getObjectMapper().createArrayNode(); } diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessorTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessorTest.java new file mode 100644 index 0000000000..96cb1fae7a --- /dev/null +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessorTest.java @@ -0,0 +1,139 @@ +/* + * 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.fod._common.session.cli.cmd; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.fortify.cli.fod._common.session.cli.mixin.FoDSessionLoginOptions; + +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.MissingParameterException; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.UnmatchedArgumentException; + +class FoDSessionTenantIgnoringPreprocessorTest { + @Test + void shouldAllowClientCredentialsWithoutTenant() { + var cmd = parse("--client-id", "id", "--client-secret", "secret"); + + assertTrue(cmd.loginOptions.hasClientCredentials()); + assertFalse(cmd.loginOptions.hasUserCredentials()); + } + + @Test + void shouldIgnoreTenantWhenClientCredentialsProvided() { + var cmd = parse("--tenant", "acme", "--client-id", "id", "--client-secret", "secret"); + + assertTrue(cmd.loginOptions.hasClientCredentials()); + assertFalse(cmd.loginOptions.hasUserCredentials()); + } + + @Test + void shouldIgnoreCompactTenantWhenClientCredentialsProvided() { + var cmd = parse("-tacme", "--client-id", "id", "--client-secret", "secret"); + + assertTrue(cmd.loginOptions.hasClientCredentials()); + assertFalse(cmd.loginOptions.hasUserCredentials()); + } + + @Test + void shouldIgnoreAllSupportedTenantSyntaxWhenClientCredentialsProvided() { + assertClientCredentialsOnly("-t", "acme"); + assertClientCredentialsOnly("-t=acme"); + assertClientCredentialsOnly("-tacme"); + assertClientCredentialsOnly("--tenant", "acme"); + assertClientCredentialsOnly("--tenant=acme"); + } + + @Test + void shouldIgnoreAllSupportedTenantSyntaxWhenClientCredentialsWithEqualsSyntax() { + assertClientCredentialsOnlyEqualsStyle("-t", "acme"); + assertClientCredentialsOnlyEqualsStyle("-t=acme"); + assertClientCredentialsOnlyEqualsStyle("-tacme"); + assertClientCredentialsOnlyEqualsStyle("--tenant", "acme"); + assertClientCredentialsOnlyEqualsStyle("--tenant=acme"); + } + + @Test + void shouldRejectInvalidLongTenantSyntax() { + assertThrows(UnmatchedArgumentException.class, + () -> parse("--tenanttenant-value", "--client-id", "id", "--client-secret", "secret")); + } + + @Test + void shouldFailUserCredentialsWithoutTenant() { + var ex = assertThrows(MissingParameterException.class, + () -> parse("--user", "bob", "--password", "pw")); + + assertTrue(ex.getMessage().toLowerCase().contains("tenant")); + } + + @Test + void shouldAllowUserCredentialsWithTenant() { + var cmd = parse("--tenant", "acme", "--user", "bob", "--password", "pw"); + + assertTrue(cmd.loginOptions.hasUserCredentials()); + assertDoesNotThrow(() -> cmd.loginOptions.getUserCredentials()); + } + + private static void assertClientCredentialsOnly(String... tenantArgs) { + var args = new ArrayList(); + Collections.addAll(args, tenantArgs); + args.add("--client-id"); + args.add("id"); + args.add("--client-secret"); + args.add("secret"); + + var cmd = parse(args.toArray(String[]::new)); + assertTrue(cmd.loginOptions.hasClientCredentials()); + assertFalse(cmd.loginOptions.hasUserCredentials()); + // Verify other options (e.g. --url) are not corrupted by the preprocessing. + assertDoesNotThrow(() -> cmd.loginOptions.getUrlConfigOptions().getUrl()); + } + + private static void assertClientCredentialsOnlyEqualsStyle(String... tenantArgs) { + var args = new ArrayList(); + Collections.addAll(args, tenantArgs); + args.add("--client-id=id"); + args.add("--client-secret=secret"); + + var cmd = parse(args.toArray(String[]::new)); + assertTrue(cmd.loginOptions.hasClientCredentials()); + assertFalse(cmd.loginOptions.hasUserCredentials()); + assertDoesNotThrow(() -> cmd.loginOptions.getUrlConfigOptions().getUrl()); + } + + private static TestFoDLoginCommand parse(String... args) { + var cmd = new TestFoDLoginCommand(); + var fullArgs = new ArrayList(); + fullArgs.add("--url"); + fullArgs.add("https://example.org"); + Collections.addAll(fullArgs, args); + new CommandLine(cmd).parseArgs(fullArgs.toArray(String[]::new)); + return cmd; + } + + @Command(name = "test-fod-login", preprocessor = FoDSessionTenantIgnoringPreprocessor.class) + static final class TestFoDLoginCommand { + @Mixin private FoDSessionLoginOptions loginOptions; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueListCommandEffectiveFastOutputTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueListCommandEffectiveFastOutputTest.java index 1de2141541..ba27485aa7 100644 --- a/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueListCommandEffectiveFastOutputTest.java +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueListCommandEffectiveFastOutputTest.java @@ -23,8 +23,6 @@ import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; import com.fortify.cli.common.output.writer.record.RecordWriterStyle; import com.fortify.cli.common.output.writer.record.RecordWriterStyle.RecordWriterStyleElement; -import com.fortify.cli.fod.app.cli.mixin.FoDAppResolverMixin; -import com.fortify.cli.fod.release.cli.mixin.FoDReleaseByQualifiedNameOrIdResolverMixin; /** * Tests for FoDIssueListCommand.isEffectiveFastOutput logic after migration to style-based fast-output. @@ -39,10 +37,7 @@ public class FoDIssueListCommandEffectiveFastOutputTest { void init() throws Exception { cmd = new FoDIssueListCommand(); streamingStub = new StreamingStubOutputHelper(); - setField(cmd, "outputHelper", streamingStub); // inject stub - // Provide empty mixins so reflection can set their private fields - setField(cmd, "appResolver", new FoDAppResolverMixin.OptionalOption()); - setField(cmd, "releaseResolver", new FoDReleaseByQualifiedNameOrIdResolverMixin.OptionalOption()); + setField(cmd, "outputHelper", streamingStub); } @Test @@ -94,17 +89,15 @@ private void setField(Object target, String fieldName, Object value) throws Exce } private void setApp(String app) throws Exception { - Object appResolver = getField(cmd, "appResolver"); - setField(appResolver, "appNameOrId", app); + var target = cmd.getTargetSpecifier(); + var appGroup = target.getApp(); + setField(appGroup, "appNameOrId", app); } + private void setRelease(String rel) throws Exception { - Object relResolver = getField(cmd, "releaseResolver"); - setField(relResolver, "qualifiedReleaseNameOrId", rel); - } - private Object getField(Object target, String fieldName) throws Exception { - Field f = target.getClass().getDeclaredField(fieldName); - f.setAccessible(true); - return f.get(target); + var target = cmd.getTargetSpecifier(); + var releaseGroup = target.getRelease(); + setField(releaseGroup, "qualifiedReleaseNameOrId", rel); } private boolean invokeIsEffectiveFastOutput() throws Exception { diff --git a/fcli-core/fcli-license/src/main/java/com/fortify/cli/license/_main/cli/cmd/LicenseCommands.java b/fcli-core/fcli-license/src/main/java/com/fortify/cli/license/_main/cli/cmd/LicenseCommands.java index 0b335d51b9..aa301a6493 100644 --- a/fcli-core/fcli-license/src/main/java/com/fortify/cli/license/_main/cli/cmd/LicenseCommands.java +++ b/fcli-core/fcli-license/src/main/java/com/fortify/cli/license/_main/cli/cmd/LicenseCommands.java @@ -13,11 +13,14 @@ package com.fortify.cli.license._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.license.msp_report.cli.cmd.MspReportCommands; import com.fortify.cli.license.ncd_report.cli.cmd.NcdReportCommands; import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) @Command( name = "license", resourceBundle = "com.fortify.cli.license.i18n.LicenseMessages", diff --git a/fcli-core/fcli-sc-dast/src/main/java/com/fortify/cli/sc_dast/_main/cli/cmd/SCDastCommands.java b/fcli-core/fcli-sc-dast/src/main/java/com/fortify/cli/sc_dast/_main/cli/cmd/SCDastCommands.java index 164b435d8a..f93c1852ee 100644 --- a/fcli-core/fcli-sc-dast/src/main/java/com/fortify/cli/sc_dast/_main/cli/cmd/SCDastCommands.java +++ b/fcli-core/fcli-sc-dast/src/main/java/com/fortify/cli/sc_dast/_main/cli/cmd/SCDastCommands.java @@ -13,6 +13,9 @@ package com.fortify.cli.sc_dast._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import com.fortify.cli.sc_dast.rest.cli.cmd.SCDastRestCommands; import com.fortify.cli.sc_dast.scan.cli.cmd.SCDastScanCommands; import com.fortify.cli.sc_dast.scan_policy.cli.cmd.SCDastScanPolicyCommands; @@ -21,6 +24,8 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) +@RelatedModules({"ssc"}) @Command( name = "sc-dast", resourceBundle = "com.fortify.cli.sc_dast.i18n.SCDastMessages", diff --git a/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/_main/cli/cmd/SCSastCommands.java b/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/_main/cli/cmd/SCSastCommands.java index 97393b2f1f..e980aa9dfe 100644 --- a/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/_main/cli/cmd/SCSastCommands.java +++ b/fcli-core/fcli-sc-sast/src/main/java/com/fortify/cli/sc_sast/_main/cli/cmd/SCSastCommands.java @@ -13,6 +13,9 @@ package com.fortify.cli.sc_sast._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import com.fortify.cli.sc_sast.rest.cli.cmd.SCSastRestCommands; import com.fortify.cli.sc_sast.scan.cli.cmd.SCSastScanCommands; import com.fortify.cli.sc_sast.sensor.cli.cmd.SCSastSensorCommands; @@ -20,6 +23,8 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) +@RelatedModules({"ssc"}) @Command( name = "sc-sast", resourceBundle = "com.fortify.cli.sc_sast.i18n.SCSastMessages", diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_main/cli/cmd/SSCCommands.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_main/cli/cmd/SSCCommands.java index cb09778300..5430d9e3eb 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_main/cli/cmd/SSCCommands.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/_main/cli/cmd/SSCCommands.java @@ -13,6 +13,8 @@ package com.fortify.cli.ssc._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; import com.fortify.cli.ssc._common.session.cli.cmd.SSCSessionCommands; import com.fortify.cli.ssc.access_control.cli.cmd.SSCAccessControlCommands; import com.fortify.cli.ssc.action.cli.cmd.SSCActionCommands; @@ -33,6 +35,7 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.PRODUCT) @Command( name = "ssc", resourceBundle = "com.fortify.cli.ssc.i18n.SSCMessages", diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/cli/cmd/SSCIssueCommands.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/cli/cmd/SSCIssueCommands.java index bf3f0aaca9..a3d1b5e40b 100644 --- a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/cli/cmd/SSCIssueCommands.java +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/cli/cmd/SSCIssueCommands.java @@ -33,6 +33,7 @@ SSCIssueGroupListCommand.class, SSCIssueCountCommand.class, SSCIssueListCommand.class, + SSCIssueUpdateCommand.class, } ) public class SSCIssueCommands extends AbstractContainerCommand { diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/cli/cmd/SSCIssueUpdateCommand.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/cli/cmd/SSCIssueUpdateCommand.java new file mode 100644 index 0000000000..bcd34ea6c8 --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/cli/cmd/SSCIssueUpdateCommand.java @@ -0,0 +1,271 @@ +/* + * 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.ssc.issue.cli.cmd; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.output.transform.IActionCommandResultSupplier; +import com.fortify.cli.ssc._common.output.cli.cmd.AbstractSSCJsonNodeOutputCommand; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc.appversion.cli.mixin.SSCAppVersionResolverMixin; +import com.fortify.cli.ssc.issue.helper.SSCIssueCustomTagAuditValue; +import com.fortify.cli.ssc.issue.helper.SSCIssueCustomTagHelper; +import com.fortify.cli.ssc.issue.helper.SSCIssueIdentifier; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; + +@Command(name = OutputHelperMixins.Update.CMD_NAME) +//@Slf4j +public class SSCIssueUpdateCommand extends AbstractSSCJsonNodeOutputCommand implements IActionCommandResultSupplier { + + @Getter @Mixin private OutputHelperMixins.Update outputHelper; + @Mixin private SSCAppVersionResolverMixin.RequiredOption appVersionResolver; + @Option(names = {"--issue-ids"}, required = true, split = ",") + private List issueIds; + @Option(names = {"--custom-tags", "-t"}, split = ",", paramLabel = "TAG=VALUE") + private Map customTags; + @Option(names = {"--suppress"}, arity = "1", paramLabel = "true|false") + private Boolean suppress; + @Option(names = {"--comment"}) + private String comment; + @Option(names = {"--assign-user"}) + private String assignUser; + + @Override + public JsonNode getJsonNode(UnirestInstance unirest) { + validateInput(); + String appVersionId = appVersionResolver.getAppVersionId(unirest); + List issues = fetchIssueRevisionsFromSSC(unirest, appVersionId, issueIds); + + if (StringUtils.isNotBlank(assignUser)) { + executeAssignUserRequest(unirest, appVersionId, issues, assignUser); + if (isUpdateRequired()) { + issues = fetchIssueRevisionsFromSSC(unirest, appVersionId, issueIds); + } + } + + if (isUpdateRequired()) { + executeAuditRequest(unirest, appVersionId, issues); + } + + return buildResults(unirest); + } + + private void validateInput() { + if (issueIds == null || issueIds.isEmpty()) { + throw new FcliSimpleException("--issue-ids must be specified"); + } + if (!isUpdateRequired() && StringUtils.isBlank(assignUser)) { + throw new FcliSimpleException("At least one of --custom-tags, --suppress, --comment, or --assign-user must be specified"); + } + } + + private boolean isUpdateRequired() { + return hasCustomTags() || suppress != null || StringUtils.isNotBlank(comment); + } + + private boolean hasCustomTags() { + return customTags != null && !customTags.isEmpty(); + } + + private JsonNode buildResults(UnirestInstance unirest) { + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); + + String updatesSummary = buildUpdateDetails(); + + ArrayNode issueIdsArray = result.putArray("issueIds"); + for (String vulnId : issueIds) { + issueIdsArray.add(vulnId); + } + + result.put("updatesString", updatesSummary); + + if (hasCustomTags()) { + ArrayNode customTagsArray = result.putArray("customTagUpdates"); + String appVersionId = appVersionResolver.getAppVersionId(unirest); + var customTagHelper = new SSCIssueCustomTagHelper(unirest, appVersionId); + customTagHelper.populateCustomTagUpdates(customTags, customTagsArray); + } + + if (StringUtils.isNotBlank(comment)) { + result.put("newComment", comment); + } + + if (StringUtils.isNotBlank(assignUser)) { + result.put("assignedUser", assignUser); + } + + if (suppress != null) { + result.put("suppressed", suppress); + } + + return result; + } + + private String buildUpdateDetails() { + StringBuilder details = new StringBuilder(); + if (hasCustomTags()) { + customTags.forEach((key, value) -> + appendDetail(details, "CustomTag: " + key + "=" + (StringUtils.isBlank(value) ? "" : value))); + } + if (suppress != null) { + appendDetail(details, "Suppressed: " + suppress); + } + if (StringUtils.isNotBlank(assignUser)) { + appendDetail(details, "User: " + assignUser); + } + if (StringUtils.isNotBlank(comment)) { + appendDetail(details, "Comment: " + comment); + } + String result = details.toString(); + return result.isEmpty() ? "No updates" : result; + } + + private void appendDetail(StringBuilder sb, String detail) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(detail); + } + + private void executeAssignUserRequest(UnirestInstance unirest, String appVersionId, + List issues, String user) { + ObjectNode requestBody = JsonHelper.getObjectMapper().createObjectNode(); + ArrayNode issuesArray = requestBody.putArray("issues"); + for (SSCIssueIdentifier issue : issues) { + ObjectNode issueNode = JsonHelper.getObjectMapper().createObjectNode(); + issueNode.put("id", issue.id()); + issueNode.put("revision", issue.revision()); + issuesArray.add(issueNode); + } + requestBody.put("user", user); + + String url = SSCUrls.PROJECT_VERSION_ISSUES_ACTION_ASSIGN_USER(appVersionId); + + try { + JsonNode response = unirest.post(url) + .body(requestBody) + .asObject(JsonNode.class) + .getBody(); + validateApiResponse(response, "Assign user operation"); + } catch (FcliSimpleException e) { + throw e; + } catch (Exception e) { + throw new FcliSimpleException("Failed to assign user: " + e.getMessage(), e); + } + } + + private void executeAuditRequest(UnirestInstance unirest, String appVersionId, List issues) { + Map request = new HashMap<>(); + request.put("issues", issues); + if (comment != null) { + request.put("comment", comment); + } + if (suppress != null) { + request.put("suppressed", suppress); + } + if (hasCustomTags()) { + var customTagHelper = new SSCIssueCustomTagHelper(unirest, appVersionId); + List processedTags = customTagHelper.processCustomTags(customTags); + request.put("customTagAudit", processedTags); + } + + String url = SSCUrls.PROJECT_VERSION_ISSUES_ACTION_AUDIT(appVersionId); + + try { + JsonNode response = unirest.post(url) + .body(request) + .asObject(JsonNode.class) + .getBody(); + validateApiResponse(response, "Audit operation"); + } catch (FcliSimpleException e) { + throw e; + } catch (Exception e) { + throw new FcliSimpleException("Failed to perform audit operation: " + e.getMessage(), e); + } + } + + private void validateApiResponse(JsonNode response, String operationName) { + if (response == null) { + throw new FcliSimpleException(operationName + " returned null response"); + } + if (response.has("responseCode")) { + int responseCode = response.get("responseCode").asInt(); + if (responseCode >= 400) { + String message = response.has("message") ? response.get("message").asText() : "Unknown error"; + throw new FcliSimpleException(operationName + " failed with response code " + responseCode + ": " + message); + } + } + } + + @Override + public String getActionCommandResult() { + return "UPDATED"; + } + + @Override + public boolean isSingular() { + return true; + } + + private List fetchIssueRevisionsFromSSC(UnirestInstance unirest, String appVersionId, List issueIds) { + String idsParam = String.join(",", issueIds); + + try { + JsonNode response = unirest.get("/api/v1/projectVersions/{appVersionId}/issues") + .routeParam("appVersionId", appVersionId) + .queryString("ids", idsParam) + .asObject(JsonNode.class) + .getBody(); + + JsonNode dataArray = response.get("data"); + if (dataArray == null || !dataArray.isArray()) { + throw new FcliSimpleException("Invalid response from SSC issues API - missing 'data' field"); + } + + Map idToRevisionMap = new HashMap<>(); + for (JsonNode issueNode : dataArray) { + idToRevisionMap.put(issueNode.get("id").asText(), issueNode.get("revision").asInt()); + } + + for (String issueId : issueIds) { + if (!idToRevisionMap.containsKey(issueId)) { + throw new FcliSimpleException("Issue with ID '" + issueId + "' not found in application version"); + } + } + + return issueIds.stream() + .map(id -> SSCIssueIdentifier.fromIdAndRevision(id, idToRevisionMap.get(id))) + .toList(); + + } catch (FcliSimpleException e) { + throw e; + } catch (Exception e) { + throw new FcliSimpleException("Failed to fetch issue revisions from SSC: " + e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueCustomTagAuditValue.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueCustomTagAuditValue.java new file mode 100644 index 0000000000..cde2a73a4e --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueCustomTagAuditValue.java @@ -0,0 +1,75 @@ +/* + * 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.ssc.issue.helper; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Represents a custom tag value in an SSC issue audit request. + */ +@JsonInclude(Include.NON_NULL) +@Reflectable +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SSCIssueCustomTagAuditValue { + @JsonProperty("customTagGuid") + private String customTagGuid; + + @JsonProperty("textValue") + private String textValue; + + @JsonProperty("newCustomTagIndex") + private Integer newCustomTagIndex; + + @JsonProperty("dateValue") + private String dateValue; + + @JsonProperty("decimalValue") + private Double decimalValue; + + public static SSCIssueCustomTagAuditValue forText(String guid, String value) { + SSCIssueCustomTagAuditValue result = new SSCIssueCustomTagAuditValue(); + result.setCustomTagGuid(guid); + result.setTextValue(value); + return result; + } + + public static SSCIssueCustomTagAuditValue forList(String guid, Integer lookupIndex) { + SSCIssueCustomTagAuditValue result = new SSCIssueCustomTagAuditValue(); + result.setCustomTagGuid(guid); + result.setNewCustomTagIndex(lookupIndex); + return result; + } + + public static SSCIssueCustomTagAuditValue forDate(String guid, String dateValue) { + SSCIssueCustomTagAuditValue result = new SSCIssueCustomTagAuditValue(); + result.setCustomTagGuid(guid); + result.setDateValue(dateValue); + return result; + } + + public static SSCIssueCustomTagAuditValue forDecimal(String guid, Double value) { + SSCIssueCustomTagAuditValue result = new SSCIssueCustomTagAuditValue(); + result.setCustomTagGuid(guid); + result.setDecimalValue(value); + return result; + } +} diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueCustomTagHelper.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueCustomTagHelper.java new file mode 100644 index 0000000000..5ce0396f9f --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueCustomTagHelper.java @@ -0,0 +1,224 @@ +/* + * 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.ssc.issue.helper; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.exception.FcliSimpleException; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.ssc._common.rest.ssc.SSCUrls; +import com.fortify.cli.ssc.custom_tag.helper.SSCCustomTagValueType; + +import kong.unirest.UnirestInstance; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; + +@RequiredArgsConstructor +public class SSCIssueCustomTagHelper { + private final UnirestInstance unirest; + private final String appVersionId; + + @Getter(lazy = true) + private final Map customTagInfoMap = loadCustomTagInfo(); + + public List processCustomTags(Map customTags) { + if (customTags == null || customTags.isEmpty()) { + return List.of(); + } + + Map tagInfoMap = getCustomTagInfoMap(); + + return customTags.entrySet().stream() + .map(entry -> { + String tagName = entry.getKey(); + String tagValue = entry.getValue(); + CustomTagInfo tagInfo = tagInfoMap.get(tagName.toLowerCase()); + if (tagInfo == null) { + throw new FcliSimpleException("Custom tag '" + tagName + "' is not available for this application version"); + } + return createAuditValue(tagName, tagValue, tagInfo); + }) + .collect(Collectors.toList()); + } + + public void populateCustomTagUpdates(Map customTags, ArrayNode customTagsArray) { + if (customTags == null || customTags.isEmpty()) { + return; + } + + Map tagInfoMap = getCustomTagInfoMap(); + + customTags.forEach((tagName, tagValue) -> { + CustomTagInfo tagInfo = tagInfoMap.get(tagName.toLowerCase()); + if (tagInfo == null) { + throw new FcliSimpleException("Custom tag '" + tagName + "' is not available for this application version"); + } + + String displayValue = tagValue == null || tagValue.isBlank() ? "" : tagValue; + String valueGuid = getValueGuidForTag(tagValue, tagInfo); + + ObjectNode tagNode = JsonHelper.getObjectMapper().createObjectNode(); + tagNode.put("customTagName", tagInfo.getName()); + tagNode.put("customTagGuid", tagInfo.getGuid()); + tagNode.put("value", displayValue); + if (valueGuid != null) { + tagNode.put("valueGuid", valueGuid); + } + customTagsArray.add(tagNode); + }); + } + + private String getValueGuidForTag(String value, CustomTagInfo tagInfo) { + if (value == null || value.isBlank()) { + return null; + } + + if (tagInfo.getValueType() == SSCCustomTagValueType.LIST) { + if (tagInfo.getValueList() != null) { + for (ValueListItem item : tagInfo.getValueList()) { + if (value.equalsIgnoreCase(item.getLookupValue())) { + return String.valueOf(item.getLookupIndex()); + } + } + } + } + + return null; + } + + private SSCIssueCustomTagAuditValue createAuditValue(String tagName, String value, CustomTagInfo tagInfo) { + String guid = tagInfo.getGuid(); + boolean isUnset = value == null || value.isBlank(); + switch (tagInfo.getValueType()) { + case TEXT: + if (isUnset) return SSCIssueCustomTagAuditValue.forText(guid, ""); + return SSCIssueCustomTagAuditValue.forText(guid, value); + case DECIMAL: + if (isUnset) return SSCIssueCustomTagAuditValue.forDecimal(guid, null); + try { + Double decimalValue = Double.parseDouble(value); + return SSCIssueCustomTagAuditValue.forDecimal(guid, decimalValue); + } catch (NumberFormatException e) { + throw new FcliSimpleException("Invalid decimal value '" + value + "' for custom tag '" + tagName + "'"); + } + case DATE: + if (isUnset) return SSCIssueCustomTagAuditValue.forDate(guid, ""); + String dateValue = processDateValue(value, tagName); + return SSCIssueCustomTagAuditValue.forDate(guid, dateValue); + case LIST: + if (isUnset) return SSCIssueCustomTagAuditValue.forList(guid, -1); + Integer lookupIndex = getListValueIndex(value, tagName, tagInfo); + return SSCIssueCustomTagAuditValue.forList(guid, lookupIndex); + default: + throw new FcliSimpleException("Unsupported custom tag value type: " + tagInfo.getValueType()); + } + } + + private String processDateValue(String value, String tagName) { + try { + LocalDate date = LocalDate.parse(value, DateTimeFormatter.ISO_LOCAL_DATE); + return date.format(DateTimeFormatter.ISO_LOCAL_DATE); + } catch (DateTimeParseException e) { + throw new FcliSimpleException("Invalid date format '" + value + "' for custom tag '" + tagName + "'. Expected format: yyyy-MM-dd"); + } + } + + private Integer getListValueIndex(String value, String tagName, CustomTagInfo tagInfo) { + if (tagInfo.getValueList() == null || tagInfo.getValueList().isEmpty()) { + throw new FcliSimpleException("Custom tag '" + tagName + "' has no valid list values configured"); + } + + for (ValueListItem item : tagInfo.getValueList()) { + if (value.equalsIgnoreCase(item.getLookupValue())) { + return item.getLookupIndex(); + } + } + + String validValues = tagInfo.getValueList().stream() + .map(ValueListItem::getLookupValue) + .collect(Collectors.joining(", ")); + + throw new FcliSimpleException("Invalid value '" + value + "' for list custom tag '" + tagName + "'. " + + "Valid values are: " + validValues); + } + + private Map loadCustomTagInfo() { + try { + JsonNode response = unirest.get(SSCUrls.PROJECT_VERSION_CUSTOM_TAGS(appVersionId)) + .asObject(JsonNode.class) + .getBody(); + + JsonNode dataArray = response.get("data"); + if (dataArray == null || !dataArray.isArray()) { + throw new FcliSimpleException("Invalid response from custom tags API"); + } + + Map result = new HashMap<>(); + + for (JsonNode tagNode : dataArray) { + CustomTagInfo tagInfo = parseCustomTagInfo(tagNode); + result.put(tagInfo.getName().toLowerCase(), tagInfo); + } + + return result; + } catch (Exception e) { + if (e instanceof FcliSimpleException) { + throw e; + } + throw new FcliSimpleException("Failed to load custom tag information: " + e.getMessage(), e); + } + } + + private CustomTagInfo parseCustomTagInfo(JsonNode tagNode) { + CustomTagInfo tagInfo = new CustomTagInfo(); + tagInfo.setGuid(tagNode.get("guid").asText()); + tagInfo.setName(tagNode.get("name").asText()); + tagInfo.setValueType(SSCCustomTagValueType.valueOf(tagNode.get("valueType").asText())); + + JsonNode valueListNode = tagNode.get("valueList"); + if (valueListNode != null && valueListNode.isArray()) { + for (JsonNode valueNode : valueListNode) { + ValueListItem item = new ValueListItem(); + item.setLookupIndex(valueNode.get("lookupIndex").asInt()); + item.setLookupValue(valueNode.get("lookupValue").asText()); + tagInfo.getValueList().add(item); + } + } + + return tagInfo; + } + + @Getter @Setter + public static class CustomTagInfo { + private String guid; + private String name; + private SSCCustomTagValueType valueType; + private List valueList = new java.util.ArrayList<>(); + } + + @Getter @Setter + public static class ValueListItem { + private int lookupIndex; + private String lookupValue; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueIdentifier.java b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueIdentifier.java new file mode 100644 index 0000000000..4ed6b2850b --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueIdentifier.java @@ -0,0 +1,54 @@ +/* + * 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.ssc.issue.helper; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.json.JsonHelper; + +import lombok.Data; +import lombok.experimental.Accessors; + +@JsonInclude(Include.NON_NULL) +@Reflectable +@Data @Accessors(fluent=true) +public final class SSCIssueIdentifier { + @JsonProperty("id") + private String id; + + @JsonProperty("revision") + private Integer revision; + + public static final SSCIssueIdentifier fromIdAndRevision(String id, Integer revision) { + return new SSCIssueIdentifier().id(id).revision(revision); + } + + public static final List fromIdList(List ids) { + return ids.stream() + .map(id -> SSCIssueIdentifier.fromIdAndRevision(id, null)) + .toList(); + } + + @Override + public String toString() { + try { + return JsonHelper.getObjectMapper().writeValueAsString(this); + } catch (Exception e) { + return super.toString(); + } + } +} \ No newline at end of file diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/i18n/SSCMessages.properties b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/i18n/SSCMessages.properties index dd5f7c5507..0e2a7da53c 100644 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/i18n/SSCMessages.properties +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/i18n/SSCMessages.properties @@ -503,6 +503,22 @@ fcli.ssc.issue.list.includeIssue = By default, only visible issues will be retur accepts a comma-separated list to allow (also) removed, suppressed and/or hidden issues to be returned, \ for example `--include visible,removed` (to return both visible and removed issues) or `--include \ removed` (to return only removed issues). Allowed values: ${COMPLETION-CANDIDATES}. +fcli.ssc.issue.update.usage.header = Update application version issues. +fcli.ssc.issue.update.usage.description = This command allows for updating SSC vulnerability data \ + for a given application version. You can assign issues to users, perform audit actions with \ + comments, custom tag updates, and suppression status. At least one of --custom-tags, --suppress, \ + --comment, or --assign-user must be specified along with the required --issue-ids. \ + To see the allowed tags for the specific application version, use: `fcli ssc tag get` +fcli.ssc.issue.update.issue-ids = Comma separated list of the vulnerability ids to be updated. +fcli.ssc.issue.update.custom-tags = Custom tag to set for the vulnerabilities. Format: tagName=value. \ + Can be specified multiple times or as comma-separated list (e.g., tag1=value1,tag2=value2). \ + For list type tags, use the exact lookup value. Pass empty value to unset (tagName=). \ + For date tags, use format: yyyy-MM-dd. +fcli.ssc.issue.update.suppress = Set the suppression status of the vulnerability. Use true to suppress or false to unsuppress. +fcli.ssc.issue.update.comment = A comment to apply to all the vulnerabilities that are updated. +fcli.ssc.issue.update.assign-user = The username or user id of the user to assign the issues to. +fcli.ssc.issue.update.output.table.args = issueIds,updatesString,action +fcli.ssc.issue.update.output.table.header.issueIds = Issue Id's fcli.ssc.issue.get-filter.usage.header = Get issue filter details. fcli.ssc.issue.filter = Technical or friendly filter as returned by the 'fcli ssc issue list-filters' command. fcli.ssc.issue.list-filters.usage.header = List application version issue filters. diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolGetCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolGetCommand.java index 5962e9fd9a..8ddbb0d56f 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolGetCommand.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolGetCommand.java @@ -19,13 +19,14 @@ import com.fortify.cli.tool._common.helper.Tool; import com.fortify.cli.tool._common.helper.ToolInstallationDescriptor; import com.fortify.cli.tool._common.helper.ToolInstallationOutputDescriptor; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionVersionDescriptor; import com.fortify.cli.tool.definitions.helper.ToolDefinitionsHelper; import picocli.CommandLine.Parameters; /** - * Abstract base class for tool 'get' commands that retrieve information about - * a specific tool version. Similar to AbstractToolListCommand but returns a + * Abstract base class for tool 'get' commands that retrieve information about + * a specific tool version. Similar to AbstractToolListCommand but returns a * single record instead of a list. * * Subclasses must implement: @@ -34,50 +35,77 @@ * @author Ruud Senden */ public abstract class AbstractToolGetCommand extends AbstractOutputCommand implements IJsonNodeSupplier { - + @Parameters(index = "0", descriptionKey = "fcli.tool.get.version") private String requestedVersion; - + @Override public final JsonNode getJsonNode() { - var toolName = getTool().getToolName(); - var toolDefinition = ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName); - + var tool = getTool(); + var toolName = tool.getToolName(); + var optDefinition = ToolDefinitionsHelper.tryGetToolDefinitionRootDescriptor(toolName); + + if (!tool.requiresToolDefinitions() && optDefinition.isEmpty()) { + return getJsonNodeWithoutDefinitions(toolName); + } + + var toolDefinition = optDefinition.orElseGet( + () -> ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName)); + // Resolve version (handles aliases like 'latest') var versionDescriptor = toolDefinition.getVersion(requestedVersion); - + // Load installation descriptor if tool is installed var installationDescriptor = ToolInstallationDescriptor.load(toolName, versionDescriptor); - + // Check if this is the default (last installed) version var lastInstalledDescriptor = ToolInstallationDescriptor.loadLastModified(toolName); boolean isDefault = isDefaultVersion(installationDescriptor, lastInstalledDescriptor); - + // Create output descriptor var outputDescriptor = new ToolInstallationOutputDescriptor( - toolName, - versionDescriptor, - installationDescriptor, - "", - isDefault - ); - + toolName, + versionDescriptor, + installationDescriptor, + "", + isDefault); + + return JsonHelper.getObjectMapper().valueToTree(outputDescriptor); + } + + private JsonNode getJsonNodeWithoutDefinitions(String toolName) { + ToolDefinitionVersionDescriptor versionDescriptor = new ToolDefinitionVersionDescriptor(); + versionDescriptor.setVersion(requestedVersion); + versionDescriptor.setStable(true); + versionDescriptor.setAliases(new String[0]); + + var installationDescriptor = ToolInstallationDescriptor.load(toolName, versionDescriptor); + var lastInstalledDescriptor = ToolInstallationDescriptor.loadLastModified(toolName); + boolean isDefault = isDefaultVersion(installationDescriptor, lastInstalledDescriptor); + + var outputDescriptor = new ToolInstallationOutputDescriptor( + toolName, + versionDescriptor, + installationDescriptor, + "", + isDefault); return JsonHelper.getObjectMapper().valueToTree(outputDescriptor); } - + @Override public final boolean isSingular() { return true; } - - private boolean isDefaultVersion(ToolInstallationDescriptor installationDescriptor, ToolInstallationDescriptor lastInstalledDescriptor) { + + private boolean isDefaultVersion(ToolInstallationDescriptor installationDescriptor, + ToolInstallationDescriptor lastInstalledDescriptor) { if (installationDescriptor == null || lastInstalledDescriptor == null) { return false; } - return installationDescriptor.getInstallDir() != null + return installationDescriptor.getInstallDir() != null && installationDescriptor.getInstallDir().equals(lastInstalledDescriptor.getInstallDir()); } - + /** * @return Tool enum entry for this tool */ diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolRunCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolRunCommand.java index 542adc17f1..33d3862ff3 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolRunCommand.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolRunCommand.java @@ -31,6 +31,8 @@ import com.fortify.cli.tool._common.helper.Tool; import com.fortify.cli.tool._common.helper.ToolInstallationDescriptor; import com.fortify.cli.tool._common.helper.ToolInstallationsResolver; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionVersionDescriptor; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionsHelper; import lombok.Getter; import lombok.SneakyThrows; @@ -59,22 +61,39 @@ public abstract class AbstractToolRunCommand extends AbstractRunnableCommand { private String workDir = System.getProperty("user.dir"); @Parameters(descriptionKey="fcli.tool.run.tool-args") @Getter private List toolArgs; - + @Override public final Integer call() throws Exception { + validateWorkingDirectory(); var descriptor = getToolInstallationDescriptor(); var baseCommands = new ArrayList<>(getBaseCommands(descriptor)); while (true) { try { + LOG.debug("Attempting to execute command: {}", baseCommands.get(0)); return call(baseCommands.get(0)); } catch ( Exception e ) { if ( baseCommands.size()==1) { throw e; } // No more base commands - LOG.debug("Command execution failed; trying fallback command"); + LOG.debug("Command execution failed ({}): {}; trying fallback command", + e.getClass().getSimpleName(), e.getMessage()); baseCommands.remove(0); } } } - + + private void validateWorkingDirectory() { + File workDirFile = new File(workDir); + if (!workDirFile.exists()) { + throw new FcliSimpleException(String.format( + "Working directory does not exist: %s", workDir + )); + } + if (!workDirFile.isDirectory()) { + throw new FcliSimpleException(String.format( + "Working directory path exists but is not a directory: %s", workDir + )); + } + } + private final Integer call(List baseCmd) throws Exception { if ( baseCmd==null ) { throw new FcliBugException("Base command to execute may not be null"); } var fullCmd = Stream.of(baseCmd, getToolArgs()) @@ -85,7 +104,7 @@ private final Integer call(List baseCmd) throws Exception { var pb = new ProcessBuilder() .command(fullCmd) .directory(new File(workDir)) - // .inheritIO(); + // .inheritIO(); // Can't use inheritIO as this as it may inherit original stdout/stderr, rather than // those created by OutputHelper.OutputType (for example through FcliCommandExecutor). // Instead, we use pipes and manually copy the output to current System.out/System.err. @@ -108,7 +127,7 @@ private final Integer call(List baseCmd) throws Exception { } return process.exitValue(); } - + private static void inheritIO(final InputStream src, final PrintStream dest) { new Thread(new Runnable() { @SneakyThrows @@ -119,7 +138,8 @@ public void run() { } private final ToolInstallationDescriptor getToolInstallationDescriptor() { - var installations = ToolInstallationsResolver.resolve(getTool()); + var tool = getTool(); + var installations = ToolInstallationsResolver.resolve(tool); var toolName = installations.tool().getToolName(); if (StringUtils.isBlank(versionToRun)) { return checkNotNull( @@ -128,6 +148,22 @@ private final ToolInstallationDescriptor getToolInstallationDescriptor() { .orElse(null), "No tool installations detected"); } + + // SCA: allow run without sca.yaml + if (!tool.requiresToolDefinitions() + && ToolDefinitionsHelper.tryGetToolDefinitionRootDescriptor(toolName).isEmpty()) { + var descriptor = installations.findByVersion(versionToRun) + .map(ToolInstallationsResolver.ToolInstallationRecord::installationDescriptor) + .orElseGet(() -> { + ToolDefinitionVersionDescriptor vd = new ToolDefinitionVersionDescriptor(); + vd.setVersion(versionToRun); + vd.setStable(true); + vd.setAliases(new String[0]); + return ToolInstallationDescriptor.load(toolName, vd); + }); + return checkNotNull(descriptor, "No tool installation detected for version " + versionToRun); + } + var versionDescriptor = installations.definition().getVersion(versionToRun); var descriptor = installations.findByVersion(versionDescriptor.getVersion()) .map(ToolInstallationsResolver.ToolInstallationRecord::installationDescriptor) @@ -141,7 +177,7 @@ private ToolInstallationDescriptor checkNotNull(ToolInstallationDescriptor descr } return descriptor; } - + protected abstract Tool getTool(); protected List> getBaseCommands(ToolInstallationDescriptor descriptor) { return List.of(getBaseCommand(descriptor)); @@ -150,5 +186,5 @@ protected List getBaseCommand(ToolInstallationDescriptor descriptor) { return null; } protected void updateProcessBuilder(ProcessBuilder pb) {}; - + } diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/Tool.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/Tool.java index f45ddf75ed..135fd8676e 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/Tool.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/Tool.java @@ -29,7 +29,8 @@ public enum Tool { FOD_UPLOADER(new ToolHelperFoDUploader(), "fod-uploader"), BUGTRACKER_UTILITY(new ToolHelperBugTrackerUtility(), "bugtracker-utility", "fbtu"), VULN_EXPORTER(new ToolHelperVulnExporter(), "vuln-exporter", "fve"), - DEBRICKED_CLI(new ToolHelperDebrickedCli(), "debricked-cli", "dcli"); + DEBRICKED_CLI(new ToolHelperDebrickedCli(), "debricked-cli", "dcli"), + SOURCE_ANALYZER(new ToolHelperSourceAnalyzer(), "sourceanalyzer"); private static final Map TOOL_NAME_MAP = new HashMap<>(); private static final Map TOOL_ALIAS_MAP = new HashMap<>(); @@ -101,7 +102,15 @@ public String getDefaultBinaryName() { public String getDefaultEnvPrefix() { return toolHelper.getDefaultEnvPrefix(); } - + + /** + * Determine if this tool requires tool definitions (e.g., for configuration). + * @return true if tool definitions are required, false otherwise + */ + public boolean requiresToolDefinitions() { + return toolHelper.requiresToolDefinitions(); + } + /** * Interface defining tool-specific helper methods. * Each tool implementation provides its own concrete helper class. @@ -113,6 +122,10 @@ public interface IToolHelper { default String getDefaultEnvPrefix() { return getToolName().toUpperCase().replace('-', '_'); } + + default boolean requiresToolDefinitions() { + return true; + } } /** @@ -231,4 +244,31 @@ public String getDefaultEnvPrefix() { return "DEBRICKED"; } } + + /** + * Helper implementation for sourceanalyzer tool. + */ + private static final class ToolHelperSourceAnalyzer implements IToolHelper { + private static final String TOOL_NAME = "sourceanalyzer"; + + @Override + public String getToolName() { + return TOOL_NAME; + } + + @Override + public String getDefaultBinaryName() { + return PlatformHelper.isWindows() ? "sourceanalyzer.exe" : "sourceanalyzer"; + } + + @Override + public String getDefaultEnvPrefix() { + return "SOURCEANALYZER"; + } + + @Override + public boolean requiresToolDefinitions() { + return false; + } + } } diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolInstallationsResolver.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolInstallationsResolver.java index cb74460852..c46002d92f 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolInstallationsResolver.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolInstallationsResolver.java @@ -32,17 +32,44 @@ * the new env command hierarchy. */ public final class ToolInstallationsResolver { - private ToolInstallationsResolver() {} + private ToolInstallationsResolver() { + } public static ToolInstallations resolve(Tool tool) { var toolName = tool.getToolName(); - var definition = ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName); - var lastInstalled = ToolInstallationDescriptor.loadLastModified(toolName); - var definedRecords = definition.getVersionsStream() - .map(vd -> createRecord(toolName, vd, lastInstalled, true)); - var unknownRecords = getUnknownRecords(toolName, definition, lastInstalled); - var records = Stream.concat(definedRecords, unknownRecords) - .toList(); + + // Non-SCA tools keep strict behavior + if (tool.requiresToolDefinitions()) { + var definition = ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName); + var lastInstalled = ToolInstallationDescriptor.loadLastModified(toolName); + var definedRecords = definition.getVersionsStream() + .map(vd -> createRecord(toolName, vd, lastInstalled, true)); + var unknownRecords = getUnknownRecords(toolName, definition, lastInstalled); + var records = Stream.concat(definedRecords, unknownRecords).toList(); + return new ToolInstallations(tool, definition, lastInstalled, records); + } + + // Tools with optional definitions: definitions may be absent + var optDef = ToolDefinitionsHelper.tryGetToolDefinitionRootDescriptor(toolName); + ToolDefinitionRootDescriptor definition; + ToolInstallationDescriptor lastInstalled = ToolInstallationDescriptor.loadLastModified(toolName); + Stream definedRecords; + Stream unknownRecords; + + if (optDef.isPresent()) { + definition = optDef.get(); + definedRecords = definition.getVersionsStream() + .map(vd -> createRecord(toolName, vd, lastInstalled, true)); + unknownRecords = getUnknownRecords(toolName, definition, lastInstalled); + } else { + // No sca.yaml: only installed versions, all treated as "known" + definition = buildSyntheticDefinitionFromInstallations(toolName); + definedRecords = definition.getVersionsStream() + .map(vd -> createRecord(toolName, vd, lastInstalled, true)); + unknownRecords = Stream.empty(); + } + + var records = Stream.concat(definedRecords, unknownRecords).toList(); return new ToolInstallations(tool, definition, lastInstalled, records); } @@ -71,7 +98,8 @@ private static boolean isUnknownVersion(String versionFileName, String toolName, if (versionFileName.equals(toolName)) { return false; } - // Special handling for "unknown" version - don't try to look it up in definitions + // Special handling for "unknown" version - don't try to look it up in + // definitions if ("unknown".equals(versionFileName)) { return true; } @@ -130,12 +158,15 @@ public static record ToolInstallations( public Stream stream() { return records.stream(); } + public Stream installedStream() { return records.stream().filter(ToolInstallationRecord::isInstalled); } + public Optional defaultInstallation() { return installedStream().filter(ToolInstallationRecord::isDefault).findFirst(); } + public Optional findByVersion(String version) { return records.stream() .filter(record -> record.versionDescriptor().getVersion().equals(version)) @@ -152,4 +183,28 @@ public boolean isInstalled() { return installationDescriptor != null && StringUtils.isNotBlank(installationDescriptor.getInstallDir()); } } + + private static ToolDefinitionRootDescriptor buildSyntheticDefinitionFromInstallations(String toolName) { + Path stateDir = ToolInstallationHelper.getToolsStatePath().resolve(toolName); + ToolDefinitionRootDescriptor def = new ToolDefinitionRootDescriptor(); + File[] versionFiles = Files.isDirectory(stateDir) + ? stateDir.toFile().listFiles(File::isFile) + : null; + if (versionFiles == null || versionFiles.length == 0) { + def.setVersions(new ToolDefinitionVersionDescriptor[0]); + return def; + } + ToolDefinitionVersionDescriptor[] versions = Stream.of(versionFiles) + .map(File::getName) + .map(vName -> { + ToolDefinitionVersionDescriptor vd = new ToolDefinitionVersionDescriptor(); + vd.setVersion(vName); + vd.setStable(true); + vd.setAliases(new String[0]); + return vd; + }) + .toArray(ToolDefinitionVersionDescriptor[]::new); + def.setVersions(versions); + return def; + } } diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolRegistrationHelper.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolRegistrationHelper.java index 73f285ebc5..4230464edf 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolRegistrationHelper.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/helper/ToolRegistrationHelper.java @@ -17,9 +17,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; import com.fortify.cli.common.exception.FcliSimpleException; import com.fortify.cli.common.util.FcliDataHelper; +import com.fortify.cli.tool.definitions.helper.ToolDefinitionRootDescriptor; import com.fortify.cli.tool.definitions.helper.ToolDefinitionVersionDescriptor; import com.fortify.cli.tool.definitions.helper.ToolDefinitionsHelper; @@ -33,7 +35,7 @@ * @author Ruud Senden */ public class ToolRegistrationHelper { - + /** * Find all potential tool binary candidates from fcli installed versions and provided paths. * Used when version filtering is needed - returns all candidates for version matching. @@ -201,11 +203,14 @@ public static class RegistrationContext { private final String defaultBinaryName; private final VersionDetector versionDetector; private RegistrationAction action; + private final boolean definitionsOptional; public RegistrationContext(String toolName, String defaultBinaryName, VersionDetector versionDetector) { this.toolName = toolName; this.defaultBinaryName = defaultBinaryName; this.versionDetector = versionDetector; + Tool tool = Tool.getByToolName(toolName); + this.definitionsOptional = tool != null && !tool.requiresToolDefinitions(); } /** @@ -276,12 +281,21 @@ private File findToolBinaryInSinglePath(String path) { } private File findExistingInstallation(String requestedVersion) { - var toolDefinition = ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName); + ToolDefinitionRootDescriptor toolDefinition; + if (!definitionsOptional) { + toolDefinition = getRequiredDefinition(); + } else { + toolDefinition = getOptionalDefinitionOrNull(); + if (toolDefinition == null) { + return null; + } + } // If specific version requested, look for matching installation if (!"any".equals(requestedVersion)) { - try { - var requestedVersionDescriptor = toolDefinition.getVersionOrDefault(requestedVersion); + var optionalVersionDescriptor = getOptionalVersionOrDefault(toolDefinition, requestedVersion); + if (optionalVersionDescriptor.isPresent()) { + var requestedVersionDescriptor = optionalVersionDescriptor.get(); var installation = ToolInstallationDescriptor.load(toolName, requestedVersionDescriptor); if (installation != null && installation.getBinPath() != null) { File binDir = installation.getBinPath().toFile(); @@ -291,8 +305,6 @@ private File findExistingInstallation(String requestedVersion) { return toolBinary; } } - } catch (IllegalArgumentException e) { - // Requested version not in definitions, will search paths } } else { // No version requested, try to find most recently registered installation @@ -320,25 +332,28 @@ private File findExistingInstallation(String requestedVersion) { } private File findToolBinaryInMultiplePaths(String[] paths, String requestedVersion) { - if (!"any".equals(requestedVersion)) { + boolean hasRequestedVersion = !"any".equals(requestedVersion); + boolean hasDefinitions = !definitionsOptional || getOptionalDefinitionOrNull() != null; + + if (hasRequestedVersion && hasDefinitions) { var candidates = findAllToolBinariesInPaths(toolName, defaultBinaryName, paths); - + File toolBinary = findMatchingCandidate(candidates, requestedVersion); if (toolBinary == null) { throw new FcliSimpleException( - String.format("%s version matching %s not found in specified paths", - toolName, requestedVersion)); + String.format("%s version matching %s not found in specified paths", + toolName, requestedVersion)); } action = RegistrationAction.REGISTERED; return toolBinary; } else { File toolBinary = findToolBinaryInPaths(toolName, defaultBinaryName, paths); - + if (toolBinary == null) { throw new FcliSimpleException( - toolName + " not found in specified paths"); + toolName + " not found in specified paths"); } - + validateBinaryExecutable(toolBinary); action = RegistrationAction.REGISTERED; return toolBinary; @@ -372,18 +387,45 @@ private String detectVersionFromBinary(File toolBinary, File installDir) { } private void validateVersionMatch(ToolDefinitionVersionDescriptor versionDescriptor, String requestedVersion) { - var toolDefinition = ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName); - try { - var requestedVersionDescriptor = toolDefinition.getVersionOrDefault(requestedVersion); + if (!definitionsOptional) { + var toolDefinition = getRequiredDefinition(); + var optionalRequestedVersion = getOptionalVersionOrDefault(toolDefinition, requestedVersion); + if (optionalRequestedVersion.isEmpty()) { + throw new FcliSimpleException( + String.format("Requested version %s not found in tool definitions. Detected version is %s", + requestedVersion, versionDescriptor.getVersion())); + } + var requestedVersionDescriptor = optionalRequestedVersion.get(); if (!versionDescriptor.getVersion().equals(requestedVersionDescriptor.getVersion())) { throw new FcliSimpleException( - String.format("Detected %s version %s does not match requested version %s (resolves to %s)", - toolName, versionDescriptor.getVersion(), requestedVersion, requestedVersionDescriptor.getVersion())); + String.format("Detected %s version %s does not match requested version %s (resolves to %s)", + toolName, versionDescriptor.getVersion(), requestedVersion, + requestedVersionDescriptor.getVersion())); } - } catch (IllegalArgumentException e) { + return; + } + + // SCA with optional definitions: try strict check if definitions exist, else + // skip + var toolDefinition = getOptionalDefinitionOrNull(); + if (toolDefinition == null) { + // No definitions → accept any detected version for requestedVersion + return; + } + var optionalRequestedVersion = getOptionalVersionOrDefault(toolDefinition, requestedVersion); + if (optionalRequestedVersion.isEmpty()) { + // Without definitions, we already returned; if we got here, behave like strict + // mode throw new FcliSimpleException( - String.format("Requested version %s not found in tool definitions. Detected version is %s", - requestedVersion, versionDescriptor.getVersion())); + String.format("Requested version %s not found in tool definitions. Detected version is %s", + requestedVersion, versionDescriptor.getVersion())); + } + var requestedVersionDescriptor = optionalRequestedVersion.get(); + if (!versionDescriptor.getVersion().equals(requestedVersionDescriptor.getVersion())) { + throw new FcliSimpleException( + String.format("Detected %s version %s does not match requested version %s (resolves to %s)", + toolName, versionDescriptor.getVersion(), requestedVersion, + requestedVersionDescriptor.getVersion())); } } @@ -394,6 +436,8 @@ private ToolInstallationDescriptor createAndSaveInstallation(File toolBinary, To toolBinary.getParentFile().toPath(), null ); + // JRE configuration is not set during registration - runtime will use ENV_VAR resolution + // with fallbacks to JAVA_HOME and PATH. This allows maximum flexibility for registered tools. // Always save descriptor to update timestamp, making this the default version for 'tool run' commands installation.save(toolName, versionDescriptor); return installation; @@ -401,13 +445,12 @@ private ToolInstallationDescriptor createAndSaveInstallation(File toolBinary, To private File findMatchingCandidate(List candidates, String requestedVersion) { var toolDefinition = ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName); - ToolDefinitionVersionDescriptor requestedVersionDescriptor; - try { - requestedVersionDescriptor = toolDefinition.getVersionOrDefault(requestedVersion); - } catch (IllegalArgumentException e) { + var optionalVersionDescriptor = getOptionalVersionOrDefault(toolDefinition, requestedVersion); + if (optionalVersionDescriptor.isEmpty()) { throw new FcliSimpleException( String.format("Requested version %s not found in tool definitions", requestedVersion)); } + var requestedVersionDescriptor = optionalVersionDescriptor.get(); for (File candidate : candidates) { if (!candidate.canExecute() && !candidate.getName().endsWith(".jar")) { @@ -435,9 +478,28 @@ private File findMatchingCandidate(List candidates, String requestedVersio } private ToolDefinitionVersionDescriptor resolveVersionDescriptor(String detectedVersion) { - var toolDefinition = ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName); - - // If version is unknown, create synthetic descriptor immediately without normalization + // Decide how to obtain the definition + ToolDefinitionRootDescriptor toolDefinition; + if (!definitionsOptional) { + toolDefinition = getRequiredDefinition(); // strict: throws if missing + } else { + toolDefinition = getOptionalDefinitionOrNull(); // may be null for SCA + } + + // If we are in definitions-optional mode and no definitions exist: + if (definitionsOptional && toolDefinition == null) { + ToolDefinitionVersionDescriptor syntheticDescriptor = new ToolDefinitionVersionDescriptor(); + syntheticDescriptor.setVersion(detectedVersion); + syntheticDescriptor.setStable(true); + syntheticDescriptor.setAliases(new String[0]); + return syntheticDescriptor; + } + + // From here on, toolDefinition is non-null (either strict mode or + // optional+present) + + // If version is unknown, create synthetic descriptor immediately without + // normalization if ("unknown".equals(detectedVersion)) { ToolDefinitionVersionDescriptor syntheticDescriptor = new ToolDefinitionVersionDescriptor(); syntheticDescriptor.setVersion("unknown"); @@ -445,22 +507,48 @@ private ToolDefinitionVersionDescriptor resolveVersionDescriptor(String detected syntheticDescriptor.setAliases(new String[0]); return syntheticDescriptor; } - - // Normalize version format to match tool definitions (e.g., 24.2.0.0050 -> 24.2.0) + + // Normalize version format to match tool definitions (e.g., 24.2.0.0050 -> + // 24.2.0) String normalizedVersion = toolDefinition.normalizeVersionFormat(detectedVersion); - + // Try to find matching version in tool definitions using normalized version + return getOptionalVersion(toolDefinition, normalizedVersion) + .orElseGet(() -> { + // Version not found in definitions, create synthetic descriptor with normalized + // version + ToolDefinitionVersionDescriptor syntheticDescriptor = new ToolDefinitionVersionDescriptor(); + syntheticDescriptor.setVersion(normalizedVersion); + syntheticDescriptor.setStable(true); + syntheticDescriptor.setAliases(new String[0]); + return syntheticDescriptor; + }); + } + + private Optional getOptionalVersionOrDefault( + ToolDefinitionRootDescriptor toolDefinition, String versionOrAlias) { try { - return toolDefinition.getVersion(normalizedVersion); - } catch (IllegalArgumentException e) { - // Version not found in definitions, create synthetic descriptor with normalized version - ToolDefinitionVersionDescriptor syntheticDescriptor = new ToolDefinitionVersionDescriptor(); - syntheticDescriptor.setVersion(normalizedVersion); - syntheticDescriptor.setStable(true); - syntheticDescriptor.setAliases(new String[0]); - return syntheticDescriptor; + return Optional.of(toolDefinition.getVersionOrDefault(versionOrAlias)); + } catch (FcliSimpleException e) { + return Optional.empty(); } } + + private Optional getOptionalVersion( + ToolDefinitionRootDescriptor toolDefinition, String versionOrAlias) { + try { + return Optional.of(toolDefinition.getVersion(versionOrAlias)); + } catch (FcliSimpleException e) { + return Optional.empty(); + } + } + private ToolDefinitionRootDescriptor getRequiredDefinition() { + return ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName); + } + + private ToolDefinitionRootDescriptor getOptionalDefinitionOrNull() { + return ToolDefinitionsHelper.tryGetToolDefinitionRootDescriptor(toolName).orElse(null); + } } /** diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_main/cli/cmd/ToolCommands.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_main/cli/cmd/ToolCommands.java index ed99ffd473..1137166aca 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_main/cli/cmd/ToolCommands.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_main/cli/cmd/ToolCommands.java @@ -13,6 +13,9 @@ package com.fortify.cli.tool._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import com.fortify.cli.tool.bugtracker_utility.cli.cmd.ToolBugTrackerUtilityCommands; import com.fortify.cli.tool.debricked_cli.cli.cmd.ToolDebrickedCliCommands; import com.fortify.cli.tool.definitions.cli.cmd.ToolDefinitionsCommands; @@ -20,10 +23,13 @@ import com.fortify.cli.tool.fcli.cli.cmd.ToolFcliCommands; import com.fortify.cli.tool.fod_uploader.cli.cmd.ToolFoDUploaderCommands; import com.fortify.cli.tool.sc_client.cli.cmd.ToolSCClientCommands; +import com.fortify.cli.tool.sourceanalyzer.cli.cmd.ToolSourceAnalyzerCommands; import com.fortify.cli.tool.vuln_exporter.cli.cmd.ToolVulnExporterCommands; import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) +@RelatedModules({"ssc","fod"}) @Command( name = "tool", resourceBundle = "com.fortify.cli.tool.i18n.ToolMessages", @@ -34,6 +40,7 @@ ToolFcliCommands.class, ToolFoDUploaderCommands.class, ToolSCClientCommands.class, + ToolSourceAnalyzerCommands.class, ToolVulnExporterCommands.class, ToolDefinitionsCommands.class } diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java index 51b030a401..8c99ab07c2 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/definitions/helper/ToolDefinitionsHelper.java @@ -25,6 +25,7 @@ import java.util.Enumeration; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -60,6 +61,7 @@ public final class ToolDefinitionsHelper { /** * List current tool definitions. + * * @return List of tool definitions output descriptors */ public static final List listToolDefinitions() { @@ -68,16 +70,21 @@ public static final List listToolDefinitions() addYamlOutputDescriptors(result); return result; } - + /** - * Update tool definitions from the specified source if needed based on forceUpdate and maxAge. - * @param source Tool definitions source zip URL or file path; if null or blank, default URL is used + * Update tool definitions from the specified source if needed based on + * forceUpdate and maxAge. + * + * @param source Tool definitions source zip URL or file path; if null or + * blank, default URL is used * @param forceUpdate If true, always update regardless of age - * @param maxAge Optional max age string (e.g., "4h", "1d"); if null, default max age of 6 hours is used + * @param maxAge Optional max age string (e.g., "4h", "1d"); if null, + * default max age of 6 hours is used * @return */ @SneakyThrows - public static final List updateToolDefinitions(String source, boolean forceUpdate, String maxAge) { + public static final List updateToolDefinitions(String source, boolean forceUpdate, + String maxAge) { String normalizedSource = normalizeSource(source); boolean shouldUpdate = shouldUpdateToolDefinitions(forceUpdate, maxAge); if (shouldUpdate) { @@ -91,6 +98,7 @@ public static final List updateToolDefinitions( /** * Reset tool definitions to internal defaults by deleting state files. + * * @return List of tool definitions output descriptors after reset */ @SneakyThrows @@ -117,7 +125,8 @@ private static final void createDefinitionsStateDir(Path dir) throws IOException } private static final FileTime getModifiedTime(Path path) throws IOException { - if (!Files.exists(path)) return null; + if (!Files.exists(path)) + return null; return Files.getLastModifiedTime(path); } @@ -145,13 +154,16 @@ private static final ToolDefinitionsStateDescriptor update(String source, Path d } /** - * Validates that a local file is a valid ZIP file containing at least one expected tool definition YAML. + * Validates that a local file is a valid ZIP file containing at least one + * expected tool definition YAML. *

- * The merge logic will handle missing required files by falling back to state directory + * The merge logic will handle missing required files by falling back to state + * directory * or internal resources, and will ignore any unknown files in the ZIP. * * @param source the file path to validate - * @return true if the file exists, is a valid ZIP, and contains at least one required YAML file + * @return true if the file exists, is a valid ZIP, and contains at least one + * required YAML file * @throws FcliSimpleException if an I/O error occurs while reading the ZIP file */ private static boolean isValidZip(String source) { @@ -177,45 +189,52 @@ private static boolean isValidZip(String source) { return false; // No required files found } - /** - * Merges tool definition YAML files from multiple sources into a single destination ZIP file. + * Merges tool definition YAML files from multiple sources into a single + * destination ZIP file. *

- * This method searches for required tool definition YAML files in the following priority order: + * This method searches for required tool definition YAML files in the following + * priority order: *

    *
  1. User-specified ZIP file (source parameter)
  2. *
  3. Existing state directory ZIP file
  4. *
  5. Internal resource ZIP file embedded in the fcli JAR
  6. *
- * For each required YAML file, the first location where it's found is used. This allows users - * to override specific tool definitions while falling back to previously downloaded or built-in + * For each required YAML file, the first location where it's found is used. + * This allows users + * to override specific tool definitions while falling back to previously + * downloaded or built-in * definitions for tools they haven't customized. * - * @param dest the destination ZIP file path where merged definitions will be written - * @param source the user-specified source ZIP file path, or null to use only state/internal sources - * @throws FcliSimpleException if the user-provided ZIP doesn't contain any required YAML files, - * or if I/O errors occur during processing + * @param dest the destination ZIP file path where merged definitions will be + * written + * @param source the user-specified source ZIP file path, or null to use only + * state/internal sources + * @throws FcliSimpleException if the user-provided ZIP doesn't contain any + * required YAML files, + * or if I/O errors occur during processing */ @SneakyThrows private static void mergeDefinitionsZip(Path dest, String source) { if (StringUtils.isNotBlank(source)) { validateUserZipContainsRequiredFiles(source); } - + createDefinitionsStateDir(DEFINITIONS_STATE_DIR); - - // If dest already exists and we're about to overwrite it, move it to temp location + + // If dest already exists and we're about to overwrite it, move it to temp + // location // so we can use it as a fallback source Path existingStateZip = null; if (Files.exists(dest) && dest.equals(DEFINITIONS_STATE_ZIP)) { existingStateZip = DEFINITIONS_STATE_DIR.resolve(".tool-definitions.yaml.zip.old"); Files.move(dest, existingStateZip, java.nio.file.StandardCopyOption.REPLACE_EXISTING); } - + try { createMergedZipFile(dest, source, existingStateZip); Files.setLastModifiedTime(dest, FileTime.fromMillis(System.currentTimeMillis())); - + // Clean up temp file if it exists if (existingStateZip != null && Files.exists(existingStateZip)) { Files.delete(existingStateZip); @@ -228,16 +247,16 @@ private static void mergeDefinitionsZip(Path dest, String source) { throw e; } } - + private static void validateUserZipContainsRequiredFiles(String source) throws IOException { Path sourcePath = Path.of(source); if (!Files.exists(sourcePath)) { throw new FcliSimpleException("ZIP file not found: " + sourcePath); } - + Set requiredYamlFiles = getRequiredYamlFileNames(); boolean foundAtLeastOne = false; - + try (ZipFile zipFile = new ZipFile(sourcePath.toFile())) { Enumeration entries = zipFile.entries(); while (entries.hasMoreElements()) { @@ -253,13 +272,14 @@ private static void validateUserZipContainsRequiredFiles(String source) throws I } catch (IOException e) { throw new FcliSimpleException("Invalid or corrupted ZIP file: " + sourcePath, e); } - + if (!foundAtLeastOne) { - throw new FcliSimpleException("ZIP file does not contain any expected tool definition files. Expected files: " - + String.join(", ", requiredYamlFiles)); + throw new FcliSimpleException( + "ZIP file does not contain any expected tool definition files. Expected files: " + + String.join(", ", requiredYamlFiles)); } } - + private static void createMergedZipFile(Path dest, String source, Path existingStateZip) throws IOException { try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(dest))) { for (String yamlFileName : getRequiredYamlFileNames()) { @@ -267,31 +287,33 @@ private static void createMergedZipFile(Path dest, String source, Path existingS } } } - - private static void copyYamlFileFromFirstAvailableSource(String yamlFileName, String userSource, + + private static void copyYamlFileFromFirstAvailableSource(String yamlFileName, String userSource, Path existingStateZip, java.util.zip.ZipOutputStream zos) throws IOException { // Try user-provided source first if (StringUtils.isNotBlank(userSource) && copyYamlFromZipToZip(Path.of(userSource), yamlFileName, zos)) { return; } // Fall back to existing state ZIP (if provided) - if (existingStateZip != null && Files.exists(existingStateZip) + if (existingStateZip != null && Files.exists(existingStateZip) && copyYamlFromZipToZip(existingStateZip, yamlFileName, zos)) { return; } // Fall back to internal resource copyYamlFromResourceZipToZip(DEFINITIONS_INTERNAL_ZIP, yamlFileName, zos); } + /** * Copies a specific YAML file from a ZIP file to an output ZIP stream. * - * @param zipPath the source ZIP file path + * @param zipPath the source ZIP file path * @param yamlFileName the name of the YAML file to copy - * @param zos the destination ZIP output stream + * @param zos the destination ZIP output stream * @return true if the file was found and copied, false if not found * @throws IOException if an I/O error occurs during reading or writing */ - private static boolean copyYamlFromZipToZip(Path zipPath, String yamlFileName, java.util.zip.ZipOutputStream zos) throws IOException { + private static boolean copyYamlFromZipToZip(Path zipPath, String yamlFileName, java.util.zip.ZipOutputStream zos) + throws IOException { if (!Files.exists(zipPath)) { return false; } @@ -317,16 +339,19 @@ private static boolean copyYamlFromZipToZip(Path zipPath, String yamlFileName, j } /** - * Copies a specific YAML file from an internal resource ZIP to an output ZIP stream. + * Copies a specific YAML file from an internal resource ZIP to an output ZIP + * stream. * - * @param resourceZip the resource path of the internal ZIP file + * @param resourceZip the resource path of the internal ZIP file * @param yamlFileName the name of the YAML file to copy - * @param zos the destination ZIP output stream + * @param zos the destination ZIP output stream * @return true if the file was found and copied, false if not found * @throws IOException if an I/O error occurs during reading or writing */ - private static boolean copyYamlFromResourceZipToZip(String resourceZip, String yamlFileName, java.util.zip.ZipOutputStream zos) throws IOException { - try (InputStream is = FileUtils.getResourceInputStream(resourceZip); ZipInputStream zis = new ZipInputStream(is)) { + private static boolean copyYamlFromResourceZipToZip(String resourceZip, String yamlFileName, + java.util.zip.ZipOutputStream zos) throws IOException { + try (InputStream is = FileUtils.getResourceInputStream(resourceZip); + ZipInputStream zis = new ZipInputStream(is)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { if (!entry.isDirectory() && Path.of(entry.getName()).getFileName().toString().equals(yamlFileName)) { @@ -339,8 +364,8 @@ private static boolean copyYamlFromResourceZipToZip(String resourceZip, String y zos.closeEntry(); return true; } - } } + } return false; } @@ -359,6 +384,24 @@ public static final ToolDefinitionRootDescriptor getToolDefinitionRootDescriptor } } + public static final Optional tryGetToolDefinitionRootDescriptor(String toolName) { + String yamlFileName = toolName + ".yaml"; + try (InputStream is = getToolDefinitionsInputStream(); ZipInputStream zis = new ZipInputStream(is)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (yamlFileName.equals(entry.getName())) { + ToolDefinitionRootDescriptor descriptor = yamlObjectMapper.readValue(zis, + ToolDefinitionRootDescriptor.class); + return Optional.of(descriptor); + } + } + // No matching YAML entry → no definitions for this tool + return Optional.empty(); + } catch (IOException e) { + throw new FcliSimpleException("Error loading tool definitions", e); + } + } + private static final InputStream getToolDefinitionsInputStream() throws IOException { return Files.exists(DEFINITIONS_STATE_ZIP) ? Files.newInputStream(DEFINITIONS_STATE_ZIP) : FileUtils.getResourceInputStream(DEFINITIONS_INTERNAL_ZIP); @@ -368,18 +411,19 @@ private static final void addZipOutputDescriptor(List result, boolean shouldUpdate) { + private static final void addZipOutputDescriptor(List result, + boolean shouldUpdate) { var stateDescriptor = FcliDataHelper.readFile(DESCRIPTOR_PATH, ToolDefinitionsStateDescriptor.class, false); String actionResult = determineActionResult(stateDescriptor, shouldUpdate); - + if (stateDescriptor != null) { result.add(new ToolDefinitionsOutputDescriptor(ZIP_FILE_NAME, stateDescriptor, actionResult)); } else { - result.add(new ToolDefinitionsOutputDescriptor(ZIP_FILE_NAME, "INTERNAL", + result.add(new ToolDefinitionsOutputDescriptor(ZIP_FILE_NAME, "INTERNAL", FcliBuildProperties.INSTANCE.getFcliBuildDate(), actionResult)); } } - + private static String determineActionResult(ToolDefinitionsStateDescriptor stateDescriptor, boolean shouldUpdate) { if (stateDescriptor == null) { return "RESET"; @@ -389,9 +433,8 @@ private static String determineActionResult(ToolDefinitionsStateDescriptor state private static Set getRequiredYamlFileNames() { var toolNames = Stream.concat( - Arrays.stream(Tool.values()).map(Tool::getToolName), - Arrays.stream(ToolDependency.values()).map(ToolDependency::getToolName) - ); + Arrays.stream(Tool.values()).map(Tool::getToolName), + Arrays.stream(ToolDependency.values()).map(ToolDependency::getToolName)); return toolNames.map(s -> s + ".yaml").collect(Collectors.toSet()); } @@ -400,7 +443,8 @@ private static final void addYamlOutputDescriptors(List requiredYamlNames = getRequiredYamlFileNames(); if (!shouldUpdate) { addYamlDescriptor(result, requiredYamlNames, "SKIPPED_BY_AGE"); - } - else if (source != null && source.contains("https://")) { + } else if (source != null && source.contains("https://")) { addYamlDescriptor(result, requiredYamlNames, "UPDATED"); - } - else { + } else { Set foundYamlNames = new HashSet<>(); String zipPathOnly = source != null - ? Path.of(source).getFileName().toString() - : null; + ? Path.of(source).getFileName().toString() + : null; if (source != null) { updateActionResultForUserFile(result, requiredYamlNames, foundYamlNames, zipPathOnly, source); } @@ -448,12 +490,12 @@ private static void updateActionResultForUserFile(List result, Set requiredYamlNames, Set foundYamlNames, String zipPathOnly) { String name = Path.of(entry.getName()).getFileName().toString(); Date lastModified = getEntryLastModified(entry); - + if (requiredYamlNames.contains(name)) { result.add(new ToolDefinitionsOutputDescriptor(name, zipPathOnly, lastModified, "UPDATED")); foundYamlNames.add(name); @@ -461,11 +503,11 @@ private static void processUserZipEntry(ZipEntry entry, List result, String fileName) { Date lastModified = getFileOrResourceLastModified(fileName); result.add(new ToolDefinitionsOutputDescriptor(fileName, ZIP_FILE_NAME, lastModified, "NOT_PRESENT")); } - + private static Date getFileOrResourceLastModified(String fileName) { Path filePath = DEFINITIONS_STATE_DIR.resolve(fileName); try { @@ -501,7 +543,8 @@ private static void addYamlDescriptor(List resu while ((entry = zis.getNextEntry()) != null) { String name = Path.of(entry.getName()).getFileName().toString(); if (requiredYamlNames.contains(name)) { - result.add(new ToolDefinitionsOutputDescriptor(name, ZIP_FILE_NAME, getEntryLastModified(entry), action)); + result.add(new ToolDefinitionsOutputDescriptor(name, ZIP_FILE_NAME, getEntryLastModified(entry), + action)); } } } catch (IOException e) { @@ -509,14 +552,14 @@ private static void addYamlDescriptor(List resu } } - private static Date getInternalResourceZipEntryLastModified(String fileName) { try (InputStream is = FileUtils.getResourceInputStream(DEFINITIONS_INTERNAL_ZIP); ZipInputStream zis = new ZipInputStream(is)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { if (entry.getName().equals(fileName)) { - return entry.getLastModifiedTime() != null ? new Date(entry.getLastModifiedTime().toMillis()) : null; + return entry.getLastModifiedTime() != null ? new Date(entry.getLastModifiedTime().toMillis()) + : null; } } } catch (IOException e) { @@ -526,13 +569,16 @@ private static Date getInternalResourceZipEntryLastModified(String fileName) { } /** - * Determines whether tool definitions should be updated based on force flag or age. + * Determines whether tool definitions should be updated based on force flag or + * age. *

- * If force is true, always returns true. If maxAge is specified, checks if current + * If force is true, always returns true. If maxAge is specified, checks if + * current * definitions are older than that age. Otherwise, uses default age of 6 hours. * * @param forceUpdate if true, always update regardless of age - * @param maxAge optional max age string (e.g., "4h", "1d"), or null to use default + * @param maxAge optional max age string (e.g., "4h", "1d"), or null to use + * default * @return true if definitions should be updated, false otherwise * @throws IOException if unable to determine file modification time */ @@ -543,16 +589,16 @@ private static boolean shouldUpdateToolDefinitions(boolean forceUpdate, String m if (!Files.exists(DEFINITIONS_STATE_ZIP)) { return true; } - + FileTime modTime = getModifiedTime(DEFINITIONS_STATE_ZIP); if (modTime == null) { throw new FcliSimpleException("Could not determine last modified time for: " + DEFINITIONS_STATE_ZIP); } - + long ageThresholdMillis = StringUtils.isNotBlank(maxAge) - ? parseDurationToMillis(maxAge) - : DEFAULT_UPDATE_AGE_HOURS * 60 * 60 * 1000; - + ? parseDurationToMillis(maxAge) + : DEFAULT_UPDATE_AGE_HOURS * 60 * 60 * 1000; + long now = System.currentTimeMillis(); long age = now - modTime.toMillis(); return age > ageThresholdMillis; @@ -561,12 +607,14 @@ private static boolean shouldUpdateToolDefinitions(boolean forceUpdate, String m /** * Parses a duration string to milliseconds using only days, hours, and minutes. *

- * Supported format examples: "1d" (1 day), "4h" (4 hours), "30m" (30 minutes), "1d4h" (1 day 4 hours). + * Supported format examples: "1d" (1 day), "4h" (4 hours), "30m" (30 minutes), + * "1d4h" (1 day 4 hours). * Seconds are explicitly not supported to avoid confusion with "6h" default. * * @param duration the duration string to parse * @return the duration in milliseconds - * @throws FcliSimpleException if the format is invalid or contains unsupported units + * @throws FcliSimpleException if the format is invalid or contains unsupported + * units */ private static long parseDurationToMillis(String duration) { try { @@ -574,7 +622,8 @@ private static long parseDurationToMillis(String duration) { var helper = DateTimePeriodHelper.byRange(Period.MINUTES, Period.DAYS); return helper.parsePeriodToMillis(duration); } catch (IllegalArgumentException e) { - throw new FcliSimpleException("Invalid duration format: " + duration + ". Use only d (days), h (hours), m (minutes)", e); + throw new FcliSimpleException( + "Invalid duration format: " + duration + ". Use only d (days), h (hours), m (minutes)", e); } } diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerCommands.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerCommands.java new file mode 100644 index 0000000000..7464ecd082 --- /dev/null +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerCommands.java @@ -0,0 +1,39 @@ +/* + * 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.tool.sourceanalyzer.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +/** + * Container command for all 'fcli tool sourceanalyzer' subcommands. + * + * @author Sangamesh Vijaykumar + */ +@Command( + name = ToolSourceAnalyzerCommands.TOOL_NAME, + subcommands = { + ToolSourceAnalyzerListCommand.class, + ToolSourceAnalyzerGetCommand.class, + ToolSourceAnalyzerRegisterCommand.class, + ToolSourceAnalyzerRunCommand.class, + ToolSourceAnalyzerUpdateRulePacksCommand.class + } + +) +public class ToolSourceAnalyzerCommands extends AbstractContainerCommand { + static final String TOOL_NAME = "sourceanalyzer"; + static final String[] TOOL_ENV_VAR_PREFIXES = {"SOURCEANALYZER"}; + +} \ No newline at end of file diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerGetCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerGetCommand.java new file mode 100644 index 0000000000..d03aa09cf1 --- /dev/null +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerGetCommand.java @@ -0,0 +1,36 @@ +/* + * 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.tool.sourceanalyzer.cli.cmd; + +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.tool._common.cli.cmd.AbstractToolGetCommand; +import com.fortify.cli.tool._common.helper.Tool; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +/** + * Command to get information about a specific Fortify Source Analyzer version. + * + * @author Sangamesh Vijaykumar + */ +@Command(name = OutputHelperMixins.Get.CMD_NAME) +public class ToolSourceAnalyzerGetCommand extends AbstractToolGetCommand { + @Getter @Mixin private OutputHelperMixins.Get outputHelper; + + @Override + protected final Tool getTool() { + return Tool.SOURCE_ANALYZER; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerListCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerListCommand.java new file mode 100644 index 0000000000..92b23abbc5 --- /dev/null +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerListCommand.java @@ -0,0 +1,36 @@ +/* + * 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.tool.sourceanalyzer.cli.cmd; + +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.tool._common.cli.cmd.AbstractToolListCommand; +import com.fortify.cli.tool._common.helper.Tool; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +/** + * Command to list available and installed Fortify Source Analyzer versions. + * + * @author Sangamesh Vijaykumar + */ +@Command(name = OutputHelperMixins.List.CMD_NAME) +public class ToolSourceAnalyzerListCommand extends AbstractToolListCommand { + @Getter @Mixin private OutputHelperMixins.List outputHelper; + + @Override + protected final Tool getTool() { + return Tool.SOURCE_ANALYZER; + } +} diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerRegisterCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerRegisterCommand.java new file mode 100644 index 0000000000..dcd5280b4a --- /dev/null +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerRegisterCommand.java @@ -0,0 +1,49 @@ +/* + * 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.tool.sourceanalyzer.cli.cmd; + +import java.io.File; + +import com.fortify.cli.tool._common.cli.cmd.AbstractToolRegisterCommand; +import com.fortify.cli.tool._common.helper.Tool; +import com.fortify.cli.tool._common.helper.ToolVersionDetector; + +import picocli.CommandLine.Command; + +/** + * Command to register a specific Fortify Source Analyzer version. + * + * @author Sangamesh Vijaykumar + */ +@Command(name = "register") +public class ToolSourceAnalyzerRegisterCommand extends AbstractToolRegisterCommand { + + @Override + protected final Tool getTool() { + return Tool.SOURCE_ANALYZER; + } + + @Override + protected String detectVersion(File toolBinary, File installDir) { + // Execute sourceanalyzer --version + String output = ToolVersionDetector.tryExecute(toolBinary, "--version"); + if (output != null) { + String version = ToolVersionDetector.extractVersionFromOutput(output); + if (version != null) { + return version; + } + } + + return "unknown"; + } +} \ No newline at end of file diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerRunCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerRunCommand.java new file mode 100644 index 0000000000..9ba64baa51 --- /dev/null +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerRunCommand.java @@ -0,0 +1,48 @@ +/* + * 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.tool.sourceanalyzer.cli.cmd; + +import java.util.List; + +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.common.util.PlatformHelper; +import com.fortify.cli.tool._common.cli.cmd.AbstractToolRunCommand; +import com.fortify.cli.tool._common.helper.Tool; +import com.fortify.cli.tool._common.helper.ToolInstallationDescriptor; + +import lombok.Getter; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; + +/** + * Command for running Fortify Source Analyzer. This command allows for running Fortify Source Analyzer as already installed in the user's machine, \ + * and is not limited to running versions of Fortify Source Analyzer that were installed through the 'fcli tool sourceanalyzer install' command. It is recommended to use double dashes to separate fcli options from Fortify Source Analyzer options, \ + * i.e., 'fcli tool sourceanalyzer run -- ' to explicitly differentiate between fcli options and Fortify Source Analyzer options. + * + * @author Sangamesh Vijaykumar + */ +@Command(name = "run") +public class ToolSourceAnalyzerRunCommand extends AbstractToolRunCommand { + @Getter @Mixin private OutputHelperMixins.Get outputHelper; + + @Override + protected final Tool getTool() { + return Tool.SOURCE_ANALYZER; + } + + @Override + protected List getBaseCommand(ToolInstallationDescriptor descriptor) { + var baseCmd = PlatformHelper.isWindows() ? "sourceanalyzer.exe" : "sourceanalyzer"; + return List.of(descriptor.getBinPath().resolve(baseCmd).toString()); + } +} diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerUpdateRulePacksCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerUpdateRulePacksCommand.java new file mode 100644 index 0000000000..d6274e1893 --- /dev/null +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerUpdateRulePacksCommand.java @@ -0,0 +1,46 @@ +/* + * 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.tool.sourceanalyzer.cli.cmd; + +import java.util.List; + +import com.fortify.cli.common.util.PlatformHelper; +import com.fortify.cli.tool._common.cli.cmd.AbstractToolRunCommand; +import com.fortify.cli.tool._common.helper.Tool; +import com.fortify.cli.tool._common.helper.ToolInstallationDescriptor; + +import picocli.CommandLine.Command; + +/** + * Command to update Fortify Source Analyzer rulepacks by running the + * fortifyupdate binary from the registered installation. + * + * This command uses the same installation resolution logic as other + * sourceanalyzer tool commands and simply executes the platform-specific + * fortifyupdate executable from the installation's bin directory. + * + * @author Sangamesh Vijaykumar + */ +@Command(name = "update-rules") +public class ToolSourceAnalyzerUpdateRulePacksCommand extends AbstractToolRunCommand { + @Override + protected final Tool getTool() { + return Tool.SOURCE_ANALYZER; + } + + @Override + protected List getBaseCommand(ToolInstallationDescriptor descriptor) { + var baseCmd = PlatformHelper.isWindows() ? "fortifyupdate.cmd" : "fortifyupdate"; + return List.of(descriptor.getBinPath().resolve(baseCmd).toString()); + } +} diff --git a/fcli-core/fcli-tool/src/main/resources/com/fortify/cli/tool/i18n/ToolMessages.properties b/fcli-core/fcli-tool/src/main/resources/com/fortify/cli/tool/i18n/ToolMessages.properties index 12eec0c14e..cee0be0357 100644 --- a/fcli-core/fcli-tool/src/main/resources/com/fortify/cli/tool/i18n/ToolMessages.properties +++ b/fcli-core/fcli-tool/src/main/resources/com/fortify/cli/tool/i18n/ToolMessages.properties @@ -67,6 +67,9 @@ fcli.tool.env.tools = Comma-separated list of tool selectors in the form _, or JAVA_HOME_). If no compatible JRE is found, fcli will automatically install an embedded JRE as if --with-jre was specified. Use --no-with-jre to explicitly skip embedded JRE installation and rely on environment-based JRE detection at runtime (including JAVA_HOME and PATH fallback). @@ -440,6 +457,30 @@ fcli.tool.vuln-exporter.uninstall.usage.header = Uninstall Fortify Vulnerability fcli.tool.vuln-exporter.uninstall.usage.description = This command removes one or more Fortify Vulnerability Exporter installations that were previously installed using the 'fcli tool vuln-exporter install' command. ${fcli.tool.uninstall.generic-global-bin-description} fcli.tool.vuln-exporter.uninstall.confirm = Confirm removal of Fortify Vulnerability Exporter. +# fcli tool sourceanalyzer (sca) +fcli.tool.sourceanalyzer.usage.header = Manage OpenText SAST registrations. +fcli.tool.sourceanalyzer.usage.description = This command allows for performing local translation and/or local scan; \ + see\nhttps://www.microfocus.com/documentation/fortify-static-code-analyzer-and-tools \nfor details. +fcli.tool.sourceanalyzer.list.usage.header = List available and installed OpenText SAST versions. +fcli.tool.sourceanalyzer.list.usage.description = List available and installed OpenText SAST versions. +fcli.tool.sourceanalyzer.get.usage.header = Get information about a specific OpenText SAST version. +fcli.tool.sourceanalyzer.get.usage.description = This command retrieves detailed information about a specific OpenText SAST version, \ + including available platforms and installation status. +fcli.tool.sourceanalyzer.register.usage.header = Register an external OpenText SAST installation. +fcli.tool.sourceanalyzer.register.usage.description.0 = ${fcli.tool.register.generic-description} +fcli.tool.sourceanalyzer.register.usage.description.1 = Examples:\n\ + \ --path /opt/fortify/tools/sourceanalyzer\n\ + \ --path $SOURCE_ANALYZER_HOME\n\ + \ --path $PATH\n\ + \ --path $SOURCE_ANALYZER_HOME:$PATH\n + +fcli.tool.sourceanalyzer.run.usage.header = Run OpenText SAST. +fcli.tool.sourceanalyzer.run.usage.description = This command allows for running OpenText SAST as already installed in the user's machine. It is recommended to use double dashes to separate \ + fcli options from OpenText SAST options, i.e., 'fcli tool sourceanalyzer run -- ' \ + to explicitly differentiate between fcli options and OpenText SAST options. +fcli.tool.sourceanalyzer.update-rules.usage.header = Update OpenText SAST rulepacks. +fcli.tool.sourceanalyzer.update-rules.usage.description = This command runs the fortifyupdate utility from the registered OpenText SAST installation to update local rulepacks. \ + Ensure that the installation has network access to your OpenText update server or that it is otherwise configured for offline updates. ################################################################################################################# # The following are technical properties that shouldn't be internationalized #################################### ################################################################################################################# @@ -447,3 +488,4 @@ fcli.tool.output.table.header.isDefaultMarker = fcli.tool.output.table.args = name,version,aliasesString,stable,installDir,isDefaultMarker fcli.tool.list-platforms.output.table.args = platform fcli.tool.definitions.output.table.args = name,source,lastUpdate +fcli.tool.env.init.output.table.args = name,version,installDir \ No newline at end of file diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java new file mode 100644 index 0000000000..11ade4fa46 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java @@ -0,0 +1,365 @@ +/* + * 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.util._common.helper; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import com.fasterxml.jackson.databind.JsonNode; + +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Cache for fcli record-collecting operations. Provides background loading with + * progressive record access, suitable for both MCP and RPC servers. + * + * Features: + * - LRU cache with configurable size and TTL + * - Background async loading with partial result access + * - Cancel support for long-running collections + * - Thread-safe concurrent access + * - Support for session options through option resolver + * + * @author Ruud Senden + */ +@Slf4j +public class FcliRecordsCache { + private static final long DEFAULT_TTL = 10 * 60 * 1000; // 10 minutes + private static final int DEFAULT_MAX_ENTRIES = 5; + private static final int DEFAULT_BG_THREADS = 2; + + private final long ttl; + private final int maxEntries; + private final Map cache; + private final Map inProgress = new ConcurrentHashMap<>(); + private final ExecutorService backgroundExecutor; + private Function> optionResolver; + + public FcliRecordsCache() { + this(DEFAULT_MAX_ENTRIES, DEFAULT_TTL, DEFAULT_BG_THREADS); + } + + public FcliRecordsCache(int maxEntries, long ttlMillis, int bgThreads) { + this.ttl = ttlMillis; + this.maxEntries = maxEntries; + // Use access-ordered LinkedHashMap for LRU behavior + this.cache = new LinkedHashMap<>(maxEntries, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxEntries; + } + }; + this.backgroundExecutor = Executors.newFixedThreadPool(bgThreads, r -> { + var t = new Thread(r, "fcli-cache-loader"); + t.setDaemon(true); + return t; + }); + log.info("Initialized FcliRecordsCache: maxEntries={} ttl={}ms bgThreads={}", maxEntries, ttlMillis, bgThreads); + } + + /** + * Set a function to resolve default options for commands (e.g., session options). + */ + public void setOptionResolver(Function> resolver) { + this.optionResolver = resolver; + } + + /** + * Get cached result, or start background collection if not cached. + * Returns null if result is already cached (caller should use getCached). + * Returns InProgressEntry if background collection started/exists. + */ + public InProgressEntry getOrStartBackground(String cacheKey, boolean refresh, String command) { + var cached = getCached(cacheKey); + if (!refresh && cached != null) { + return null; // Already cached + } + + var existing = inProgress.get(cacheKey); + if (existing != null && !existing.isExpired(ttl)) { + return existing; // Already loading + } + + return startNewBackgroundCollection(cacheKey, command); + } + + /** + * Start a background collection and return immediately with the cacheKey. + */ + public String startBackgroundCollection(String command) { + var cacheKey = UUID.randomUUID().toString(); + startNewBackgroundCollection(cacheKey, command); + return cacheKey; + } + + private InProgressEntry startNewBackgroundCollection(String cacheKey, String command) { + var entry = new InProgressEntry(cacheKey, command); + inProgress.put(cacheKey, entry); + + var future = buildCollectionFuture(entry, command); + future.whenComplete(createCompletionHandler(entry, cacheKey)); + + entry.setFuture(future); + log.debug("Started background collection: cacheKey={} command={}", cacheKey, command); + + return entry; + } + + private CompletableFuture buildCollectionFuture(InProgressEntry entry, String command) { + // Resolve options before starting async execution + var defaultOptions = optionResolver != null ? optionResolver.apply(command) : null; + + return CompletableFuture.supplyAsync(() -> { + var records = entry.getRecords(); + var result = FcliRunnerHelper.collectRecords(command, record -> { + if (!Thread.currentThread().isInterrupted()) { + records.add(record); + } + }, defaultOptions); + + if (Thread.currentThread().isInterrupted()) { + return null; + } + + var fullResult = FcliToolResult.fromRecords(result, records); + if (result.getExitCode() == 0) { + put(entry.getCacheKey(), fullResult); + } + return fullResult; + }, backgroundExecutor); + } + + private BiConsumer createCompletionHandler(InProgressEntry entry, String cacheKey) { + return (result, throwable) -> { + entry.setCompleted(true); + captureExecutionResult(entry, result, throwable); + cleanupFailedCollection(entry, cacheKey); + log.debug("Background collection completed: cacheKey={} exitCode={}", cacheKey, entry.getExitCode()); + }; + } + + private void captureExecutionResult(InProgressEntry entry, FcliToolResult result, Throwable throwable) { + if (throwable != null) { + entry.setExitCode(999); + entry.setStderr(throwable.getMessage() != null ? throwable.getMessage() : "Background collection failed"); + } else if (result != null) { + entry.setExitCode(result.getExitCode()); + entry.setStderr(result.getStderr()); + } else { + entry.setExitCode(999); + entry.setStderr("Cancelled"); + } + } + + private void cleanupFailedCollection(InProgressEntry entry, String cacheKey) { + if (entry.getExitCode() != 0) { + inProgress.remove(cacheKey); + } + } + + /** + * Store a result in the cache. + */ + public void put(String cacheKey, FcliToolResult result) { + if (result == null) { + return; + } + synchronized (cache) { + cache.put(cacheKey, new CacheEntry(result)); + } + log.debug("Cached result: cacheKey={} records={}", cacheKey, result.getRecords() != null ? result.getRecords().size() : 0); + } + + /** + * Get a cached result if present and not expired. + */ + public FcliToolResult getCached(String cacheKey) { + synchronized (cache) { + var entry = cache.get(cacheKey); + return entry == null || entry.isExpired(ttl) ? null : entry.getFullResult(); + } + } + + /** + * Get an in-progress entry if exists. + */ + public InProgressEntry getInProgress(String cacheKey) { + return inProgress.get(cacheKey); + } + + /** + * Wait for collection to complete (up to maxWaitMs) and return the result. + */ + public FcliToolResult waitForCompletion(String cacheKey, long maxWaitMs) { + var entry = inProgress.get(cacheKey); + if (entry == null) { + return getCached(cacheKey); + } + + long start = System.currentTimeMillis(); + while (!entry.isCompleted() && System.currentTimeMillis() - start < maxWaitMs) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + if (entry.isCompleted()) { + inProgress.remove(cacheKey); + return getCached(cacheKey); + } + + return null; // Still in progress + } + + /** + * Cancel a background collection. + */ + public boolean cancel(String cacheKey) { + var entry = inProgress.get(cacheKey); + if (entry != null) { + entry.cancel(); + inProgress.remove(cacheKey); + log.debug("Cancelled collection: cacheKey={}", cacheKey); + return true; + } + return false; + } + + /** + * Clear a specific cache entry. + */ + public boolean clear(String cacheKey) { + boolean removed = false; + synchronized (cache) { + removed = cache.remove(cacheKey) != null; + } + var inProg = inProgress.remove(cacheKey); + if (inProg != null) { + inProg.cancel(); + removed = true; + } + return removed; + } + + /** + * Clear all cache entries. + */ + public void clearAll() { + synchronized (cache) { + cache.clear(); + } + inProgress.values().forEach(InProgressEntry::cancel); + inProgress.clear(); + log.debug("Cleared all cache entries"); + } + + /** + * Get cache statistics. + */ + public CacheStats getStats() { + int cached; + synchronized (cache) { + cached = cache.size(); + } + return new CacheStats(cached, inProgress.size()); + } + + /** + * Shutdown the cache and background executor. + */ + public void shutdown() { + backgroundExecutor.shutdown(); + try { + backgroundExecutor.awaitTermination(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + backgroundExecutor.shutdownNow(); + log.info("FcliRecordsCache shutdown complete"); + } + + /** + * In-progress tracking entry giving access to partial records list. + */ + @Data + public static final class InProgressEntry { + private final String cacheKey; + private final String command; + private final long created = System.currentTimeMillis(); + private final CopyOnWriteArrayList records = new CopyOnWriteArrayList<>(); + private volatile CompletableFuture future; + private volatile boolean completed = false; + private volatile int exitCode = 0; + private volatile String stderr = ""; + + public InProgressEntry(String cacheKey, String command) { + this.cacheKey = cacheKey; + this.command = command; + } + + public boolean isExpired(long ttl) { + return System.currentTimeMillis() > created + ttl; + } + + public void setFuture(CompletableFuture f) { + this.future = f; + } + + public void cancel() { + if (future != null) { + future.cancel(true); + } + } + + public int getLoadedCount() { + return records.size(); + } + + public List getRecordsSnapshot() { + return List.copyOf(records); + } + } + + @Data + @RequiredArgsConstructor + private static final class CacheEntry { + private final FcliToolResult fullResult; + private final long created = System.currentTimeMillis(); + + public boolean isExpired(long ttl) { + return System.currentTimeMillis() > created + ttl; + } + } + + @Data + @RequiredArgsConstructor + public static final class CacheStats { + private final int cachedEntries; + private final int inProgressEntries; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java new file mode 100644 index 0000000000..fb84e71a19 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java @@ -0,0 +1,112 @@ +/* + * 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.util._common.helper; + +import java.util.ArrayList; +import java.util.Map; +import java.util.function.Consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.util.OutputHelper.OutputType; +import com.fortify.cli.common.util.OutputHelper.Result; + +/** + * Helper methods for running fcli commands, collecting either records or stdout. + * This class is shared between MCP server and RPC server implementations. + * + * @author Ruud Senden + */ +public class FcliRunnerHelper { + + /** + * Execute a command and collect stdout output. + */ + public static Result collectStdout(String fullCmd) { + return collectStdout(fullCmd, null); + } + + /** + * Execute a command and collect stdout output with default options. + */ + public static Result collectStdout(String fullCmd, Map defaultOptions) { + var builder = FcliCommandExecutorFactory.builder() + .cmd(fullCmd) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}); + + if (defaultOptions != null) { + builder.defaultOptionsIfNotPresent(defaultOptions); + } + + return builder.build().create().execute(); + } + + /** + * Execute a command and collect structured records. + */ + public static Result collectRecords(String fullCmd, Consumer recordConsumer) { + return collectRecords(fullCmd, recordConsumer, null); + } + + /** + * Execute a command and collect structured records with default options. + */ + public static Result collectRecords(String fullCmd, Consumer recordConsumer, Map defaultOptions) { + var builder = FcliCommandExecutorFactory.builder() + .cmd(fullCmd) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .recordConsumer(recordConsumer) + .onFail(r -> {}); + + if (defaultOptions != null) { + builder.defaultOptionsIfNotPresent(defaultOptions); + } + + return builder.build().create().execute(); + } + + /** + * Execute a command and return a FcliToolResult with all collected records. + */ + public static FcliToolResult collectRecordsAsResult(String fullCmd) { + return collectRecordsAsResult(fullCmd, null); + } + + /** + * Execute a command and return a FcliToolResult with all collected records and default options. + */ + public static FcliToolResult collectRecordsAsResult(String fullCmd, Map defaultOptions) { + var records = new ArrayList(); + var result = collectRecords(fullCmd, records::add, defaultOptions); + return FcliToolResult.fromRecords(result, records); + } + + /** + * Execute a command and return a FcliToolResult with stdout. + */ + public static FcliToolResult collectStdoutAsResult(String fullCmd) { + return collectStdoutAsResult(fullCmd, null); + } + + /** + * Execute a command and return a FcliToolResult with stdout and default options. + */ + public static FcliToolResult collectStdoutAsResult(String fullCmd, Map defaultOptions) { + var result = collectStdout(fullCmd, defaultOptions); + return FcliToolResult.fromPlainText(result); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java new file mode 100644 index 0000000000..2113ef8a6e --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java @@ -0,0 +1,241 @@ +/* + * 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.util._common.helper; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; +import com.fortify.cli.common.exception.FcliExceptionHelper; +import com.fortify.cli.common.json.JsonHelper; +import com.fortify.cli.common.util.OutputHelper.Result; + +import lombok.Builder; +import lombok.Data; + +/** + * Unified result class for fcli command execution. Supports multiple output formats: + * plain text (stdout), structured records, paginated records, and errors. + * Null fields are excluded from JSON serialization. + * + * This class is shared between MCP server and RPC server implementations. + * + * @author Ruud Senden + */ +@Data @Builder +@Reflectable +@JsonInclude(Include.NON_NULL) +public class FcliToolResult { + private static final Logger LOG = LoggerFactory.getLogger(FcliToolResult.class); + + // Common fields for all result types + private final Integer exitCode; + private final String stderr; + + // Error fields (populated when exitCode != 0) + private final String error; + private final String errorStackTrace; + private final String errorGuidance; + + // Plain text output + private final String stdout; + + // Structured records output + private final List records; + + // Pagination metadata (for paged results) + private final PageInfo pagination; + + // Factory methods + + /** + * Create result from fcli execution with plain text stdout. + */ + public static FcliToolResult fromPlainText(Result result) { + return builder() + .exitCode(result.getExitCode()) + .stderr(result.getErr()) + .stdout(result.getOut()) + .build(); + } + + /** + * Create result from fcli execution with structured records. + */ + public static FcliToolResult fromRecords(Result result, List records) { + return builder() + .exitCode(result.getExitCode()) + .stderr(result.getErr()) + .records(records) + .build(); + } + + /** + * Create complete paged result once all records have been collected. + */ + public static FcliToolResult fromCompletedPagedResult(FcliToolResult plainResult, int offset, int limit) { + var allRecords = plainResult.getRecords(); + var pageInfo = PageInfo.complete(allRecords.size(), offset, limit); + var endIndexExclusive = Math.min(offset+limit, allRecords.size()); + List pageRecords = offset>=endIndexExclusive ? List.of() : allRecords.subList(offset, endIndexExclusive); + return builder() + .exitCode(plainResult.getExitCode()) + .stderr(plainResult.getStderr()) + .error(plainResult.getError()) + .errorStackTrace(plainResult.getErrorStackTrace()) + .errorGuidance(plainResult.getErrorGuidance()) + .records(pageRecords) + .pagination(pageInfo) + .build(); + } + + /** + * Create partial paged result while background collection is still running. + */ + public static FcliToolResult fromPartialPagedResult(List loadedRecords, int offset, int limit, boolean complete, String cacheKey) { + if ( complete ) { + return fromCompletedPagedResult( + builder().exitCode(0).stderr("").records(loadedRecords).build(), + offset, limit); + } + var endIndexExclusive = Math.min(offset+limit, loadedRecords.size()); + List pageRecords = offset>=endIndexExclusive ? List.of() : loadedRecords.subList(offset, endIndexExclusive); + var hasMore = loadedRecords.size() > offset+limit; + var pageInfo = PageInfo.partial(offset, limit, hasMore).toBuilder().cacheKey(cacheKey).build(); + return builder() + .exitCode(0) + .stderr("") + .records(pageRecords) + .pagination(pageInfo) + .build(); + } + + /** + * Create error result from exit code and stderr message. + */ + public static FcliToolResult fromError(int exitCode, String stderr) { + return builder() + .exitCode(exitCode) + .stderr(stderr != null ? stderr : "Unknown error") + .records(List.of()) + .build(); + } + + /** + * Create error result from exception with structured error information. + */ + public static FcliToolResult fromError(Exception e) { + return builder() + .exitCode(1) + .stderr(getErrorMessage(e)) + .error(getErrorMessage(e)) + .errorStackTrace(formatException(e)) + .errorGuidance(getErrorGuidance()) + .records(List.of()) + .build(); + } + + /** + * Create error result with simple message. + */ + public static FcliToolResult fromError(String message) { + return fromError(1, message); + } + + // Conversion to JSON + + public final String asJsonString() { + return JsonHelper.getObjectMapper().valueToTree(this).toPrettyString(); + } + + public final JsonNode asJsonNode() { + return JsonHelper.getObjectMapper().valueToTree(this); + } + + // Pagination metadata inner class + + @Data @Builder(toBuilder = true) + @Reflectable + public static final class PageInfo { + private final Integer totalRecords; + private final Integer totalPages; + private final int currentOffset; + private final int currentLimit; + private final Integer nextPageOffset; + private final Integer lastPageOffset; + private final boolean hasMore; + private final boolean complete; + private final String cacheKey; // For RPC: reference to cached result + private final String jobToken; // For MCP: reference to job tracking + private final String guidance; + + public static PageInfo complete(int totalRecords, int offset, int limit) { + var totalPages = (int)Math.ceil((double)totalRecords / (double)limit); + var lastPageOffset = (totalPages - 1) * limit; + var nextPageOffset = offset+limit; + var hasMore = totalRecords>nextPageOffset; + return PageInfo.builder() + .currentLimit(limit) + .currentOffset(offset) + .lastPageOffset(lastPageOffset) + .nextPageOffset(hasMore ? nextPageOffset : null) + .hasMore(hasMore) + .totalRecords(totalRecords) + .totalPages(totalPages) + .complete(true) + .guidance("All records loaded; totals available.") + .build(); + } + + public static PageInfo partial(int offset, int limit, boolean hasMore) { + return PageInfo.builder() + .currentLimit(limit) + .currentOffset(offset) + .nextPageOffset(hasMore ? offset+limit : null) + .hasMore(hasMore) + .complete(false) + .guidance("Partial page; totals unavailable. Use cacheKey/jobToken to wait for completion.") + .build(); + } + + @JsonIgnore + public boolean isComplete() { + return complete; + } + } + + // Exception formatting helpers + + private static String formatException(Exception e) { + return FcliExceptionHelper.formatException(e); + } + + private static String getErrorMessage(Exception e) { + return FcliExceptionHelper.getErrorMessage(e); + } + + private static String getErrorGuidance() { + return """ + The fcli command failed with an exception. You may use the error message and stack trace to: + 1. Diagnose the root cause and suggest corrective actions to resolve the issue + 2. Provide the error details to the user if manual troubleshooting is required + 3. Adjust command parameters or suggest alternative approaches to accomplish the task + """; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java index dae59fa29a..70af487563 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_main/cli/cmd/UtilCommands.java @@ -13,16 +13,22 @@ package com.fortify.cli.util._main.cli.cmd; import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; import com.fortify.cli.util.all_commands.cli.cmd.AllCommandsCommands; import com.fortify.cli.util.autocomplete.cli.cmd.AutoCompleteCommands; import com.fortify.cli.util.crypto.cli.cmd.CryptoCommands; import com.fortify.cli.util.mcp_server.cli.cmd.MCPServerCommands; +import com.fortify.cli.util.rpc_server.cli.cmd.RPCServerCommands; import com.fortify.cli.util.sample_data.cli.cmd.SampleDataCommands; import com.fortify.cli.util.state.cli.cmd.StateCommands; import com.fortify.cli.util.variable.cli.cmd.VariableCommands; import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) +@RelatedModules({"ssc","fod"}) @Command( name = "util", resourceBundle = "com.fortify.cli.util.i18n.UtilMessages", @@ -31,6 +37,7 @@ AutoCompleteCommands.class, CryptoCommands.class, MCPServerCommands.class, + RPCServerCommands.class, SampleDataCommands.class, StateCommands.class, VariableCommands.class diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/all_commands/cli/mixin/AllCommandsCommandSelectorMixin.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/all_commands/cli/mixin/AllCommandsCommandSelectorMixin.java index 600ac64b67..342dda9b31 100644 --- a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/all_commands/cli/mixin/AllCommandsCommandSelectorMixin.java +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/all_commands/cli/mixin/AllCommandsCommandSelectorMixin.java @@ -12,13 +12,23 @@ */ package com.fortify.cli.util.all_commands.cli.mixin; +import java.lang.reflect.AccessibleObject; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; @@ -30,7 +40,11 @@ import lombok.Data; import lombok.Getter; +import picocli.CommandLine.Model.ArgGroupSpec; +import picocli.CommandLine.Model.ArgSpec; import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.OptionSpec; +import picocli.CommandLine.Model.PositionalParamSpec; import picocli.CommandLine.Option; /** @@ -41,6 +55,10 @@ public class AllCommandsCommandSelectorMixin { @Option(names = {"-q", "--query"}, order=1, converter = QueryExpressionTypeConverter.class, paramLabel = "") @Getter private QueryExpression queryExpression; + private static final String HEADING_COMMAND_OPTIONS = "Command Options"; + private static final String HEADING_OUTPUT_OPTIONS = "Output options"; + private static final String HEADING_GENERIC_OPTIONS = "Generic fcli options"; + public final IObjectNodeProducer getObjectNodeProducer() { return StreamingObjectNodeProducer.builder() .streamSupplier(this::createObjectNodeStream) @@ -48,25 +66,25 @@ public final IObjectNodeProducer getObjectNodeProducer() { } public final Stream createObjectNodeStream() { - return createStream().map(n->n.getNode()); + return createStream().map(CommandSpecAndNode::getNode); } - + public final Stream createCommandSpecStream() { - return createStream().map(n->n.getSpec()); + return createStream().map(CommandSpecAndNode::getSpec); } - + private final Stream createStream() { return FcliCommandSpecHelper.rootCommandTreeStream() - .map(CommandSpecAndNode::new) - .filter(n->n.matches(queryExpression)) - .distinct(); + .map(CommandSpecAndNode::new) + .filter(n -> n.matches(queryExpression)) + .distinct(); } - + @Data - private static final class CommandSpecAndNode { + private static final class CommandSpecAndNode { private final CommandSpec spec; private final ObjectNode node; - + private CommandSpecAndNode(CommandSpec spec) { this.spec = spec; this.node = createNode(spec); @@ -82,10 +100,22 @@ private static final ObjectNode createNode(CommandSpec spec) { var hiddenSelf = FcliCommandSpecHelper.isHiddenSelf(spec); var hidden = FcliCommandSpecHelper.isHiddenSelfOrParent(spec); var mcpIgnored = FcliCommandSpecHelper.isMcpIgnored(spec); - var nameComponents = spec.qualifiedName(" ").split(" "); - var module = nameComponents.length>1 ? nameComponents[1] : ""; - var entity = nameComponents.length>2 ? nameComponents[2] : ""; - var action = nameComponents.length>3 ? nameComponents[3] : ""; + + String qualifiedName = spec.qualifiedName(" "); + String[] nameComponents = qualifiedName.split(" "); + String module = nameComponents.length > 1 ? nameComponents[1] : ""; + String entity = nameComponents.length > 2 ? nameComponents[2] : ""; + String action = nameComponents.length > 3 ? nameComponents[3] : ""; + + Map requiredByOption = new HashMap<>(); + for (OptionSpec option : spec.options()) { + boolean required = isEffectivelyRequired(option, spec.argGroups()); + requiredByOption.put(option, required); + } + + List exclusiveGroups = new ArrayList<>(); + collectExclusiveGroups(spec.argGroups(), exclusiveGroups); + ObjectNode result = JsonHelper.getObjectMapper().createObjectNode(); result.put("command", spec.qualifiedName(" ")); result.put("module", module); @@ -96,59 +126,539 @@ private static final ObjectNode createNode(CommandSpec spec) { result.put("hiddenSelf", hiddenSelf); result.put("mcpIgnored", mcpIgnored); result.put("runnable", FcliCommandSpecHelper.isRunnable(spec)); - result.put("usageHeader", String.join("\n", spec.usageMessage().header())); + result.put("usageHeader", normalizeNewlines(String.join("\n", spec.usageMessage().header()))); + result.put("usageDescription", normalizeNewlines(String.join("\n", spec.usageMessage().description()))); result.set("aliases", Stream.of(spec.aliases()).map(TextNode::new).collect(JsonHelper.arrayNodeCollector())); result.put("aliasesString", Stream.of(spec.aliases()).collect(Collectors.joining(", "))); var fullAliases = computeFullAliases(spec); result.set("fullAliases", fullAliases.stream().map(TextNode::new).collect(JsonHelper.arrayNodeCollector())); result.put("fullAliasesString", String.join(", ", fullAliases)); + result.set("options", spec.optionsMap().keySet().stream().map(TextNode::new).collect(JsonHelper.arrayNodeCollector())); result.put("optionsString", spec.optionsMap().keySet().stream().collect(Collectors.joining(", "))); + result.set("commandArgs", createCommandArgsNode(spec, requiredByOption, exclusiveGroups)); + return result; } - /** - * Compute all possible full command aliases for the given {@link CommandSpec} by - * generating the cartesian product of primary names + aliases for every command - * in the hierarchy (root to leaf). The canonical command name (concatenation of - * primary names) is INCLUDED as the first element if there is at least one alias - * somewhere in the hierarchy; if there are no aliases anywhere, an empty list is - * returned. - * - * Example: For hierarchy fcli -> ssc -> appversion (alias: av) -> list (alias: ls), - * this method returns (order preserved): ["fcli ssc appversion list", "fcli ssc appversion ls", - * "fcli ssc av list", "fcli ssc av ls"]. - */ - private static final List computeFullAliases(CommandSpec leafSpec) { - // Build ordered list of specs from root to leaf + private final static ObjectNode createCommandArgsNode(CommandSpec spec, Map requiredByOption, + List exclusiveGroups) { + var mapper = JsonHelper.getObjectMapper(); + ObjectNode commandArgs = mapper.createObjectNode(); + + ArrayNode parameters = mapper.createArrayNode(); + for (PositionalParamSpec param : spec.positionalParameters()) { + parameters.add(createParameterNode(param)); + } + commandArgs.set("parameters", parameters); + + Map> optionsByHeading = new LinkedHashMap<>(); + Map optionToGroup = buildOptionToGroupMap(spec); + + for (OptionSpec option : spec.options()) { + if (option.hidden()) { + continue; + } + String heading = getOptionGroupHeading(option, optionToGroup); + optionsByHeading.computeIfAbsent(heading, h -> new ArrayList<>()).add(option); + } + + // Build exclusive group metadata: map each child subgroup to its sibling IDs + Map> exclusiveWithById = buildExclusiveWithMap(exclusiveGroups); + + // Track options that are part of exclusive sub-groups (to avoid duplicates) + Set optionsInSubGroups = new LinkedHashSet<>(); + + // Build per-heading exclusive subGroups + Map> exclusiveSubGroupsByGroupId = buildExclusiveSubGroups(exclusiveGroups, optionToGroup, exclusiveWithById, requiredByOption, optionsInSubGroups); + + ArrayNode optionGroups = mapper.createArrayNode(); + + java.util.function.Consumer addGroupByHeading = heading -> { + List opts = optionsByHeading.get(heading); + if (opts == null || opts.isEmpty()) { + return; + } + ObjectNode groupNode = mapper.createObjectNode(); + String groupId = toGroupId(heading); + groupNode.put("title", heading); + groupNode.put("id", groupId); + + // Top-level options: only those NOT in any exclusive subgroup + ArrayNode optionsArray = mapper.createArrayNode(); + for (OptionSpec opt : opts) { + if (!optionsInSubGroups.contains(opt)) { + optionsArray.add(createOptionNode(opt, requiredByOption.getOrDefault(opt, false))); + } + } + groupNode.set("options", optionsArray); + + ArrayNode subGroupsArray = mapper.createArrayNode(); + List subGroups = exclusiveSubGroupsByGroupId.get(groupId); + if (subGroups != null) { + subGroups.forEach(subGroupsArray::add); + } + groupNode.set("subGroups", subGroupsArray); + + optionGroups.add(groupNode); + }; + + addGroupByHeading.accept(HEADING_COMMAND_OPTIONS); + + List allHeadings = new ArrayList<>(optionsByHeading.keySet()); + for (String heading : allHeadings) { + if (HEADING_COMMAND_OPTIONS.equals(heading) + || HEADING_OUTPUT_OPTIONS.equals(heading) + || HEADING_GENERIC_OPTIONS.equals(heading)) { + continue; + } + addGroupByHeading.accept(heading); + } + + addGroupByHeading.accept(HEADING_OUTPUT_OPTIONS); + addGroupByHeading.accept(HEADING_GENERIC_OPTIONS); + + commandArgs.set("optionGroups", optionGroups); + return commandArgs; + } + + private final static Map> buildExclusiveWithMap(List exclusiveGroups) { + Map> result = new LinkedHashMap<>(); + for (ArgGroupSpec exclusiveGroup : exclusiveGroups) { + List children = exclusiveGroup.subgroups(); + if (children.size() < 2) { + continue; + } + List childIds = children.stream().map(child -> toGroupId(computeGroupTitle(child))).collect(Collectors.toList()); + for (int i = 0; i < children.size(); i++) { + String thisId = childIds.get(i); + List siblings = new ArrayList<>(); + for (int j = 0; j < children.size(); j++) { + if (j != i) { + siblings.add(childIds.get(j)); + } + } + result.put(thisId, siblings); + } + } + return result; + } + + private final static Map> buildExclusiveSubGroups( + List exclusiveGroups, + Map optionToGroup, + Map> exclusiveWithById, + Map requiredByOption, + Set optionsInSubGroups) { + var mapper = JsonHelper.getObjectMapper(); + Map> result = new LinkedHashMap<>(); + + for (ArgGroupSpec exclusiveGroup : exclusiveGroups) { + for (ArgGroupSpec child : exclusiveGroup.subgroups()) { + // Collect all (non-hidden) options in this child subgroup + List childOptions = collectAllOptions(child).stream().filter(o -> !o.hidden()).distinct().collect(Collectors.toList()); + if (childOptions.isEmpty()) { + continue; + } + + // Determine which top-level heading group this subgroup belongs to + OptionSpec firstOpt = childOptions.get(0); + String parentHeading = getOptionGroupHeading(firstOpt, optionToGroup); + String parentGroupId = toGroupId(parentHeading); + + String title = computeGroupTitle(child); + String groupId = toGroupId(title); + + ObjectNode groupNode = mapper.createObjectNode(); + groupNode.put("id", groupId); + groupNode.put("title", title); + + // Full option metadata for this subgroup + ArrayNode optionsArray = mapper.createArrayNode(); + for (OptionSpec opt : childOptions) { + optionsArray.add(createOptionNode(opt, requiredByOption.getOrDefault(opt, false))); + optionsInSubGroups.add(opt); + } + groupNode.set("options", optionsArray); + + List siblings = exclusiveWithById.get(groupId); + if (siblings != null && !siblings.isEmpty()) { + ArrayNode exclusiveWithArray = mapper.createArrayNode(); + siblings.forEach(exclusiveWithArray::add); + groupNode.set("exclusiveWith", exclusiveWithArray); + } + + result.computeIfAbsent(parentGroupId, k -> new ArrayList<>()).add(groupNode); + } + } + return result; + } + + private final static List collectAllOptions(ArgGroupSpec group) { + List result = new ArrayList<>(group.options()); + for (ArgGroupSpec sub : group.subgroups()) { + result.addAll(collectAllOptions(sub)); + } + return result; + } + + private final static Map buildOptionToGroupMap(CommandSpec spec) { + Map map = new HashMap<>(); + collectOptionToGroup(spec.argGroups(), map); + return map; + } + + private final static void collectOptionToGroup(Collection groups, Map map) { + for (ArgGroupSpec group : groups) { + for (OptionSpec opt : group.options()) { + map.put(opt, group); + } + collectOptionToGroup(group.subgroups(), map); + } + } + + private final static String getOptionGroupHeading(OptionSpec option, Map optionToGroup) { + ArgGroupSpec group = optionToGroup.get(option); + String heading = null; + if (group != null) { + if (group.heading() != null && !group.heading().isBlank()) { + heading = group.heading().replace("%n", "").trim(); + } else if (group.headingKey() != null && !group.headingKey().isBlank()) { + heading = group.headingKey().trim(); + } + } + if (heading == null) { + heading = HEADING_COMMAND_OPTIONS; + } + int idx = heading.indexOf(" ("); + if (idx > 0) { + heading = heading.substring(0, idx).trim(); + } + return heading; + } + + private final static ObjectNode createOptionNode(OptionSpec option, boolean required) { + ObjectNode node = JsonHelper.getObjectMapper().createObjectNode(); + String title = computeTitleFromOption(option); + node.put("title", title); + node.set("names", JsonHelper.getObjectMapper().createArrayNode() + .addAll(Arrays.stream(option.names()).map(TextNode::new).collect(Collectors.toList()))); + node.put("primaryName", getPrimaryName(option)); + + String valueFormat = option.paramLabel(); + if (valueFormat == null) { + valueFormat = ""; + } + node.put("valueFormat", valueFormat); + + node.put("description", normalizeNewlines( + option.description().length > 0 ? option.description()[0] : "")); + node.put("required", required); + boolean secret = isSecretOption(option); + ArrayNode allowedValues = getAllowedValues(option, option.type(), + option.type() != null && option.type().isEnum()); + String datatype = getDatatype(option, allowedValues.size() > 0); + node.put("datatype", datatype); + node.put("secret", secret); + node.put("multiselect", isMultiSelect(resolveType(option), option.arity(), option.splitRegex())); + node.set("allowedValues", allowedValues); + return node; + } + + private final static ObjectNode createParameterNode(PositionalParamSpec param) { + ObjectNode node = JsonHelper.getObjectMapper().createObjectNode(); + String title = computeTitleFromLabel(param.paramLabel()); + node.put("title", title); + node.put("valueFormat", param.paramLabel()); + node.put("description", normalizeNewlines( + param.description().length > 0 ? param.description()[0] : "")); + node.put("required", param.required()); + ArrayNode allowedValues = getAllowedValues(param, param.type(), param.type() != null && param.type().isEnum()); + node.put("datatype", getDatatype(param, allowedValues.size() > 0)); + node.put("multiselect", isMultiSelect(resolveType(param), param.arity(), param.splitRegex())); + node.set("allowedValues", allowedValues); + return node; + } + + private final static void collectExclusiveGroups( + Collection groups, + List out) { + for (ArgGroupSpec g : groups) { + if (g.exclusive()) { + out.add(g); + } + collectExclusiveGroups(g.subgroups(), out); + } + } + + private final static String computeGroupTitle(ArgGroupSpec group) { + String heading = group.heading(); + if (heading != null && !heading.isBlank()) { + return heading.replace("%n", "").trim(); + } + OptionSpec firstOption = !group.options().isEmpty() + ? group.options().get(0) + : group.subgroups().stream() + .flatMap(g -> g.options().stream()) + .findFirst() + .orElse(null); + if (firstOption != null) { + return computeTitleFromOption(firstOption); + } + return "Arguments"; + } + + private final static boolean isEffectivelyRequired(OptionSpec option, Collection rootGroups) { + if (!option.required()) { + return false; + } + return !isInOptionalGroup(option, rootGroups, false); + } + + private final static boolean isInOptionalGroup( + OptionSpec option, + Collection groups, + boolean parentOptional) { + for (ArgGroupSpec g : groups) { + boolean thisOptional = parentOptional; + var multiplicity = g.multiplicity(); + if (multiplicity != null && multiplicity.min() == 0) { + thisOptional = true; + } + if (g.options().contains(option)) { + return thisOptional; + } + if (isInOptionalGroup(option, g.subgroups(), thisOptional)) { + return true; + } + } + return false; + } + + private final static String getPrimaryName(OptionSpec option) { + String[] names = option.names(); + if (names == null || names.length == 0) { + return null; + } + return Arrays.stream(names) + .filter(n -> n.startsWith("--")) + .findFirst() + .orElse(names[0]); + } + + private static String getDatatype(OptionSpec option, boolean hasAllowedValues) { + return getDatatype(option, resolveType(option), option.arity(), option.splitRegex(), hasAllowedValues, option.paramLabel()); + } + + private static String getDatatype(PositionalParamSpec param, boolean hasAllowedValues) { + return getDatatype(param, resolveType(param), param.arity(), param.splitRegex(), hasAllowedValues, param.paramLabel()); + } + + private final static String getDatatype( + ArgSpec argSpec, + Class type, + picocli.CommandLine.Range arity, + String splitRegex, + boolean hasAllowedValues, + String paramLabel) { + if (arity != null && arity.max() == 0) { + return "boolean"; + } + if (type == null) { + return "string"; + } + // Treat character arrays as a single string value (e.g. tokens) + if (type.isArray()) { + Class componentType = type.getComponentType(); + if (componentType == char.class || componentType == Character.class) { + return "string"; + } + } + // File/Path types should be presented as file datatype + if (type == java.nio.file.Path.class || java.io.File.class.isAssignableFrom(type)) { + return "file"; + } + boolean isListType = Collection.class.isAssignableFrom(type) + || type.isArray() + || (splitRegex != null && !splitRegex.isBlank()) + || (arity != null && arity.max() > 1); + if (isListType && hasAllowedValues) { + return "array"; + } + return "string"; + } + + private static Class resolveType(ArgSpec argSpec) { + Class type = argSpec.type(); + if (type == null || type == String.class || type == Object.class) { + Class reflectedType = getReflectedType(argSpec.userObject()); + if (reflectedType != null) { + return reflectedType; + } + } + return type; + } + + private static Class getReflectedType(Object userObject) { + if (userObject instanceof Field field) { + return field.getType(); + } + if (userObject instanceof Method method) { + return method.getReturnType(); + } + if (userObject instanceof Parameter parameter) { + return parameter.getType(); + } + if (userObject instanceof AccessibleObject accessibleObject) { + if (accessibleObject instanceof Field field) { + return field.getType(); + } + if (accessibleObject instanceof Method method) { + return method.getReturnType(); + } + } + return null; + } + + private final static boolean isMultiSelect(Class type, picocli.CommandLine.Range arity, String splitRegex) { + if (arity != null && arity.max() == 0) { + return false; + } + if (type != null && type.isArray()) { + Class componentType = type.getComponentType(); + // Character arrays should be treated as single-valued (e.g. tokens) + if (componentType == char.class || componentType == Character.class) { + return false; + } + } + if (type != null && (type.isArray() || Collection.class.isAssignableFrom(type))) { + return true; + } + if (splitRegex != null && !splitRegex.isBlank()) { + return true; + } + if (arity != null && arity.max() > 1) { + return true; + } + return false; + } + + private final static boolean hasCompletionCandidates(OptionSpec option) { + Iterable candidates = option.completionCandidates(); + if (candidates == null) { + return false; + } + for (@SuppressWarnings("unused") + Object ignored : candidates) { + return true; + } + return false; + } + + private final static ArrayNode getAllowedValues(OptionSpec option, Class type, boolean isEnumType) { + ArrayNode result = JsonHelper.getObjectMapper().createObjectNode().arrayNode(); + if (isEnumType && type != null) { + Object[] constants = type.getEnumConstants(); + if (constants != null) { + for (Object constant : constants) { + result.add(constant.toString()); + } + } + } else { + Iterable candidates = option.completionCandidates(); + if (candidates != null) { + for (Object candidate : candidates) { + result.add(String.valueOf(candidate)); + } + } + } + return result; + } + + private final static ArrayNode getAllowedValues(PositionalParamSpec param, Class type, boolean isEnumType) { + ArrayNode result = JsonHelper.getObjectMapper().createObjectNode().arrayNode(); + if (isEnumType && type != null) { + Object[] constants = type.getEnumConstants(); + if (constants != null) { + for (Object constant : constants) { + result.add(constant.toString()); + } + } + } else { + Iterable candidates = param.completionCandidates(); + if (candidates != null) { + for (Object candidate : candidates) { + result.add(String.valueOf(candidate)); + } + } + } + return result; + } + + private final static String computeTitleFromOption(OptionSpec option) { + String primaryName = getPrimaryName(option); + if (primaryName == null) { + return ""; + } + String withoutDashes = primaryName.replaceFirst("^-+", ""); + return computeTitleFromLabel(withoutDashes); + } + + private final static String computeTitleFromLabel(String label) { + if (label == null) { + return ""; + } + String sanitized = label.replace("<", "").replace(">", "").replace(":", " "); + if (sanitized.isBlank()) { + return ""; + } + String[] parts = sanitized.split("[-_\\s]+"); + return Arrays.stream(parts).filter(p -> !p.isBlank()).map(p -> p.substring(0, 1).toUpperCase() + p.substring(1)).collect(Collectors.joining(" ")); + } + + private final static boolean isSecretOption(OptionSpec option) { + return FcliCommandSpecHelper.isSensitive(option); + } + + private static String toGroupId(String title) { + if (title == null || title.isBlank()) { + return "unknown"; + } + return title.toLowerCase().replaceAll("[^a-z0-9]+", ""); + } + + private final static String normalizeNewlines(String text) { + if (text == null) { + return ""; + } + return text.replace("\n%n", "\n\n").replace("%n", "\n"); + } + + private static List computeFullAliases(CommandSpec leafSpec) { List hierarchy = new ArrayList<>(); for (CommandSpec current = leafSpec; current != null; current = current.parent()) { hierarchy.add(0, current); } - // Collect possible names (primary + aliases) for each spec in hierarchy List> hierarchyNames = new ArrayList<>(); boolean hasAnyAlias = false; for (CommandSpec cs : hierarchy) { List names = new ArrayList<>(); names.add(cs.name()); for (String a : cs.aliases()) { - if (!a.equals(cs.name())) { // avoid duplicate of primary name + if (!a.equals(cs.name())) { names.add(a); hasAnyAlias = true; } } hierarchyNames.add(names); } - if (!hasAnyAlias) { // No aliases anywhere => no full alias combinations + if (!hasAnyAlias) { return List.of(); } - // Cartesian product Set combinations = new LinkedHashSet<>(); buildCombinations(hierarchyNames, 0, new ArrayList<>(), combinations); - // Ensure canonical (all primary names) appears first if present String canonical = hierarchy.stream().map(CommandSpec::name).collect(Collectors.joining(" ")); if (combinations.remove(canonical)) { - // Re-insert at beginning by creating new list List ordered = new ArrayList<>(); ordered.add(canonical); ordered.addAll(combinations); @@ -157,7 +667,7 @@ private static final List computeFullAliases(CommandSpec leafSpec) { return new ArrayList<>(combinations); } - private static final void buildCombinations(List> hierarchyNames, int index, List current, Set out) { + private static void buildCombinations(List> hierarchyNames, int index, List current, Set out) { if (index == hierarchyNames.size()) { out.add(String.join(" ", current)); return; diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java new file mode 100644 index 0000000000..abea2e15a1 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java @@ -0,0 +1,30 @@ +/* + * 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.util.rpc_server.cli.cmd; + +import com.fortify.cli.common.cli.cmd.AbstractContainerCommand; + +import picocli.CommandLine.Command; + +/** + * Container command for JSON-RPC server commands. + * + * @author Ruud Senden + */ +@Command( + name = "rpc-server", + subcommands = { + RPCServerStartCommand.class + } +) +public class RPCServerCommands extends AbstractContainerCommand {} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java new file mode 100644 index 0000000000..347792dc73 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java @@ -0,0 +1,48 @@ +/* + * 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.util.rpc_server.cli.cmd; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.common.cli.cmd.AbstractRunnableCommand; +import com.fortify.cli.common.mcp.MCPExclude; +import com.fortify.cli.common.output.cli.mixin.OutputHelperMixins; +import com.fortify.cli.util.rpc_server.helper.rpc.JsonRpcServer; + +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Command; + +/** + * Command to start the fcli JSON-RPC server for IDE plugin integration. + * The server listens on stdin/stdout for JSON-RPC 2.0 requests and processes + * them synchronously. + * + * @author Ruud Senden + */ +@Command(name = OutputHelperMixins.Start.CMD_NAME) +@MCPExclude +@Slf4j +public class RPCServerStartCommand extends AbstractRunnableCommand { + + @Override + public Integer call() throws Exception { + log.info("Starting JSON-RPC server"); + + var objectMapper = new ObjectMapper(); + var server = new JsonRpcServer(objectMapper); + + // Start the server on stdin/stdout + server.start(System.in, System.out); + + return 0; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java new file mode 100644 index 0000000000..8d636686f0 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java @@ -0,0 +1,33 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Interface for JSON-RPC method handlers. Each handler is responsible for + * executing a specific RPC method and returning the result. + * + * @author Ruud Senden + */ +@FunctionalInterface +public interface IRpcMethodHandler { + /** + * Execute the RPC method with the given parameters. + * + * @param params the method parameters (may be null) + * @return the result as a JsonNode, or null if no result + * @throws RpcMethodException if the method execution fails + */ + JsonNode execute(JsonNode params) throws RpcMethodException; +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java new file mode 100644 index 0000000000..30d013c0d6 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java @@ -0,0 +1,76 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * JSON-RPC 2.0 error object. Per specification: + * - code: Integer indicating the error type + * - message: String providing a short description of the error + * - data: Optional value containing additional information about the error + * + * Standard error codes: + * -32700: Parse error + * -32600: Invalid Request + * -32601: Method not found + * -32602: Invalid params + * -32603: Internal error + * -32000 to -32099: Server error (reserved for implementation-defined errors) + * + * @author Ruud Senden + */ +@Reflectable +@JsonInclude(Include.NON_NULL) +public record JsonRpcError( + int code, + String message, + JsonNode data +) { + public static final int PARSE_ERROR = -32700; + public static final int INVALID_REQUEST = -32600; + public static final int METHOD_NOT_FOUND = -32601; + public static final int INVALID_PARAMS = -32602; + public static final int INTERNAL_ERROR = -32603; + public static final int SERVER_ERROR = -32000; + + public static JsonRpcError parseError() { + return new JsonRpcError(PARSE_ERROR, "Parse error", null); + } + + public static JsonRpcError invalidRequest() { + return new JsonRpcError(INVALID_REQUEST, "Invalid Request", null); + } + + public static JsonRpcError methodNotFound(String method) { + return new JsonRpcError(METHOD_NOT_FOUND, "Method not found: " + method, null); + } + + public static JsonRpcError invalidParams(String details) { + return new JsonRpcError(INVALID_PARAMS, "Invalid params: " + details, null); + } + + public static JsonRpcError internalError(String details) { + return new JsonRpcError(INTERNAL_ERROR, "Internal error: " + details, null); + } + + public static JsonRpcError serverError(int code, String message, JsonNode data) { + if (code > SERVER_ERROR || code < SERVER_ERROR - 99) { + code = SERVER_ERROR; + } + return new JsonRpcError(code, message, data); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java new file mode 100644 index 0000000000..ff806274dd --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java @@ -0,0 +1,43 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * JSON-RPC 2.0 request object. Per specification: + * - jsonrpc: MUST be "2.0" + * - method: String containing the name of the method to be invoked + * - params: Optional structured value holding parameter values + * - id: An identifier established by the client (can be string, number, or null for notifications) + * + * @author Ruud Senden + */ +@Reflectable +@JsonIgnoreProperties(ignoreUnknown = true) +public record JsonRpcRequest( + String jsonrpc, + String method, + JsonNode params, + JsonNode id +) { + public boolean isNotification() { + return id == null || id.isNull(); + } + + public boolean isValid() { + return "2.0".equals(jsonrpc) && method != null && !method.isBlank(); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java new file mode 100644 index 0000000000..3f6b4a7695 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java @@ -0,0 +1,64 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.JsonNode; +import com.formkiq.graalvm.annotations.Reflectable; + +/** + * JSON-RPC 2.0 response object. Per specification: + * - jsonrpc: MUST be "2.0" + * - result: Required on success. Value determined by method invocation. + * - error: Required on error. Error object describing the error. + * - id: MUST be same as request id, or null if id couldn't be determined + * + * @author Ruud Senden + */ +@Reflectable +@JsonInclude(Include.NON_NULL) +public record JsonRpcResponse( + String jsonrpc, + JsonNode result, + JsonRpcError error, + JsonNode id +) { + public static JsonRpcResponse success(JsonNode id, JsonNode result) { + return new JsonRpcResponse("2.0", result, null, id); + } + + public static JsonRpcResponse error(JsonNode id, JsonRpcError error) { + return new JsonRpcResponse("2.0", null, error, id); + } + + public static JsonRpcResponse parseError() { + return error(null, JsonRpcError.parseError()); + } + + public static JsonRpcResponse invalidRequest(JsonNode id) { + return error(id, JsonRpcError.invalidRequest()); + } + + public static JsonRpcResponse methodNotFound(JsonNode id, String method) { + return error(id, JsonRpcError.methodNotFound(method)); + } + + public static JsonRpcResponse invalidParams(JsonNode id, String message) { + return error(id, JsonRpcError.invalidParams(message)); + } + + public static JsonRpcResponse internalError(JsonNode id, String message) { + return error(id, JsonRpcError.internalError(message)); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java new file mode 100644 index 0000000000..1dad92274a --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java @@ -0,0 +1,239 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.extern.slf4j.Slf4j; + +/** + * A lightweight JSON-RPC 2.0 server that reads requests from an input stream + * and writes responses to an output stream (typically stdin/stdout for IDE integration). + * + * This implementation: + * - Supports JSON-RPC 2.0 specification + * - Handles single requests and batch requests + * - Supports notifications (requests without id) + * - Is compatible with GraalVM native image compilation + * - Processes requests synchronously (appropriate for stdio-based IDE integration) + * - Includes caching for efficient paged access to large result sets + * - Manages product sessions (SSC, FoD) with automatic cleanup on shutdown + * + * @author Ruud Senden + */ +@Slf4j +public final class JsonRpcServer { + private final ObjectMapper objectMapper; + private final Map methodHandlers; + private final AtomicBoolean running = new AtomicBoolean(false); + private final FcliRecordsCache cache; + private final RpcSessionManager sessionManager; + + public JsonRpcServer(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.methodHandlers = new LinkedHashMap<>(); + this.cache = new FcliRecordsCache(); + this.sessionManager = new RpcSessionManager(objectMapper); + + // Configure cache to use session manager for resolving session options + this.cache.setOptionResolver(sessionManager::getSessionOptionsForCommand); + + registerDefaultMethods(); + } + + private void registerDefaultMethods() { + // Register built-in fcli methods + registerMethod("fcli.execute", new RpcMethodHandlerFcliExecute(objectMapper, sessionManager)); + registerMethod("fcli.executeAsync", new RpcMethodHandlerFcliExecuteAsync(objectMapper, cache)); + registerMethod("fcli.getPage", new RpcMethodHandlerFcliGetPage(objectMapper, cache)); + registerMethod("fcli.cancelCollection", new RpcMethodHandlerFcliCancelCollection(objectMapper, cache)); + registerMethod("fcli.clearCache", new RpcMethodHandlerFcliClearCache(objectMapper, cache)); + registerMethod("fcli.listCommands", new RpcMethodHandlerFcliListCommands(objectMapper)); + registerMethod("fcli.version", new RpcMethodHandlerFcliVersion(objectMapper)); + registerMethod("rpc.listMethods", new RpcMethodHandlerListMethods(objectMapper, methodHandlers)); + + // Register product-specific session methods + for (var entry : sessionManager.getLoginHandlers().entrySet()) { + registerMethod("fcli." + entry.getKey() + ".login", entry.getValue()); + } + for (var entry : sessionManager.getLogoutHandlers().entrySet()) { + registerMethod("fcli." + entry.getKey() + ".logout", entry.getValue()); + } + } + + /** + * Register a custom method handler. + */ + public void registerMethod(String methodName, IRpcMethodHandler handler) { + methodHandlers.put(methodName, handler); + log.debug("Registered RPC method: {}", methodName); + } + + /** + * Start the server, reading from the given input stream and writing to the output stream. + * This method blocks until the input stream is closed or an error occurs. + * Requests are processed synchronously in the order they are received. + */ + public void start(InputStream input, OutputStream output) { + running.set(true); + log.info("JSON-RPC server starting on stdio"); + System.err.println("Fcli JSON-RPC server running on stdio. Hit Ctrl-C to exit."); + + try (var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + var writer = new PrintWriter(output, true, StandardCharsets.UTF_8)) { + + String line; + while (running.get() && (line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + + log.debug("Received request: {}", line); + + String responseJson = processRequest(line); + if (responseJson != null) { + log.debug("Sending response: {}", responseJson); + writer.println(responseJson); + } + } + } catch (Exception e) { + log.error("Error in JSON-RPC server", e); + } finally { + running.set(false); + // Logout all sessions on shutdown + sessionManager.logoutAll(); + cache.shutdown(); + log.info("JSON-RPC server stopped"); + } + } + + /** + * Stop the server gracefully. + */ + public void stop() { + running.set(false); + } + + /** + * Process a single JSON-RPC request line and return the response JSON. + * Returns null for notifications (requests without id). + */ + public String processRequest(String requestJson) { + try { + JsonNode requestNode = objectMapper.readTree(requestJson); + + // Check for batch request + if (requestNode.isArray()) { + return processBatchRequest((ArrayNode) requestNode); + } + + // Single request + return processSingleRequest(requestNode); + } catch (JsonProcessingException e) { + log.warn("Failed to parse JSON-RPC request: {}", e.getMessage()); + return toJson(JsonRpcResponse.parseError()); + } + } + + private String processBatchRequest(ArrayNode requests) { + if (requests.isEmpty()) { + return toJson(JsonRpcResponse.invalidRequest(null)); + } + + ArrayNode responses = objectMapper.createArrayNode(); + for (JsonNode request : requests) { + String responseJson = processSingleRequest(request); + if (responseJson != null) { + try { + responses.add(objectMapper.readTree(responseJson)); + } catch (JsonProcessingException e) { + log.error("Error processing batch response", e); + } + } + } + + // If all requests were notifications, return nothing + if (responses.isEmpty()) { + return null; + } + + return toJson(responses); + } + + private String processSingleRequest(JsonNode requestNode) { + JsonRpcRequest request; + try { + request = objectMapper.treeToValue(requestNode, JsonRpcRequest.class); + } catch (JsonProcessingException e) { + return toJson(JsonRpcResponse.invalidRequest(null)); + } + + if (request == null || !request.isValid()) { + return toJson(JsonRpcResponse.invalidRequest(request != null ? request.id() : null)); + } + + // Process the method + JsonRpcResponse response = executeMethod(request); + + // Don't return response for notifications + if (request.isNotification()) { + return null; + } + + return toJson(response); + } + + private JsonRpcResponse executeMethod(JsonRpcRequest request) { + var handler = methodHandlers.get(request.method()); + if (handler == null) { + return JsonRpcResponse.methodNotFound(request.id(), request.method()); + } + + try { + JsonNode result = handler.execute(request.params()); + return JsonRpcResponse.success(request.id(), result); + } catch (RpcMethodException e) { + return JsonRpcResponse.error(request.id(), e.toJsonRpcError()); + } catch (Exception e) { + log.error("Unexpected error executing method {}: {}", request.method(), e.getMessage(), e); + return JsonRpcResponse.internalError(request.id(), e.getMessage()); + } + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + log.error("Failed to serialize response", e); + // Fallback to a hardcoded error response to avoid infinite recursion + // if serialization itself fails + return String.format( + "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":%d,\"message\":\"Internal error: serialization failed\"},\"id\":null}", + JsonRpcError.INTERNAL_ERROR); + } + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java new file mode 100644 index 0000000000..765dfbe5e1 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java @@ -0,0 +1,64 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Exception thrown by RPC method handlers to indicate a method execution error. + * This maps to JSON-RPC error responses. + * + * @author Ruud Senden + */ +public class RpcMethodException extends Exception { + private final int code; + private final JsonNode data; + + public RpcMethodException(int code, String message) { + this(code, message, null, null); + } + + public RpcMethodException(int code, String message, JsonNode data) { + this(code, message, data, null); + } + + public RpcMethodException(int code, String message, JsonNode data, Throwable cause) { + super(message, cause); + this.code = code; + this.data = data; + } + + public int getCode() { + return code; + } + + public JsonNode getData() { + return data; + } + + public JsonRpcError toJsonRpcError() { + return new JsonRpcError(code, getMessage(), data); + } + + public static RpcMethodException invalidParams(String message) { + return new RpcMethodException(JsonRpcError.INVALID_PARAMS, message); + } + + public static RpcMethodException internalError(String message) { + return new RpcMethodException(JsonRpcError.INTERNAL_ERROR, message); + } + + public static RpcMethodException internalError(String message, Throwable cause) { + return new RpcMethodException(JsonRpcError.INTERNAL_ERROR, message, null, cause); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java new file mode 100644 index 0000000000..fc1f180f4d --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java @@ -0,0 +1,66 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for cancelling an in-progress collection. + * + * Method: fcli.cancelCollection + * Params: + * - cacheKey (string, required): Cache key from fcli.executeAsync + * + * Returns: + * - success (boolean): Whether cancellation was successful + * - message (string): Human-readable status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliCancelCollection implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("cacheKey")) { + throw RpcMethodException.invalidParams("'cacheKey' parameter is required"); + } + + var cacheKey = params.get("cacheKey").asText(); + if (cacheKey == null || cacheKey.isBlank()) { + throw RpcMethodException.invalidParams("'cacheKey' cannot be empty"); + } + + log.debug("Cancelling collection: cacheKey={}", cacheKey); + + var cancelled = cache.cancel(cacheKey); + + ObjectNode result = objectMapper.createObjectNode(); + result.put("success", cancelled); + result.put("cacheKey", cacheKey); + result.put("message", cancelled + ? "Collection cancelled successfully" + : "No in-progress collection found for this cacheKey"); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java new file mode 100644 index 0000000000..d70312633d --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java @@ -0,0 +1,74 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for clearing cache entries. + * + * Method: fcli.clearCache + * Params: + * - cacheKey (string, optional): Specific cache key to clear. If not provided, clears all. + * + * Returns: + * - success (boolean): Whether operation was successful + * - message (string): Human-readable status message + * - stats (object, optional): Cache statistics after clearing + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliClearCache implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + var cacheKey = params != null && params.has("cacheKey") + ? params.get("cacheKey").asText() + : null; + + ObjectNode result = objectMapper.createObjectNode(); + + if (cacheKey != null && !cacheKey.isBlank()) { + log.debug("Clearing cache entry: cacheKey={}", cacheKey); + var cleared = cache.clear(cacheKey); + result.put("success", cleared); + result.put("cacheKey", cacheKey); + result.put("message", cleared + ? "Cache entry cleared successfully" + : "No cache entry found for this cacheKey"); + } else { + log.debug("Clearing all cache entries"); + cache.clearAll(); + result.put("success", true); + result.put("message", "All cache entries cleared"); + } + + // Add current stats + var stats = cache.getStats(); + ObjectNode statsNode = result.putObject("stats"); + statsNode.put("cachedEntries", stats.getCachedEntries()); + statsNode.put("inProgressEntries", stats.getInProgressEntries()); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java new file mode 100644 index 0000000000..d986618882 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java @@ -0,0 +1,125 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import java.util.ArrayList; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.util.OutputHelper.OutputType; +import com.fortify.cli.common.util.OutputHelper.Result; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for executing fcli commands synchronously. + * + * Method: fcli.execute + * Params: + * - command (string, required): The fcli command to execute (e.g., "ssc appversion list") + * - collectRecords (boolean, optional): If true, collect structured records instead of stdout + * + * Returns: + * - exitCode (integer): The command exit code + * - records (array, optional): Array of ALL record objects if collectRecords=true + * - stdout (string, optional): Standard output if collectRecords=false + * - stderr (string): Standard error output + * + * Note: This method returns ALL records without paging. For commands that may return + * large datasets (e.g., issue list), use fcli.executeAsync + fcli.getPage instead. + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliExecute implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("command")) { + throw RpcMethodException.invalidParams("'command' parameter is required"); + } + + var command = params.get("command").asText(); + var collectRecords = params.has("collectRecords") && params.get("collectRecords").asBoolean(false); + + if (command == null || command.isBlank()) { + throw RpcMethodException.invalidParams("'command' cannot be empty"); + } + + log.debug("Executing fcli command: {} (collectRecords={})", command, collectRecords); + + try { + if (collectRecords) { + return executeWithRecords(command); + } else { + return executeWithStdout(command); + } + } catch (Exception e) { + log.error("Error executing fcli command: {}", command, e); + throw RpcMethodException.internalError("Command execution failed: " + e.getMessage(), e); + } + } + + private JsonNode executeWithStdout(String command) { + var result = FcliCommandExecutorFactory.builder() + .cmd(command) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .defaultOptionsIfNotPresent(sessionManager.getSessionOptionsForCommand(command)) + .onFail(r -> {}) + .build().create().execute(); + + return buildResponse(result, null); + } + + private JsonNode executeWithRecords(String command) { + var allRecords = new ArrayList(); + + var result = FcliCommandExecutorFactory.builder() + .cmd(command) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .recordConsumer(allRecords::add) + .defaultOptionsIfNotPresent(sessionManager.getSessionOptionsForCommand(command)) + .onFail(r -> {}) + .build().create().execute(); + + return buildResponse(result, allRecords); + } + + private ObjectNode buildResponse(Result result, java.util.List records) { + var response = objectMapper.createObjectNode(); + response.put("exitCode", result.getExitCode()); + + if (records != null) { + ArrayNode recordsArray = response.putArray("records"); + records.forEach(recordsArray::add); + response.put("totalRecords", records.size()); + } else { + response.put("stdout", result.getOut()); + } + + if (result.getErr() != null && !result.getErr().isBlank()) { + response.put("stderr", result.getErr()); + } + + return response; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java new file mode 100644 index 0000000000..3df184848a --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java @@ -0,0 +1,70 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for starting async fcli command execution with caching. + * + * Method: fcli.executeAsync + * Params: + * - command (string, required): The fcli command to execute (e.g., "ssc issue list") + * + * Returns: + * - cacheKey (string): Key to retrieve results via fcli.getPage + * - status (string): "started" or "cached" + * - message (string): Human-readable status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliExecuteAsync implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("command")) { + throw RpcMethodException.invalidParams("'command' parameter is required"); + } + + var command = params.get("command").asText(); + if (command == null || command.isBlank()) { + throw RpcMethodException.invalidParams("'command' cannot be empty"); + } + + log.debug("Starting async execution: command={}", command); + + try { + var cacheKey = cache.startBackgroundCollection(command); + + ObjectNode result = objectMapper.createObjectNode(); + result.put("cacheKey", cacheKey); + result.put("status", "started"); + result.put("message", "Background collection started. Use fcli.getPage with this cacheKey to retrieve results."); + + return result; + } catch (Exception e) { + log.error("Error starting async execution: {}", command, e); + throw RpcMethodException.internalError("Failed to start async execution: " + e.getMessage(), e); + } + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java new file mode 100644 index 0000000000..01d2493680 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java @@ -0,0 +1,196 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import java.util.List; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.util._common.helper.FcliRecordsCache; +import com.fortify.cli.util._common.helper.FcliToolResult; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for retrieving a page of results from cache. + * + * Method: fcli.getPage + * Params: + * - cacheKey (string, required): Cache key from fcli.executeAsync + * - offset (integer, optional): Start offset (default: 0) + * - limit (integer, optional): Maximum records to return (default: 100) + * - wait (boolean, optional): If true, wait for completion if still loading (default: false) + * - waitTimeoutMs (integer, optional): Max time to wait in ms (default: 30000) + * + * Returns: + * - status (string): "complete", "partial", "loading", "not_found", or "error" + * - records (array): Array of record objects for this page + * - pagination (object): Pagination metadata + * - loadedCount (integer): Number of records loaded so far + * - exitCode (integer, optional): Command exit code if complete + * - stderr (string, optional): Error output if any + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliGetPage implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final FcliRecordsCache cache; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("cacheKey")) { + throw RpcMethodException.invalidParams("'cacheKey' parameter is required"); + } + + var cacheKey = params.get("cacheKey").asText(); + var offset = params.has("offset") ? params.get("offset").asInt(0) : 0; + var limit = params.has("limit") ? params.get("limit").asInt(100) : 100; + var wait = params.has("wait") && params.get("wait").asBoolean(false); + var waitTimeoutMs = params.has("waitTimeoutMs") ? params.get("waitTimeoutMs").asInt(30000) : 30000; + + if (cacheKey == null || cacheKey.isBlank()) { + throw RpcMethodException.invalidParams("'cacheKey' cannot be empty"); + } + + if (offset < 0) { + throw RpcMethodException.invalidParams("'offset' must be non-negative"); + } + + if (limit <= 0) { + throw RpcMethodException.invalidParams("'limit' must be greater than 0"); + } + + log.debug("Getting page: cacheKey={} offset={} limit={} wait={}", cacheKey, offset, limit, wait); + + try { + // If wait requested, wait for completion first + if (wait) { + var waitResult = cache.waitForCompletion(cacheKey, waitTimeoutMs); + if (waitResult != null) { + return buildCompletedResponse(waitResult, offset, limit, cacheKey); + } + } + + // Check if we have a cached complete result + var cached = cache.getCached(cacheKey); + if (cached != null) { + return buildCompletedResponse(cached, offset, limit, cacheKey); + } + + // Check if loading is in progress + var inProgress = cache.getInProgress(cacheKey); + if (inProgress != null) { + return buildInProgressResponse(inProgress, offset, limit); + } + + // Not found + return buildNotFoundResponse(cacheKey); + + } catch (Exception e) { + log.error("Error getting page: cacheKey={}", cacheKey, e); + throw RpcMethodException.internalError("Failed to get page: " + e.getMessage(), e); + } + } + + private ObjectNode buildCompletedResponse(FcliToolResult result, int offset, int limit, String cacheKey) { + var allRecords = result.getRecords(); + var totalRecords = allRecords != null ? allRecords.size() : 0; + + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", result.getExitCode() == 0 ? "complete" : "error"); + response.put("cacheKey", cacheKey); + response.put("exitCode", result.getExitCode()); + + if (result.getStderr() != null && !result.getStderr().isBlank()) { + response.put("stderr", result.getStderr()); + } + + // Get the requested page + var endIndex = Math.min(offset + limit, totalRecords); + List pageRecords = offset >= totalRecords + ? List.of() + : allRecords.subList(offset, endIndex); + + ArrayNode recordsArray = response.putArray("records"); + pageRecords.forEach(recordsArray::add); + + // Pagination metadata + ObjectNode pagination = response.putObject("pagination"); + pagination.put("offset", offset); + pagination.put("limit", limit); + pagination.put("totalRecords", totalRecords); + pagination.put("totalPages", (int) Math.ceil((double) totalRecords / limit)); + pagination.put("hasMore", offset + limit < totalRecords); + pagination.put("complete", true); + if (offset + limit < totalRecords) { + pagination.put("nextOffset", offset + limit); + } + + response.put("loadedCount", totalRecords); + + return response; + } + + private ObjectNode buildInProgressResponse(FcliRecordsCache.InProgressEntry inProgress, int offset, int limit) { + var loadedRecords = inProgress.getRecordsSnapshot(); + var loadedCount = loadedRecords.size(); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", inProgress.isCompleted() ? "complete" : "loading"); + response.put("cacheKey", inProgress.getCacheKey()); + response.put("loadedCount", loadedCount); + + if (inProgress.isCompleted()) { + response.put("exitCode", inProgress.getExitCode()); + if (inProgress.getStderr() != null && !inProgress.getStderr().isBlank()) { + response.put("stderr", inProgress.getStderr()); + } + } + + // Return available records within requested range + var endIndex = Math.min(offset + limit, loadedCount); + List pageRecords = offset >= loadedCount + ? List.of() + : loadedRecords.subList(offset, endIndex); + + ArrayNode recordsArray = response.putArray("records"); + pageRecords.forEach(recordsArray::add); + + // Pagination metadata (partial) + ObjectNode pagination = response.putObject("pagination"); + pagination.put("offset", offset); + pagination.put("limit", limit); + pagination.put("hasMore", loadedCount > offset + limit || !inProgress.isCompleted()); + pagination.put("complete", inProgress.isCompleted()); + if (loadedCount > offset + limit) { + pagination.put("nextOffset", offset + limit); + } + pagination.put("guidance", "Collection in progress. Call again with wait=true to wait for completion, or poll periodically."); + + return response; + } + + private ObjectNode buildNotFoundResponse(String cacheKey) { + ObjectNode response = objectMapper.createObjectNode(); + response.put("status", "not_found"); + response.put("cacheKey", cacheKey); + response.put("message", "No cached result or in-progress collection found for this cacheKey. Use fcli.executeAsync to start a new collection."); + response.putArray("records"); + return response; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java new file mode 100644 index 0000000000..9036748344 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java @@ -0,0 +1,267 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandSpecHelper; +import com.fortify.cli.common.cli.util.ModuleType; +import com.fortify.cli.common.cli.util.ProductModule; +import com.fortify.cli.common.cli.util.RelatedModules; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import picocli.CommandLine.Model.CommandSpec; + +/** + * RPC method handler for listing available fcli commands. + * + * Method: fcli.listCommands + * Params: + * - module (string, optional): Filter by module (e.g., "ssc", "fod") + * - runnableOnly (boolean, optional): If true, only return runnable (leaf) + * commands + * - includeHidden (boolean, optional): If true, include hidden commands + * + * Returns: + * - commands (array): Array of command descriptors with: + * - name (string): Qualified command name + * - module (string): The module this command belongs to + * - usageHeader (string): Short description + * - runnable (boolean): Whether the command is executable + * - hidden (boolean): Whether the command is hidden + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliListCommands implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + var moduleParam = params != null && params.has("module") + ? params.get("module").asText(null) + : null; + var modulesOnly = params != null && params.has("modulesOnly") + && params.get("modulesOnly").asBoolean(false); + var runnableOnly = params != null && params.has("runnableOnly") + && params.get("runnableOnly").asBoolean(false); + var includeHidden = params != null && params.has("includeHidden") + && params.get("includeHidden").asBoolean(false); + var moduleTypeParam = params != null && params.has("moduleType") + ? params.get("moduleType").asText(null) + : null; + + var requestedModules = parseRequestedModules(moduleParam); + var requestedModuleType = parseRequestedModuleType(moduleTypeParam); + + log.debug("Listing fcli commands (module={}, moduleType={}, runnableOnly={}, includeHidden={}, modulesOnly={})", + moduleParam, moduleTypeParam, runnableOnly, includeHidden, modulesOnly); + + try { + var rootSpec = FcliCommandSpecHelper.getRootCommandLine().getCommandSpec(); + + if (modulesOnly) { + // Special path: return modules (with related + type filter) instead of commands + return listModulesWithRelations(rootSpec, requestedModules, requestedModuleType, runnableOnly, + includeHidden); + } + + // Normal commands listing path + Stream commandStream = FcliCommandSpecHelper.commandTreeStream(rootSpec); + + // Apply module filter (single or multiple) + if (requestedModules != null && !requestedModules.isEmpty()) { + commandStream = commandStream.filter(spec -> { + String qualifiedName = spec.qualifiedName(" "); + String[] parts = qualifiedName.split(" "); + String moduleName = parts.length > 1 ? parts[1] : ""; + return requestedModules.contains(moduleName); + }); + } + + if (runnableOnly) { + commandStream = commandStream.filter(FcliCommandSpecHelper::isRunnable); + } + + if (!includeHidden) { + commandStream = commandStream.filter(spec -> !spec.usageMessage().hidden()); + } + + ArrayNode commands = objectMapper.createArrayNode(); + commandStream + .map(this::specToDescriptor) + .forEach(commands::add); + + ObjectNode result = objectMapper.createObjectNode(); + result.set("commands", commands); + result.put("count", commands.size()); + return result; + } catch (Exception e) { + log.error("Error listing fcli commands", e); + throw RpcMethodException.internalError("Failed to list commands: " + e.getMessage(), e); + } + } + + private ObjectNode specToDescriptor(CommandSpec spec) { + var descriptor = objectMapper.createObjectNode(); + var qualifiedName = spec.qualifiedName(" "); + + descriptor.put("name", qualifiedName); + descriptor.put("module", extractModule(qualifiedName)); + descriptor.put("usageHeader", getUsageHeader(spec)); + descriptor.put("runnable", FcliCommandSpecHelper.isRunnable(spec)); + descriptor.put("hidden", spec.usageMessage().hidden()); + + return descriptor; + } + + private String extractModule(String qualifiedName) { + // Format: "fcli ..." or just "fcli" + var parts = qualifiedName.split(" "); + if (parts.length >= 2) { + return parts[1]; + } + return ""; + } + + private String getUsageHeader(CommandSpec spec) { + var headerLines = spec.usageMessage().header(); + if (headerLines != null && headerLines.length > 0) { + return String.join(" ", headerLines); + } + return ""; + } + + private Set parseRequestedModules(String moduleParam) { + if (moduleParam == null || moduleParam.isBlank()) { + return null; + } + return Stream.of(moduleParam.split("[|,]")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + } + + private JsonNode listModulesWithRelations(CommandSpec rootSpec, + Set requestedModules, + ModuleType moduleTypeFilter, + boolean runnableOnly, + boolean includeHidden) { + var modules = new java.util.LinkedHashSet(); + + FcliCommandSpecHelper.commandTreeStream(rootSpec) + .forEach(spec -> { + if (runnableOnly && !FcliCommandSpecHelper.isRunnable(spec)) { + return; + } + if (!includeHidden && spec.usageMessage().hidden()) { + return; + } + + String qualifiedName = spec.qualifiedName(" "); + String[] parts = qualifiedName.split(" "); + String moduleName = parts.length > 1 ? parts[1] : ""; + String entityName = parts.length > 2 ? parts[2] : ""; + + // Only consider module-level commands: "fcli " + if (moduleName.isEmpty() || !entityName.isEmpty()) { + return; + } + + // No specific base modules requested: include all + if (requestedModules == null || requestedModules.isEmpty()) { + if (matchesModuleType(spec, moduleTypeFilter)) { + modules.add(moduleName); + } + return; + } + + // Directly requested module + if (requestedModules.contains(moduleName)) { + if (matchesModuleType(spec, moduleTypeFilter)) { + modules.add(moduleName); + } + return; + } + + // Indirectly related via @RelatedModules on the command class + RelatedModules related = getRelatedModulesAnnotation(spec); + if (related != null) { + for (String base : related.value()) { + if (requestedModules.contains(base)) { + if (matchesModuleType(spec, moduleTypeFilter)) { + modules.add(moduleName); + } + break; + } + } + } + }); + + ArrayNode modulesArray = objectMapper.createArrayNode(); + modules.forEach(modulesArray::add); + + ObjectNode result = objectMapper.createObjectNode(); + result.set("modules", modulesArray); + result.put("count", modules.size()); + return result; + } + + private RelatedModules getRelatedModulesAnnotation(CommandSpec spec) { + Object userObject = FcliCommandSpecHelper.userObject(spec); + if (userObject == null) { + return null; + } + return userObject.getClass().getAnnotation(RelatedModules.class); + } + + private ModuleType parseRequestedModuleType(String moduleTypeParam) { + if (moduleTypeParam == null || moduleTypeParam.isBlank()) { + return null; + } + String v = moduleTypeParam.trim(); + if (v.equalsIgnoreCase("product")) { + return ModuleType.PRODUCT; + } + if (v.equalsIgnoreCase("other") || v.equalsIgnoreCase("others")) { + return ModuleType.OTHER; + } + return null; // Unknown value: ignore filter + } + + private boolean matchesModuleType(CommandSpec spec, ModuleType moduleTypeFilter) { + if (moduleTypeFilter == null) { + return true; // no filter → accept all + } + ProductModule pm = getProductModuleAnnotation(spec); + // Treat unannotated modules as OTHER by default + ModuleType effectiveType = pm != null ? pm.value() : ModuleType.OTHER; + return effectiveType == moduleTypeFilter; + } + + private ProductModule getProductModuleAnnotation(CommandSpec spec) { + Object userObject = FcliCommandSpecHelper.userObject(spec); + if (userObject == null) { + return null; + } + return userObject.getClass().getAnnotation(ProductModule.class); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java new file mode 100644 index 0000000000..90036217a3 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java @@ -0,0 +1,50 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.util.FcliBuildProperties; + +import lombok.RequiredArgsConstructor; + +/** + * RPC method handler for getting fcli version information. + * + * Method: fcli.version + * Params: none + * + * Returns: + * - version (string): The fcli version + * - buildDate (string): The build date + * - actionSchemaVersion (string): The action schema version + * + * @author Ruud Senden + */ +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliVersion implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + var props = FcliBuildProperties.INSTANCE; + + ObjectNode result = objectMapper.createObjectNode(); + result.put("version", props.getFcliVersion()); + result.put("buildDate", props.getFcliBuildDateString()); + result.put("actionSchemaVersion", props.getFcliActionSchemaVersion()); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java new file mode 100644 index 0000000000..4876c93134 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java @@ -0,0 +1,115 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for FoD session login. + * + * Method: fcli.fod.login + * Params: + * - url (string, required): FoD URL (e.g., "https://ams.fortify.com") + * - client-id (string, optional): API client ID for client credentials auth + * - client-secret (string, optional): API client secret for client credentials auth + * - user (string, optional): Username for user/password auth + * - password (string, optional): Password for user/password auth + * - tenant (string, optional): Tenant name (required for user/password auth) + * - insecure (boolean, optional): Allow insecure connections + * + * Authentication requires either (client-id + client-secret) or (user + password + tenant). + * + * Returns: + * - success (boolean): Whether login was successful + * - sessionName (string): The session name created + * - product (string): "fod" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFodLogin implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("url")) { + throw RpcMethodException.invalidParams("'url' parameter is required"); + } + + var loginArgs = buildLoginArgs(params); + + log.debug("FoD login with args: {}", loginArgs.replaceAll("(--password|--client-secret)\\s+\\S+", "$1 ***")); + + return sessionManager.executeLogin(ProductType.FOD, loginArgs); + } + + private String buildLoginArgs(JsonNode params) throws RpcMethodException { + var sb = new StringBuilder(); + + // URL is required + sb.append("--url ").append(quoteValue(params.get("url").asText())).append(" "); + + // Authentication - at least one method required + boolean hasAuth = false; + + if (params.has("client-id") && params.has("client-secret")) { + sb.append("--client-id ").append(quoteValue(params.get("client-id").asText())).append(" "); + sb.append("--client-secret ").append(quoteValue(params.get("client-secret").asText())).append(" "); + hasAuth = true; + } + + if (params.has("user") && params.has("password")) { + if (!params.has("tenant")) { + throw RpcMethodException.invalidParams( + "FoD user/password login requires 'tenant' parameter"); + } + sb.append("--user ").append(quoteValue(params.get("user").asText())).append(" "); + sb.append("--password ").append(quoteValue(params.get("password").asText())).append(" "); + sb.append("--tenant ").append(quoteValue(params.get("tenant").asText())).append(" "); + hasAuth = true; + } + + if (!hasAuth) { + throw RpcMethodException.invalidParams( + "FoD login requires either (client-id + client-secret) or (user + password + tenant)"); + } + + // Optional parameters + if (params.has("insecure") && params.get("insecure").asBoolean(false)) { + sb.append("-k "); + } + + return sb.toString().trim(); + } + + /** + * Quote a value for use in fcli command arguments. + * Always quotes the value to ensure special characters are handled correctly. + * The value is placed in double quotes with any internal quotes escaped. + */ + private String quoteValue(String value) { + if (value == null || value.isEmpty()) { + return "\"\""; + } + // Escape any double quotes in the value and wrap in double quotes + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java new file mode 100644 index 0000000000..0f70ef24f2 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java @@ -0,0 +1,47 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for FoD session logout. + * + * Method: fcli.fod.logout + * Params: none required + * + * Returns: + * - success (boolean): Whether logout was successful + * - sessionName (string): The session name that was logged out + * - product (string): "fod" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFodLogout implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + log.debug("FoD logout"); + return sessionManager.executeLogout(ProductType.FOD); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java new file mode 100644 index 0000000000..2846037122 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java @@ -0,0 +1,84 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import lombok.RequiredArgsConstructor; + +/** + * RPC method handler for listing available RPC methods. + * + * Method: rpc.listMethods + * Params: none + * + * Returns: + * - methods (array): Array of method descriptors with: + * - name (string): Method name + * - description (string): Method description + * + * @author Ruud Senden + */ +@RequiredArgsConstructor +public final class RpcMethodHandlerListMethods implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final Map methodHandlers; + + private static final Map METHOD_DESCRIPTIONS = new HashMap<>(); + + static { + // Core execution methods + METHOD_DESCRIPTIONS.put("fcli.execute", "Execute an fcli command synchronously and return all results"); + METHOD_DESCRIPTIONS.put("fcli.executeAsync", "Start async fcli command execution, returns cacheKey for paged retrieval"); + METHOD_DESCRIPTIONS.put("fcli.getPage", "Retrieve a page of results from cache by cacheKey"); + METHOD_DESCRIPTIONS.put("fcli.cancelCollection", "Cancel an in-progress async collection by cacheKey"); + METHOD_DESCRIPTIONS.put("fcli.clearCache", "Clear cache entries (specific cacheKey or all)"); + + // Info methods + METHOD_DESCRIPTIONS.put("fcli.listCommands", "List available fcli commands with optional filtering"); + METHOD_DESCRIPTIONS.put("fcli.version", "Get fcli version information"); + METHOD_DESCRIPTIONS.put("rpc.listMethods", "List available RPC methods"); + + // SSC session methods + METHOD_DESCRIPTIONS.put("fcli.ssc.login", "Login to SSC (params: url, user+password or token or ci-token)"); + METHOD_DESCRIPTIONS.put("fcli.ssc.logout", "Logout from SSC session"); + + // FoD session methods + METHOD_DESCRIPTIONS.put("fcli.fod.login", "Login to FoD (params: url, client-id+client-secret or user+password+tenant)"); + METHOD_DESCRIPTIONS.put("fcli.fod.logout", "Logout from FoD session"); + } + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + ArrayNode methods = objectMapper.createArrayNode(); + + for (String methodName : methodHandlers.keySet()) { + ObjectNode method = objectMapper.createObjectNode(); + method.put("name", methodName); + method.put("description", METHOD_DESCRIPTIONS.getOrDefault(methodName, "No description available")); + methods.add(method); + } + + ObjectNode result = objectMapper.createObjectNode(); + result.set("methods", methods); + result.put("count", methods.size()); + + return result; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java new file mode 100644 index 0000000000..4f8ed19557 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java @@ -0,0 +1,122 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for SSC session login. + * + * Method: fcli.ssc.login + * Params: + * - url (string, required): SSC URL + * - user (string, optional): Username for user/password auth + * - password (string, optional): Password for user/password auth + * - token (string, optional): UnifiedLoginToken for token-based auth + * - client-auth-token (string, optional): SC-SAST client auth token + * - sc-sast-url (string, optional): SC-SAST controller URL + * - expire-in (string, optional): Token expiration time (e.g., "1d", "8h") + * - insecure (boolean, optional): Allow insecure connections + * + * At least one auth method must be provided: (user+password) or token. + * + * Returns: + * - success (boolean): Whether login was successful + * - sessionName (string): The session name created + * - product (string): "ssc" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerSscLogin implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + if (params == null || !params.has("url")) { + throw RpcMethodException.invalidParams("'url' parameter is required"); + } + + var loginArgs = buildLoginArgs(params); + + log.debug("SSC login with args: {}", loginArgs.replaceAll("(--password|--token|--client-auth-token)\\s+\\S+", "$1 ***")); + + return sessionManager.executeLogin(ProductType.SSC, loginArgs); + } + + private String buildLoginArgs(JsonNode params) throws RpcMethodException { + var sb = new StringBuilder(); + + // URL is required + sb.append("--url ").append(quoteValue(params.get("url").asText())).append(" "); + + // Authentication - at least one method required + boolean hasAuth = false; + + if (params.has("user") && params.has("password")) { + sb.append("--user ").append(quoteValue(params.get("user").asText())).append(" "); + sb.append("--password ").append(quoteValue(params.get("password").asText())).append(" "); + hasAuth = true; + } + + if (params.has("token")) { + sb.append("--token ").append(quoteValue(params.get("token").asText())).append(" "); + hasAuth = true; + } + + if (!hasAuth) { + throw RpcMethodException.invalidParams( + "SSC login requires one of: (user + password) or token"); + } + + // Optional parameters + if (params.has("expire-in")) { + sb.append("--expire-in ").append(params.get("expire-in").asText()).append(" "); + } + + if (params.has("client-auth-token")) { + sb.append("--client-auth-token ").append(quoteValue(params.get("client-auth-token").asText())).append(" "); + } + + if (params.has("sc-sast-url")) { + sb.append("--sc-sast-url ").append(quoteValue(params.get("sc-sast-url").asText())).append(" "); + } + + if (params.has("insecure") && params.get("insecure").asBoolean(false)) { + sb.append("-k "); + } + + return sb.toString().trim(); + } + + /** + * Quote a value for use in fcli command arguments. + * Always quotes the value to ensure special characters are handled correctly. + * The value is placed in double quotes with any internal quotes escaped. + */ + private String quoteValue(String value) { + if (value == null || value.isEmpty()) { + return "\"\""; + } + // Escape any double quotes in the value and wrap in double quotes + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java new file mode 100644 index 0000000000..aeed4f0e52 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java @@ -0,0 +1,47 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.RpcSessionManager.ProductType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * RPC method handler for SSC session logout. + * + * Method: fcli.ssc.logout + * Params: none required + * + * Returns: + * - success (boolean): Whether logout was successful + * - sessionName (string): The session name that was logged out + * - product (string): "ssc" + * - message (string): Status message + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerSscLogout implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; + + @Override + public JsonNode execute(JsonNode params) throws RpcMethodException { + log.debug("SSC logout"); + return sessionManager.executeLogout(ProductType.SSC); + } +} diff --git a/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java new file mode 100644 index 0000000000..300492039a --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java @@ -0,0 +1,321 @@ +/* + * 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.util.rpc_server.helper.rpc; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fortify.cli.common.cli.util.FcliCommandExecutorFactory; +import com.fortify.cli.common.util.OutputHelper.OutputType; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Manages sessions for the RPC server. This class: + * - Creates unique session names for each product type (SSC, FoD, etc.) + * - Tracks which sessions have been created by the RPC server + * - Auto-discovers which session type is needed for a command + * - Provides session options to be added to commands + * - Logs out all sessions when the server shuts down + * + * The architecture is extensible: new products can be added by registering + * additional product handlers. + * + * @author Ruud Senden + */ +@Slf4j +public final class RpcSessionManager { + + /** + * Supported product types and their session option names. + */ + public enum ProductType { + SSC("--ssc-session", "ssc", "ssc session"), + FOD("--fod-session", "fod", "fod session"), + SC_SAST("--ssc-session", "sc-sast", "ssc session"), // SC-SAST uses SSC session + SC_DAST("--ssc-session", "sc-dast", "ssc session"); // SC-DAST uses SSC session + + @Getter private final String sessionOption; + @Getter private final String commandPrefix; + @Getter private final String sessionCommandPrefix; + + ProductType(String sessionOption, String commandPrefix, String sessionCommandPrefix) { + this.sessionOption = sessionOption; + this.commandPrefix = commandPrefix; + this.sessionCommandPrefix = sessionCommandPrefix; + } + + /** + * Determine the product type from a command string. + */ + public static ProductType fromCommand(String command) { + if (command == null) return null; + var normalizedCmd = command.toLowerCase().replaceFirst("^fcli\\s+", "").trim(); + + // Check specific product prefixes + if (normalizedCmd.startsWith("ssc ")) return SSC; + if (normalizedCmd.startsWith("fod ")) return FOD; + if (normalizedCmd.startsWith("sc-sast ")) return SC_SAST; + if (normalizedCmd.startsWith("sc-dast ")) return SC_DAST; + + return null; + } + + /** + * Get the actual session type for this product (e.g., SC-SAST uses SSC session). + */ + public ProductType getSessionType() { + return switch (this) { + case SC_SAST, SC_DAST -> SSC; + default -> this; + }; + } + } + + private final ObjectMapper objectMapper; + + // Unique ID for this RPC server instance + private final String instanceId = UUID.randomUUID().toString().substring(0, 8); + + // Session names created by this RPC server (product type -> session name) + private final Map sessionNames = new HashMap<>(); + + // Set of sessions that we've successfully logged in (need to logout on shutdown) + private final Set activeSessions = new LinkedHashSet<>(); + + // Registry of RPC method handlers for session login (product -> handler) + private final Map loginHandlers = new LinkedHashMap<>(); + + // Registry of RPC method handlers for session logout (product -> handler) + private final Map logoutHandlers = new LinkedHashMap<>(); + + public RpcSessionManager(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + registerDefaultHandlers(); + } + + private void registerDefaultHandlers() { + // Register SSC session handlers + registerLoginHandler("ssc", new RpcMethodHandlerSscLogin(objectMapper, this)); + registerLogoutHandler("ssc", new RpcMethodHandlerSscLogout(objectMapper, this)); + + // Register FoD session handlers + registerLoginHandler("fod", new RpcMethodHandlerFodLogin(objectMapper, this)); + registerLogoutHandler("fod", new RpcMethodHandlerFodLogout(objectMapper, this)); + } + + /** + * Register a login handler for a product. + */ + public void registerLoginHandler(String product, IRpcMethodHandler handler) { + loginHandlers.put(product.toLowerCase(), handler); + } + + /** + * Register a logout handler for a product. + */ + public void registerLogoutHandler(String product, IRpcMethodHandler handler) { + logoutHandlers.put(product.toLowerCase(), handler); + } + + /** + * Get all login handlers (for registering RPC methods). + */ + public Map getLoginHandlers() { + return Map.copyOf(loginHandlers); + } + + /** + * Get all logout handlers (for registering RPC methods). + */ + public Map getLogoutHandlers() { + return Map.copyOf(logoutHandlers); + } + + /** + * Get the session name for a product type, creating one if needed. + */ + public String getSessionName(ProductType productType) { + // Use the actual session type (e.g., SC-SAST uses SSC session) + var sessionType = productType.getSessionType(); + return sessionNames.computeIfAbsent(sessionType, + pt -> "rpc-" + instanceId + "-" + pt.name().toLowerCase()); + } + + /** + * Get session options to add to a command, based on the command prefix. + * Returns empty map if the command doesn't need a session or if we don't have one. + */ + public Map getSessionOptionsForCommand(String command) { + var productType = ProductType.fromCommand(command); + if (productType == null) { + return Map.of(); + } + + // Use the actual session type + var sessionType = productType.getSessionType(); + + // If we have an active session for this product type, add the option + if (activeSessions.contains(sessionType)) { + var sessionName = sessionNames.get(sessionType); + if (sessionName != null) { + return Map.of(productType.getSessionOption(), sessionName); + } + } + + return Map.of(); + } + + /** + * Execute login command and track the session. + */ + public JsonNode executeLogin(ProductType productType, String loginArgs) { + var sessionName = getSessionName(productType); + var loginCmd = buildLoginCommand(productType, sessionName, loginArgs); + + log.info("RPC session login: {} (session: {})", productType, sessionName); + + var result = FcliCommandExecutorFactory.builder() + .cmd(loginCmd) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}) + .build().create().execute(); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("product", productType.name().toLowerCase().replace("_", "-")); + response.put("sessionName", sessionName); + + if (result.getExitCode() == 0) { + activeSessions.add(productType.getSessionType()); + response.put("success", true); + response.put("message", "Successfully logged in to " + productType); + log.info("RPC session login successful: {}", sessionName); + } else { + response.put("success", false); + response.put("message", "Login failed: " + result.getErr()); + response.put("stderr", result.getErr()); + log.error("RPC session login failed: {} - {}", sessionName, result.getErr()); + } + + return response; + } + + /** + * Execute logout command for a product. + */ + public JsonNode executeLogout(ProductType productType) { + var sessionType = productType.getSessionType(); + var sessionName = sessionNames.get(sessionType); + + ObjectNode response = objectMapper.createObjectNode(); + response.put("product", productType.name().toLowerCase().replace("_", "-")); + + if (sessionName == null || !activeSessions.contains(sessionType)) { + response.put("success", true); + response.put("message", "No active session to logout"); + return response; + } + + var logoutCmd = buildLogoutCommand(productType, sessionName); + + log.info("RPC session logout: {} (session: {})", productType, sessionName); + + var result = FcliCommandExecutorFactory.builder() + .cmd(logoutCmd) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}) + .build().create().execute(); + + response.put("sessionName", sessionName); + + if (result.getExitCode() == 0) { + activeSessions.remove(sessionType); + response.put("success", true); + response.put("message", "Successfully logged out from " + productType); + log.info("RPC session logout successful: {}", sessionName); + } else { + response.put("success", false); + response.put("message", "Logout failed: " + result.getErr()); + log.warn("RPC session logout failed: {} - {}", sessionName, result.getErr()); + } + + return response; + } + + /** + * Logout from all sessions created by this RPC server. + * Called on server shutdown. + */ + public void logoutAll() { + log.info("Logging out all RPC sessions..."); + + // Iterate through activeSessions directly to avoid duplicate logout attempts + // (e.g., SC_SAST and SC_DAST share SSC session type) + for (var sessionType : Set.copyOf(activeSessions)) { + try { + executeLogout(sessionType); + } catch (Exception e) { + log.warn("Failed to logout session for {}: {}", sessionType, e.getMessage()); + } + } + + activeSessions.clear(); + sessionNames.clear(); + log.info("All RPC sessions logged out"); + } + + /** + * Get list of active sessions as JSON. + */ + public JsonNode getActiveSessions() { + ArrayNode sessions = objectMapper.createArrayNode(); + for (var productType : activeSessions) { + ObjectNode session = objectMapper.createObjectNode(); + session.put("product", productType.name().toLowerCase().replace("_", "-")); + session.put("sessionName", sessionNames.get(productType)); + sessions.add(session); + } + return sessions; + } + + /** + * Check if a session is active for a product type. + */ + public boolean hasActiveSession(ProductType productType) { + return activeSessions.contains(productType.getSessionType()); + } + + private String buildLoginCommand(ProductType productType, String sessionName, String loginArgs) { + // Session name is generated internally (rpc-{uuid}-{product}) and is safe + // loginArgs are pre-quoted by the login handlers + var baseCmd = productType.getSessionCommandPrefix() + " login"; + return String.format("%s %s %s", baseCmd, sessionName, loginArgs != null ? loginArgs : "").trim(); + } + + private String buildLogoutCommand(ProductType productType, String sessionName) { + // Session name is generated internally and is safe + var baseCmd = productType.getSessionCommandPrefix() + " logout"; + return String.format("%s %s", baseCmd, sessionName); + } +} diff --git a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index 7da5271b2b..83e95e1d24 100644 --- a/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties +++ b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties @@ -106,6 +106,98 @@ fcli.util.mcp-server.start.progress-threads = Number of threads used for updatin fcli.util.mcp-server.start.job-safe-return = Maximum time to wait synchronously for a job result before returning an in_progress placeholder. Specify duration like 25s, 2m, 1h. fcli.util.mcp-server.start.progress-interval = Interval between internal progress counter updates for long-running jobs. Specify duration (e.g. 500ms, 1s, 2s). +# fcli util rpc-server +fcli.util.rpc-server.usage.header = (PREVIEW) Manage fcli JSON-RPC server for IDE plugin integration +fcli.util.rpc-server.start.usage.header = (PREVIEW) Start fcli JSON-RPC server for IDE plugin integration +fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides a simple JSON-RPC 2.0 interface \ + for IDE plugins and other tools to interact with Fortify products through fcli. Unlike the MCP server which is \ + designed for LLM integration, the RPC server exposes a smaller set of general-purpose methods suitable for \ + programmatic access from IDE plugins.%n%n\ + The server reads JSON-RPC requests from stdin and writes responses to stdout, one JSON object per line. \ + Sessions are automatically logged out when the server terminates.%n%n\ + Available RPC methods:%n\ + %n SESSION METHODS (per product):\ + %n\ + %n - fcli.ssc.login: Login to SSC\ + %n Parameters:\ + %n - url (string, required): SSC URL\ + %n - user (string): Username for user/password auth\ + %n - password (string): Password for user/password auth\ + %n - token (string): UnifiedLoginToken or CIToken\ + %n - client-auth-token (string, optional): SC-SAST client auth token\ + %n - sc-sast-url (string, optional): SC-SAST controller URL\ + %n - expire-in (string): Token expiration (e.g., "1d", "8h")\ + %n - insecure (boolean): Allow insecure connections\ + %n Note: Requires one of (user+password) or token\ + %n\ + %n - fcli.ssc.logout: Logout from SSC session\ + %n\ + %n - fcli.fod.login: Login to FoD\ + %n Parameters:\ + %n - url (string, required): FoD URL (e.g., "https://ams.fortify.com")\ + %n - client-id (string): API client ID\ + %n - client-secret (string): API client secret\ + %n - user (string): Username\ + %n - password (string): Password\ + %n - tenant (string): Tenant name (required for user/password)\ + %n - insecure (boolean): Allow insecure connections\ + %n Note: Requires either (client-id+client-secret) or (user+password+tenant)\ + %n\ + %n - fcli.fod.logout: Logout from FoD session\ + %n\ + %n EXECUTION METHODS:\ + %n\ + %n - fcli.execute: Execute an fcli command synchronously and return ALL results\ + %n Parameters:\ + %n - command (string, required): The fcli command to execute (e.g., "ssc appversion list")\ + %n - collectRecords (boolean, optional): If true, collect structured records instead of stdout\ + %n Note: For large datasets, use fcli.executeAsync + fcli.getPage instead\ + %n\ + %n - fcli.executeAsync: Start async command execution, returns cacheKey for paged retrieval\ + %n Parameters:\ + %n - command (string, required): The fcli command to execute\ + %n Returns: cacheKey to use with fcli.getPage\ + %n\ + %n - fcli.getPage: Retrieve a page of results from cache\ + %n Parameters:\ + %n - cacheKey (string, required): Cache key from fcli.executeAsync\ + %n - offset (integer, optional): Start offset (default: 0)\ + %n - limit (integer, optional): Max records per page (default: 100)\ + %n - wait (boolean, optional): Wait for completion if still loading (default: false)\ + %n - waitTimeoutMs (integer, optional): Max wait time in ms (default: 30000)\ + %n\ + %n - fcli.cancelCollection: Cancel an in-progress async collection\ + %n Parameters:\ + %n - cacheKey (string, required): Cache key to cancel\ + %n\ + %n - fcli.clearCache: Clear cache entries\ + %n Parameters:\ + %n - cacheKey (string, optional): Specific key to clear, or omit to clear all\ + %n\ + %n INFO METHODS:\ + %n\ + %n - fcli.listCommands: List available fcli commands with optional filtering\ + %n Parameters:\ + %n - module (string, optional): Filter by module (e.g., "ssc", "fod")\ + %n - runnableOnly (boolean, optional): If true, only return runnable (leaf) commands\ + %n - includeHidden (boolean, optional): If true, include hidden commands\ + %n\ + %n - fcli.version: Get fcli version information\ + %n Parameters: none\ + %n\ + %n - rpc.listMethods: List available RPC methods\ + %n Parameters: none\ + %n%n\ + Typical workflow:%n\ + 1. Call fcli.ssc.login or fcli.fod.login with credentials%n\ + 2. Execute commands via fcli.execute or fcli.executeAsync%n\ + 3. Session options are automatically added to commands%n\ + 4. Sessions are logged out automatically when RPC server terminates%n%n\ + Example JSON-RPC requests:%n\ + %n{"jsonrpc":"2.0","method":"fcli.ssc.login","params":{"url":"https://ssc.example.com","token":"mytoken"},"id":1}\ + %n{"jsonrpc":"2.0","method":"fcli.execute","params":{"command":"ssc appversion list","collectRecords":true},"id":2}\ + %n{"jsonrpc":"2.0","method":"fcli.ssc.logout","id":3} + # fcli util sample-data fcli.util.sample-data.usage.header = (INTERNAL) Generate sample data fcli.util.sample-data.usage.description = These commands generate and output a fixed set of sample data \ diff --git a/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java new file mode 100644 index 0000000000..d73d86ba52 --- /dev/null +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java @@ -0,0 +1,497 @@ +/* + * 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.util.rpc_server.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fortify.cli.util.rpc_server.helper.rpc.JsonRpcServer; + +/** + * Unit tests for {@link JsonRpcServer}. Tests the JSON-RPC 2.0 protocol handling + * including request parsing, response generation, and error handling. + * + * @author Ruud Senden + */ +class JsonRpcServerTest { + + private JsonRpcServer server; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + server = new JsonRpcServer(objectMapper); + } + + @Test + void shouldReturnParseErrorForInvalidJson() throws Exception { + // Act + String response = server.processRequest("not valid json"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("error")); + assertEquals(-32700, node.get("error").get("code").asInt()); + assertNull(node.get("result")); + } + + @Test + void shouldReturnInvalidRequestForMissingJsonrpcVersion() throws Exception { + // Act + String response = server.processRequest("{\"method\":\"test\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("error")); + assertEquals(-32600, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnInvalidRequestForWrongJsonrpcVersion() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"1.0\",\"method\":\"test\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32600, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnMethodNotFoundForUnknownMethod() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"unknown.method\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("error")); + assertEquals(-32601, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("unknown.method")); + assertEquals(1, node.get("id").asInt()); + } + + @Test + void shouldReturnNullForNotification() throws Exception { + // Notification = request without id + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\"}"); + + // Assert - notifications should not return a response + assertNull(response); + } + + @Test + void shouldExecuteFcliVersionMethod() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":42}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + assertEquals(42, node.get("id").asInt()); + + // Check result contains version info + var result = node.get("result"); + assertTrue(result.has("version")); + assertTrue(result.has("buildDate")); + assertTrue(result.has("actionSchemaVersion")); + } + + @Test + void shouldExecuteRpcListMethodsMethod() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + + // Check result contains methods list + var result = node.get("result"); + assertTrue(result.has("methods")); + assertTrue(result.get("methods").isArray()); + assertTrue(result.get("methods").size() >= 4); // At least our 4 default methods + assertTrue(result.has("count")); + } + + @Test + void shouldReturnInvalidParamsForExecuteWithoutCommand() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{},\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("command")); + } + + @Test + void shouldReturnInvalidParamsForZeroLimit() throws Exception { + // Test limit validation in fcli.getPage + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"test-key\",\"limit\":0},\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("limit")); + } + + @Test + void shouldReturnInvalidParamsForNegativeOffset() throws Exception { + // Test offset validation in fcli.getPage + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"test-key\",\"offset\":-5},\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("offset")); + } + + @Test + void shouldPreserveRequestIdInResponse() throws Exception { + // Test with string id + String response1 = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":\"string-id\"}"); + assertNotNull(response1); + var node1 = objectMapper.readTree(response1); + assertEquals("string-id", node1.get("id").asText()); + + // Test with numeric id + String response2 = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":999}"); + assertNotNull(response2); + var node2 = objectMapper.readTree(response2); + assertEquals(999, node2.get("id").asInt()); + } + + @Test + void shouldHandleBatchRequest() throws Exception { + // Act + String response = server.processRequest( + "[{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":1}," + + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":2}]" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertTrue(node.isArray()); + assertEquals(2, node.size()); + + // Both responses should be successful + for (var responseNode : node) { + assertEquals("2.0", responseNode.get("jsonrpc").asText()); + assertNotNull(responseNode.get("result")); + assertNull(responseNode.get("error")); + } + } + + @Test + void shouldReturnInvalidRequestForEmptyBatch() throws Exception { + // Act + String response = server.processRequest("[]"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32600, node.get("error").get("code").asInt()); + } + + @Test + void shouldHandleNullId() throws Exception { + // Act - id is explicitly null (this is a notification) + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"id\":null}"); + + // Assert - no response for notifications + assertNull(response); + } + + @Test + void shouldHandleRequestWithNullParams() throws Exception { + // Act + String response = server.processRequest("{\"jsonrpc\":\"2.0\",\"method\":\"fcli.version\",\"params\":null,\"id\":1}"); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + } + + @Test + void shouldReturnErrorForListCommandsWithoutAppContext() throws Exception { + // Note: fcli.listCommands requires the full fcli command tree to be initialized, + // which isn't available in unit tests. This test verifies that the method + // returns an error response rather than crashing. + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.listCommands\",\"params\":{},\"id\":1}" + ); + + // Either we get an error (expected in unit test context) or a result (if running in full context) + assertNotNull(response); + var node = objectMapper.readTree(response); + assertEquals("2.0", node.get("jsonrpc").asText()); + // In unit test context, we expect an error since the command tree isn't initialized + // but the important thing is that it doesn't crash + assertTrue(node.has("error") || node.has("result")); + } + + @Test + void shouldReturnCacheKeyForExecuteAsync() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.executeAsync\",\"params\":{\"command\":\"util sample-data list\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertNull(node.get("error")); + + var result = node.get("result"); + assertTrue(result.has("cacheKey")); + assertNotNull(result.get("cacheKey").asText()); + assertEquals("started", result.get("status").asText()); + } + + @Test + void shouldReturnInvalidParamsForExecuteAsyncWithoutCommand() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.executeAsync\",\"params\":{},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnNotFoundForGetPageWithInvalidCacheKey() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"non-existent-key\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertEquals("not_found", node.get("result").get("status").asText()); + } + + @Test + void shouldReturnInvalidParamsForGetPageWithoutCacheKey() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } + + @Test + void shouldHandleCancelCollectionForNonExistentKey() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.cancelCollection\",\"params\":{\"cacheKey\":\"non-existent-key\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertEquals(false, node.get("result").get("success").asBoolean()); + } + + @Test + void shouldHandleClearCacheAll() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.clearCache\",\"params\":{},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + assertEquals(true, node.get("result").get("success").asBoolean()); + assertNotNull(node.get("result").get("stats")); + } + + @Test + void shouldListAllNewMethodsInRpcListMethods() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + + var methods = node.get("result").get("methods"); + assertTrue(methods.isArray()); + // Verify minimum expected methods - don't hardcode exact count for maintainability + assertTrue(methods.size() >= 8, "Should have at least 8 methods including async ones"); + + // Verify new methods are present + boolean hasExecuteAsync = false; + boolean hasGetPage = false; + boolean hasCancelCollection = false; + boolean hasClearCache = false; + + for (var method : methods) { + String name = method.get("name").asText(); + if ("fcli.executeAsync".equals(name)) hasExecuteAsync = true; + if ("fcli.getPage".equals(name)) hasGetPage = true; + if ("fcli.cancelCollection".equals(name)) hasCancelCollection = true; + if ("fcli.clearCache".equals(name)) hasClearCache = true; + } + + assertTrue(hasExecuteAsync, "fcli.executeAsync method should be present"); + assertTrue(hasGetPage, "fcli.getPage method should be present"); + assertTrue(hasCancelCollection, "fcli.cancelCollection method should be present"); + assertTrue(hasClearCache, "fcli.clearCache method should be present"); + } + + @Test + void shouldListSessionMethodsInRpcListMethods() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"rpc.listMethods\",\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("result")); + + var methods = node.get("result").get("methods"); + assertTrue(methods.isArray()); + // Verify we have at least 12 methods (8 core + 4 session methods) + assertTrue(methods.size() >= 12, "Should have at least 12 methods including session ones"); + + // Verify session methods are present + boolean hasSscLogin = false; + boolean hasSscLogout = false; + boolean hasFodLogin = false; + boolean hasFodLogout = false; + + for (var method : methods) { + String name = method.get("name").asText(); + if ("fcli.ssc.login".equals(name)) hasSscLogin = true; + if ("fcli.ssc.logout".equals(name)) hasSscLogout = true; + if ("fcli.fod.login".equals(name)) hasFodLogin = true; + if ("fcli.fod.logout".equals(name)) hasFodLogout = true; + } + + assertTrue(hasSscLogin, "fcli.ssc.login method should be present"); + assertTrue(hasSscLogout, "fcli.ssc.logout method should be present"); + assertTrue(hasFodLogin, "fcli.fod.login method should be present"); + assertTrue(hasFodLogout, "fcli.fod.logout method should be present"); + } + + @Test + void shouldReturnInvalidParamsForSscLoginWithoutUrl() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.ssc.login\",\"params\":{\"token\":\"test\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("url")); + } + + @Test + void shouldReturnInvalidParamsForSscLoginWithoutAuth() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.ssc.login\",\"params\":{\"url\":\"https://ssc.example.com\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } + + @Test + void shouldReturnInvalidParamsForFodLoginWithoutUrl() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.fod.login\",\"params\":{\"client-id\":\"test\",\"client-secret\":\"test\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + assertTrue(node.get("error").get("message").asText().contains("url")); + } + + @Test + void shouldReturnInvalidParamsForFodLoginWithoutAuth() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.fod.login\",\"params\":{\"url\":\"https://ams.fortify.com\"},\"id\":1}" + ); + + // Assert + assertNotNull(response); + var node = objectMapper.readTree(response); + assertNotNull(node.get("error")); + assertEquals(-32602, node.get("error").get("code").asInt()); + } +}