From 0633dbdc6565d65e53a9903b0f0c0b3f908fcf3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:03:59 +0000 Subject: [PATCH 01/28] Initial plan From 98e73c64d22c9736fb0f69f4c6e4ce7a01158594 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:25:30 +0000 Subject: [PATCH 02/28] Add JSON-RPC server for IDE plugin integration Implements a lightweight JSON-RPC 2.0 server accessible via 'fcli util rpc-server start'. This provides programmatic access to fcli functionality for IDE plugins. Key features: - Custom JSON-RPC 2.0 implementation (no external dependencies, GraalVM compatible) - RPC methods: fcli.execute, fcli.listCommands, fcli.version, rpc.listMethods - Pagination support for record-producing commands - Structured JSON responses for IDE integration Reuses patterns from MCP server implementation. Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../cli/util/_main/cli/cmd/UtilCommands.java | 2 + .../rpc_server/cli/cmd/RPCServerCommands.java | 30 +++ .../cli/cmd/RPCServerStartCommand.java | 50 ++++ .../helper/rpc/IRpcMethodHandler.java | 33 +++ .../rpc_server/helper/rpc/JsonRpcError.java | 76 ++++++ .../rpc_server/helper/rpc/JsonRpcRequest.java | 43 +++ .../helper/rpc/JsonRpcResponse.java | 64 +++++ .../rpc_server/helper/rpc/JsonRpcServer.java | 216 +++++++++++++++ .../helper/rpc/RpcMethodException.java | 64 +++++ .../rpc/RpcMethodHandlerFcliExecute.java | 152 +++++++++++ .../rpc/RpcMethodHandlerFcliListCommands.java | 127 +++++++++ .../rpc/RpcMethodHandlerFcliVersion.java | 50 ++++ .../rpc/RpcMethodHandlerListMethods.java | 66 +++++ .../cli/util/i18n/UtilMessages.properties | 42 +++ .../rpc_server/unit/JsonRpcServerTest.java | 247 ++++++++++++++++++ 15 files changed, 1262 insertions(+) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodException.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java create mode 100644 fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java 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 00d29dbe6ea..e53ef42430a 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 @@ -17,6 +17,7 @@ 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; @@ -31,6 +32,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/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 00000000000..0e1f194677d --- /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-2025 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 00000000000..f53ef6ace59 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021-2025 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; +import picocli.CommandLine.Option; + +/** + * Command to start the fcli JSON-RPC server for IDE plugin integration. + * The server listens on stdin/stdout for JSON-RPC 2.0 requests. + * + * @author Ruud Senden + */ +@Command(name = OutputHelperMixins.Start.CMD_NAME) +@MCPExclude +@Slf4j +public class RPCServerStartCommand extends AbstractRunnableCommand { + @Option(names = {"--threads", "-t"}, defaultValue = "4") + private int threads; + + @Override + public Integer call() throws Exception { + log.info("Starting JSON-RPC server with {} threads", threads); + + var objectMapper = new ObjectMapper(); + var server = new JsonRpcServer(objectMapper, threads); + + // 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 00000000000..95fc1bb156f --- /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-2025 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 00000000000..e126beedd74 --- /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-2025 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 00000000000..e90ca5955bd --- /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-2025 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 00000000000..9d6ed0727a7 --- /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-2025 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 00000000000..aa1db52becb --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java @@ -0,0 +1,216 @@ +/* + * Copyright 2021-2025 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.ExecutorService; +import java.util.concurrent.Executors; +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 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 + * - Runs in a single thread for simplicity (IDE integration use case) + * + * @author Ruud Senden + */ +@Slf4j +public final class JsonRpcServer { + private final ObjectMapper objectMapper; + private final Map methodHandlers; + private final ExecutorService executor; + private final AtomicBoolean running = new AtomicBoolean(false); + + public JsonRpcServer(ObjectMapper objectMapper, int threadPoolSize) { + this.objectMapper = objectMapper; + this.methodHandlers = new LinkedHashMap<>(); + this.executor = Executors.newFixedThreadPool(threadPoolSize); + registerDefaultMethods(); + } + + private void registerDefaultMethods() { + // Register built-in fcli methods + registerMethod("fcli.execute", new RpcMethodHandlerFcliExecute(objectMapper)); + registerMethod("fcli.listCommands", new RpcMethodHandlerFcliListCommands(objectMapper)); + registerMethod("fcli.version", new RpcMethodHandlerFcliVersion(objectMapper)); + registerMethod("rpc.listMethods", new RpcMethodHandlerListMethods(objectMapper, methodHandlers)); + } + + /** + * 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. + */ + 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); + final String requestLine = line; + + // Process synchronously for stdio mode + String responseJson = processRequest(requestLine); + 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); + executor.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). + * This method is package-private for testing purposes. + */ + 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); + return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error: serialization failed\"},\"id\":null}"; + } + } +} 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 00000000000..e5f7ad95fd4 --- /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-2025 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/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 00000000000..cb52a830129 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java @@ -0,0 +1,152 @@ +/* + * Copyright 2021-2025 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 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.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. + * + * 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 + * - offset (integer, optional): For paging, the offset to start from (default: 0) + * - limit (integer, optional): For paging, the maximum number of records (default: 100) + * + * Returns: + * - exitCode (integer): The command exit code + * - records (array, optional): Array of record objects if collectRecords=true + * - stdout (string, optional): Standard output if collectRecords=false + * - stderr (string): Standard error output + * - pagination (object, optional): Pagination info for paged results + * + * @author Ruud Senden + */ +@Slf4j +@RequiredArgsConstructor +public final class RpcMethodHandlerFcliExecute implements IRpcMethodHandler { + private final ObjectMapper objectMapper; + + @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); + var offset = params.has("offset") ? params.get("offset").asInt(0) : 0; + var limit = params.has("limit") ? params.get("limit").asInt(100) : 100; + + if (command == null || command.isBlank()) { + throw RpcMethodException.invalidParams("'command' cannot be empty"); + } + + log.debug("Executing fcli command: {} (collectRecords={}, offset={}, limit={})", + command, collectRecords, offset, limit); + + try { + if (collectRecords) { + return executeWithRecords(command, offset, limit); + } 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) + .onFail(r -> {}) + .build().create().execute(); + + return buildResponse(result, null, null); + } + + private JsonNode executeWithRecords(String command, int offset, int limit) { + var allRecords = new ArrayList(); + + var result = FcliCommandExecutorFactory.builder() + .cmd(command) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .recordConsumer(allRecords::add) + .onFail(r -> {}) + .build().create().execute(); + + // Apply pagination + var totalRecords = allRecords.size(); + var endIndex = Math.min(offset + limit, totalRecords); + List pagedRecords = offset >= totalRecords + ? List.of() + : allRecords.subList(offset, endIndex); + + var pagination = buildPagination(offset, limit, totalRecords); + return buildResponse(result, pagedRecords, pagination); + } + + private ObjectNode buildResponse(Result result, List records, ObjectNode pagination) { + var response = objectMapper.createObjectNode(); + response.put("exitCode", result.getExitCode()); + + if (records != null) { + ArrayNode recordsArray = response.putArray("records"); + records.forEach(recordsArray::add); + } else { + response.put("stdout", result.getOut()); + } + + if (result.getErr() != null && !result.getErr().isBlank()) { + response.put("stderr", result.getErr()); + } + + if (pagination != null) { + response.set("pagination", pagination); + } + + return response; + } + + private ObjectNode buildPagination(int offset, int limit, int totalRecords) { + var pagination = objectMapper.createObjectNode(); + 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); + + if (offset + limit < totalRecords) { + pagination.put("nextOffset", offset + limit); + } + + return pagination; + } +} 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 00000000000..9038c941b9f --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java @@ -0,0 +1,127 @@ +/* + * Copyright 2021-2025 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.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 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 module = params != null && params.has("module") + ? params.get("module").asText(null) : null; + var runnableOnly = params != null && params.has("runnableOnly") + && params.get("runnableOnly").asBoolean(false); + var includeHidden = params != null && params.has("includeHidden") + && params.get("includeHidden").asBoolean(false); + + log.debug("Listing fcli commands (module={}, runnableOnly={}, includeHidden={})", + module, runnableOnly, includeHidden); + + try { + var rootSpec = FcliCommandSpecHelper.getRootCommandLine().getCommandSpec(); + Stream commandStream = FcliCommandSpecHelper.commandTreeStream(rootSpec); + + // Apply filters + if (module != null && !module.isBlank()) { + commandStream = commandStream.filter(spec -> + spec.qualifiedName(" ").startsWith("fcli " + module + " ") || + spec.qualifiedName(" ").equals("fcli " + module)); + } + + 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 ""; + } +} 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 00000000000..80e0f2dd3cc --- /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-2025 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/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 00000000000..f2096e41f96 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021-2025 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.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 = Map.of( + "fcli.execute", "Execute an fcli command and return structured results or stdout", + "fcli.listCommands", "List available fcli commands with optional filtering", + "fcli.version", "Get fcli version information", + "rpc.listMethods", "List available RPC methods" + ); + + @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/resources/com/fortify/cli/util/i18n/UtilMessages.properties b/fcli-core/fcli-util/src/main/resources/com/fortify/cli/util/i18n/UtilMessages.properties index 7da5271b2bf..c228d83a531 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,48 @@ 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.%n%n\ + Available RPC methods:%n\ + %n - fcli.execute: Execute any fcli command and return structured 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 - offset (integer, optional): For paging, the offset to start from (default: 0)\ + %n - limit (integer, optional): For paging, the maximum number of records (default: 100)\ + %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\ + Example IDE plugin configuration (VS Code settings.json style):%n\ + %n{\ + %n "fortify.fcli.path": "/path/to/fcli",\ + %n "fortify.rpc.args": ["util", "rpc-server", "start"]\ + %n}\ + %n%n\ + Example JSON-RPC request/response:%n\ + %nRequest: {"jsonrpc":"2.0","method":"fcli.version","id":1}\ + %nResponse: {"jsonrpc":"2.0","result":{"version":"x.y.z","buildDate":"..."},"id":1}\ + %n%n\ + Note: Like the MCP server, you'll need to have active fcli sessions for each product you want to \ + interact with. Login sessions must be created separately using 'fcli session login' commands. +fcli.util.rpc-server.start.threads = Number of threads for processing RPC requests. Default is 4. + # 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 00000000000..b1ea02518f1 --- /dev/null +++ b/fcli-core/fcli-util/src/test/java/com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java @@ -0,0 +1,247 @@ +/* + * Copyright 2021-2025 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, 2); + } + + @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 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")); + } +} From 7786d94cc13ca9af564b59e0db856120afe4d172 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 05:34:32 +0000 Subject: [PATCH 03/28] Address code review feedback - Remove unused ExecutorService and thread pool parameter from JsonRpcServer - Add validation for limit (must be > 0) and offset (must be >= 0) parameters - Cache qualifiedName in local variable in filter lambda - Use JsonRpcError.INTERNAL_ERROR constant in fallback error response Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../cli/cmd/RPCServerStartCommand.java | 10 +++---- .../rpc_server/helper/rpc/JsonRpcServer.java | 21 ++++++------- .../rpc/RpcMethodHandlerFcliExecute.java | 8 +++++ .../rpc/RpcMethodHandlerFcliListCommands.java | 9 ++++-- .../cli/util/i18n/UtilMessages.properties | 1 - .../rpc_server/unit/JsonRpcServerTest.java | 30 ++++++++++++++++++- 6 files changed, 56 insertions(+), 23 deletions(-) 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 index f53ef6ace59..582eb0250e6 100644 --- 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 @@ -20,11 +20,11 @@ import lombok.extern.slf4j.Slf4j; import picocli.CommandLine.Command; -import picocli.CommandLine.Option; /** * Command to start the fcli JSON-RPC server for IDE plugin integration. - * The server listens on stdin/stdout for JSON-RPC 2.0 requests. + * The server listens on stdin/stdout for JSON-RPC 2.0 requests and processes + * them synchronously. * * @author Ruud Senden */ @@ -32,15 +32,13 @@ @MCPExclude @Slf4j public class RPCServerStartCommand extends AbstractRunnableCommand { - @Option(names = {"--threads", "-t"}, defaultValue = "4") - private int threads; @Override public Integer call() throws Exception { - log.info("Starting JSON-RPC server with {} threads", threads); + log.info("Starting JSON-RPC server"); var objectMapper = new ObjectMapper(); - var server = new JsonRpcServer(objectMapper, threads); + var server = new JsonRpcServer(objectMapper); // Start the server on stdin/stdout server.start(System.in, System.out); 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 index aa1db52becb..7d9ef6effc2 100644 --- 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 @@ -20,8 +20,6 @@ import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import com.fasterxml.jackson.core.JsonProcessingException; @@ -40,7 +38,7 @@ * - Handles single requests and batch requests * - Supports notifications (requests without id) * - Is compatible with GraalVM native image compilation - * - Runs in a single thread for simplicity (IDE integration use case) + * - Processes requests synchronously (appropriate for stdio-based IDE integration) * * @author Ruud Senden */ @@ -48,13 +46,11 @@ public final class JsonRpcServer { private final ObjectMapper objectMapper; private final Map methodHandlers; - private final ExecutorService executor; private final AtomicBoolean running = new AtomicBoolean(false); - public JsonRpcServer(ObjectMapper objectMapper, int threadPoolSize) { + public JsonRpcServer(ObjectMapper objectMapper) { this.objectMapper = objectMapper; this.methodHandlers = new LinkedHashMap<>(); - this.executor = Executors.newFixedThreadPool(threadPoolSize); registerDefaultMethods(); } @@ -77,6 +73,7 @@ public void registerMethod(String methodName, IRpcMethodHandler handler) { /** * 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); @@ -93,10 +90,8 @@ public void start(InputStream input, OutputStream output) { } log.debug("Received request: {}", line); - final String requestLine = line; - // Process synchronously for stdio mode - String responseJson = processRequest(requestLine); + String responseJson = processRequest(line); if (responseJson != null) { log.debug("Sending response: {}", responseJson); writer.println(responseJson); @@ -106,7 +101,6 @@ public void start(InputStream input, OutputStream output) { log.error("Error in JSON-RPC server", e); } finally { running.set(false); - executor.shutdown(); log.info("JSON-RPC server stopped"); } } @@ -121,7 +115,6 @@ public void stop() { /** * Process a single JSON-RPC request line and return the response JSON. * Returns null for notifications (requests without id). - * This method is package-private for testing purposes. */ public String processRequest(String requestJson) { try { @@ -210,7 +203,11 @@ private String toJson(Object obj) { return objectMapper.writeValueAsString(obj); } catch (JsonProcessingException e) { log.error("Failed to serialize response", e); - return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error: serialization failed\"},\"id\":null}"; + // 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/RpcMethodHandlerFcliExecute.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java index cb52a830129..4bc10290ceb 100644 --- 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 @@ -65,6 +65,14 @@ public JsonNode execute(JsonNode params) throws RpcMethodException { throw RpcMethodException.invalidParams("'command' 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("Executing fcli command: {} (collectRecords={}, offset={}, limit={})", command, collectRecords, offset, limit); 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 index 9038c941b9f..9f3010cd563 100644 --- 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 @@ -66,9 +66,12 @@ public JsonNode execute(JsonNode params) throws RpcMethodException { // Apply filters if (module != null && !module.isBlank()) { - commandStream = commandStream.filter(spec -> - spec.qualifiedName(" ").startsWith("fcli " + module + " ") || - spec.qualifiedName(" ").equals("fcli " + module)); + final String modulePrefix = "fcli " + module + " "; + final String moduleExact = "fcli " + module; + commandStream = commandStream.filter(spec -> { + var qualifiedName = spec.qualifiedName(" "); + return qualifiedName.startsWith(modulePrefix) || qualifiedName.equals(moduleExact); + }); } if (runnableOnly) { 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 c228d83a531..9ed037c3fc2 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 @@ -146,7 +146,6 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %n%n\ Note: Like the MCP server, you'll need to have active fcli sessions for each product you want to \ interact with. Login sessions must be created separately using 'fcli session login' commands. -fcli.util.rpc-server.start.threads = Number of threads for processing RPC requests. Default is 4. # fcli util sample-data fcli.util.sample-data.usage.header = (INTERNAL) Generate 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 index b1ea02518f1..d8902462c86 100644 --- 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 @@ -37,7 +37,7 @@ class JsonRpcServerTest { @BeforeEach void setUp() { objectMapper = new ObjectMapper(); - server = new JsonRpcServer(objectMapper, 2); + server = new JsonRpcServer(objectMapper); } @Test @@ -157,6 +157,34 @@ void shouldReturnInvalidParamsForExecuteWithoutCommand() throws Exception { assertTrue(node.get("error").get("message").asText().contains("command")); } + @Test + void shouldReturnInvalidParamsForZeroLimit() throws Exception { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{\"command\":\"util sample-data list\",\"collectRecords\":true,\"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 { + // Act + String response = server.processRequest( + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{\"command\":\"util sample-data list\",\"collectRecords\":true,\"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 From 0c633c0141242629d2834f37b7ea6dabf099f631 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:52:52 +0000 Subject: [PATCH 04/28] Add caching and async execution support to JSON-RPC server - Create shared _common/helper package with reusable components: - FcliRecordsCache: Background loading and caching for large result sets - FcliToolResult: Unified result class for fcli command execution - FcliRunnerHelper: Helper methods for running fcli commands - Add new RPC methods for async execution and cache management: - fcli.executeAsync: Start background collection, returns cacheKey - fcli.getPage: Retrieve page from cache by cacheKey with wait option - fcli.cancelCollection: Cancel in-progress collection - fcli.clearCache: Clear cache entries - Update documentation and tests for new methods Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../util/_common/helper/FcliRecordsCache.java | 352 ++++++++++++++++++ .../util/_common/helper/FcliRunnerHelper.java | 73 ++++ .../util/_common/helper/FcliToolResult.java | 241 ++++++++++++ .../rpc_server/helper/rpc/JsonRpcServer.java | 9 + .../RpcMethodHandlerFcliCancelCollection.java | 66 ++++ .../rpc/RpcMethodHandlerFcliClearCache.java | 74 ++++ .../rpc/RpcMethodHandlerFcliExecuteAsync.java | 70 ++++ .../rpc/RpcMethodHandlerFcliGetPage.java | 196 ++++++++++ .../rpc/RpcMethodHandlerListMethods.java | 6 +- .../cli/util/i18n/UtilMessages.properties | 25 +- .../rpc_server/unit/JsonRpcServerTest.java | 127 +++++++ 11 files changed, 1237 insertions(+), 2 deletions(-) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliCancelCollection.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java 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 00000000000..025d32d5f73 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRecordsCache.java @@ -0,0 +1,352 @@ +/* + * Copyright 2021-2025 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 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 + * + * @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; + + 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); + } + + /** + * 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) { + return CompletableFuture.supplyAsync(() -> { + var records = entry.getRecords(); + var result = FcliRunnerHelper.collectRecords(command, record -> { + if (!Thread.currentThread().isInterrupted()) { + records.add(record); + } + }); + + 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 00000000000..1187e936684 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliRunnerHelper.java @@ -0,0 +1,73 @@ +/* + * Copyright 2021-2025 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.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 FcliCommandExecutorFactory.builder() + .cmd(fullCmd) + .stdoutOutputType(OutputType.collect) + .stderrOutputType(OutputType.collect) + .onFail(r -> {}) + .build().create().execute(); + } + + /** + * Execute a command and collect structured records. + */ + public static Result collectRecords(String fullCmd, Consumer recordConsumer) { + return FcliCommandExecutorFactory.builder() + .cmd(fullCmd) + .stdoutOutputType(OutputType.suppress) + .stderrOutputType(OutputType.collect) + .recordConsumer(recordConsumer) + .onFail(r -> {}) + .build().create().execute(); + } + + /** + * Execute a command and return a FcliToolResult with all collected records. + */ + public static FcliToolResult collectRecordsAsResult(String fullCmd) { + var records = new ArrayList(); + var result = collectRecords(fullCmd, records::add); + return FcliToolResult.fromRecords(result, records); + } + + /** + * Execute a command and return a FcliToolResult with stdout. + */ + public static FcliToolResult collectStdoutAsResult(String fullCmd) { + var result = collectStdout(fullCmd); + 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 00000000000..7877998ac74 --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/_common/helper/FcliToolResult.java @@ -0,0 +1,241 @@ +/* + * Copyright 2021-2025 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/rpc_server/helper/rpc/JsonRpcServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java index 7d9ef6effc2..ba942563ccc 100644 --- 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 @@ -26,6 +26,7 @@ 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; @@ -39,6 +40,7 @@ * - 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 * * @author Ruud Senden */ @@ -47,16 +49,22 @@ public final class JsonRpcServer { private final ObjectMapper objectMapper; private final Map methodHandlers; private final AtomicBoolean running = new AtomicBoolean(false); + private final FcliRecordsCache cache; public JsonRpcServer(ObjectMapper objectMapper) { this.objectMapper = objectMapper; this.methodHandlers = new LinkedHashMap<>(); + this.cache = new FcliRecordsCache(); registerDefaultMethods(); } private void registerDefaultMethods() { // Register built-in fcli methods registerMethod("fcli.execute", new RpcMethodHandlerFcliExecute(objectMapper)); + 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)); @@ -101,6 +109,7 @@ public void start(InputStream input, OutputStream output) { log.error("Error in JSON-RPC server", e); } finally { running.set(false); + cache.shutdown(); log.info("JSON-RPC server stopped"); } } 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 00000000000..701a73c1bc6 --- /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-2025 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 00000000000..4b41d4039cc --- /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-2025 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/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 00000000000..bded6dc23a8 --- /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-2025 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 00000000000..3198d931d89 --- /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-2025 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/RpcMethodHandlerListMethods.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java index f2096e41f96..bdbf7451e71 100644 --- 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 @@ -40,7 +40,11 @@ public final class RpcMethodHandlerListMethods implements IRpcMethodHandler { private final Map methodHandlers; private static final Map METHOD_DESCRIPTIONS = Map.of( - "fcli.execute", "Execute an fcli command and return structured results or stdout", + "fcli.execute", "Execute an fcli command synchronously and return structured results or stdout", + "fcli.executeAsync", "Start async fcli command execution, returns cacheKey for retrieving results", + "fcli.getPage", "Retrieve a page of results from cache by cacheKey (from fcli.executeAsync)", + "fcli.cancelCollection", "Cancel an in-progress async collection by cacheKey", + "fcli.clearCache", "Clear cache entries (specific cacheKey or all)", "fcli.listCommands", "List available fcli commands with optional filtering", "fcli.version", "Get fcli version information", "rpc.listMethods", "List available RPC methods" 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 9ed037c3fc2..d1d5045a48a 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 @@ -115,13 +115,34 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides 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.%n%n\ Available RPC methods:%n\ - %n - fcli.execute: Execute any fcli command and return structured results\ + %n - fcli.execute: Execute an fcli command synchronously and return structured 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 - offset (integer, optional): For paging, the offset to start from (default: 0)\ %n - limit (integer, optional): For paging, the maximum number of records (default: 100)\ %n\ + %n - fcli.executeAsync: Start async command execution, returns cacheKey for retrieving results\ + %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 - fcli.listCommands: List available fcli commands with optional filtering\ %n Parameters:\ %n - module (string, optional): Filter by module (e.g., "ssc", "fod")\ @@ -134,6 +155,8 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %n - rpc.listMethods: List available RPC methods\ %n Parameters: none\ %n%n\ + For commands that return large datasets (e.g., issue lists), use fcli.executeAsync followed by \ + fcli.getPage to efficiently retrieve paged results with background loading and caching.%n%n\ Example IDE plugin configuration (VS Code settings.json style):%n\ %n{\ %n "fortify.fcli.path": "/path/to/fcli",\ 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 index d8902462c86..4e4c31fac90 100644 --- 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 @@ -272,4 +272,131 @@ void shouldReturnErrorForListCommandsWithoutAppContext() throws Exception { // 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"); + } } From 94a279250d9d81f1e70a9361735b2206ba3b03f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:00:47 +0000 Subject: [PATCH 05/28] Add product-specific session methods and improve security - Add fcli.ssc.login, fcli.ssc.logout for SSC sessions - Add fcli.fod.login, fcli.fod.logout for FoD sessions - Auto-logout all sessions when RPC server terminates - Auto-add session options to commands based on product type - Remove paging from fcli.execute (use async methods for large datasets) - Improve argument quoting to prevent injection issues - Fix logoutAll to avoid duplicate logout attempts - Update documentation and tests Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../util/_common/helper/FcliRecordsCache.java | 15 +- .../util/_common/helper/FcliRunnerHelper.java | 55 ++- .../rpc_server/helper/rpc/JsonRpcServer.java | 19 +- .../rpc/RpcMethodHandlerFcliExecute.java | 65 +--- .../helper/rpc/RpcMethodHandlerFodLogin.java | 115 +++++++ .../helper/rpc/RpcMethodHandlerFodLogout.java | 47 +++ .../rpc/RpcMethodHandlerListMethods.java | 34 +- .../helper/rpc/RpcMethodHandlerSscLogin.java | 118 +++++++ .../helper/rpc/RpcMethodHandlerSscLogout.java | 47 +++ .../helper/rpc/RpcSessionManager.java | 321 ++++++++++++++++++ .../cli/util/i18n/UtilMessages.properties | 65 ++-- .../rpc_server/unit/JsonRpcServerTest.java | 103 +++++- 12 files changed, 911 insertions(+), 93 deletions(-) create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java create mode 100644 fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcSessionManager.java 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 index 025d32d5f73..073fbfad97e 100644 --- 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 @@ -23,6 +23,7 @@ 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; @@ -39,6 +40,7 @@ * - 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 */ @@ -53,6 +55,7 @@ public class FcliRecordsCache { 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); @@ -76,6 +79,13 @@ protected boolean removeEldestEntry(Map.Entry eldest) { 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). @@ -118,13 +128,16 @@ private InProgressEntry startNewBackgroundCollection(String cacheKey, String com } 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; 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 index 1187e936684..ba27f6a4c9f 100644 --- 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 @@ -13,6 +13,7 @@ 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; @@ -33,33 +34,64 @@ public class FcliRunnerHelper { * Execute a command and collect stdout output. */ public static Result collectStdout(String fullCmd) { - return FcliCommandExecutorFactory.builder() + 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 -> {}) - .build().create().execute(); + .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 FcliCommandExecutorFactory.builder() + 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 -> {}) - .build().create().execute(); + .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); + var result = collectRecords(fullCmd, records::add, defaultOptions); return FcliToolResult.fromRecords(result, records); } @@ -67,7 +99,14 @@ public static FcliToolResult collectRecordsAsResult(String fullCmd) { * Execute a command and return a FcliToolResult with stdout. */ public static FcliToolResult collectStdoutAsResult(String fullCmd) { - var result = collectStdout(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/rpc_server/helper/rpc/JsonRpcServer.java b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java index ba942563ccc..60f4d2781f8 100644 --- 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 @@ -41,6 +41,7 @@ * - 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 */ @@ -50,17 +51,23 @@ public final class JsonRpcServer { 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)); + 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)); @@ -68,6 +75,14 @@ private void registerDefaultMethods() { 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()); + } } /** @@ -109,6 +124,8 @@ public void start(InputStream input, OutputStream output) { 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"); } 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 index 4bc10290ceb..672ce42e124 100644 --- 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 @@ -13,7 +13,6 @@ package com.fortify.cli.util.rpc_server.helper.rpc; import java.util.ArrayList; -import java.util.List; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -27,21 +26,21 @@ import lombok.extern.slf4j.Slf4j; /** - * RPC method handler for executing fcli commands. + * 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 - * - offset (integer, optional): For paging, the offset to start from (default: 0) - * - limit (integer, optional): For paging, the maximum number of records (default: 100) * * Returns: * - exitCode (integer): The command exit code - * - records (array, optional): Array of record objects if collectRecords=true + * - records (array, optional): Array of ALL record objects if collectRecords=true * - stdout (string, optional): Standard output if collectRecords=false * - stderr (string): Standard error output - * - pagination (object, optional): Pagination info for paged results + * + * 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 */ @@ -49,6 +48,7 @@ @RequiredArgsConstructor public final class RpcMethodHandlerFcliExecute implements IRpcMethodHandler { private final ObjectMapper objectMapper; + private final RpcSessionManager sessionManager; @Override public JsonNode execute(JsonNode params) throws RpcMethodException { @@ -58,27 +58,16 @@ public JsonNode execute(JsonNode params) throws RpcMethodException { var command = params.get("command").asText(); var collectRecords = params.has("collectRecords") && params.get("collectRecords").asBoolean(false); - var offset = params.has("offset") ? params.get("offset").asInt(0) : 0; - var limit = params.has("limit") ? params.get("limit").asInt(100) : 100; if (command == null || command.isBlank()) { throw RpcMethodException.invalidParams("'command' 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("Executing fcli command: {} (collectRecords={}, offset={}, limit={})", - command, collectRecords, offset, limit); + log.debug("Executing fcli command: {} (collectRecords={})", command, collectRecords); try { if (collectRecords) { - return executeWithRecords(command, offset, limit); + return executeWithRecords(command); } else { return executeWithStdout(command); } @@ -93,13 +82,14 @@ private JsonNode executeWithStdout(String command) { .cmd(command) .stdoutOutputType(OutputType.collect) .stderrOutputType(OutputType.collect) + .defaultOptionsIfNotPresent(sessionManager.getSessionOptionsForCommand(command)) .onFail(r -> {}) .build().create().execute(); - return buildResponse(result, null, null); + return buildResponse(result, null); } - private JsonNode executeWithRecords(String command, int offset, int limit) { + private JsonNode executeWithRecords(String command) { var allRecords = new ArrayList(); var result = FcliCommandExecutorFactory.builder() @@ -107,27 +97,21 @@ private JsonNode executeWithRecords(String command, int offset, int limit) { .stdoutOutputType(OutputType.suppress) .stderrOutputType(OutputType.collect) .recordConsumer(allRecords::add) + .defaultOptionsIfNotPresent(sessionManager.getSessionOptionsForCommand(command)) .onFail(r -> {}) .build().create().execute(); - // Apply pagination - var totalRecords = allRecords.size(); - var endIndex = Math.min(offset + limit, totalRecords); - List pagedRecords = offset >= totalRecords - ? List.of() - : allRecords.subList(offset, endIndex); - - var pagination = buildPagination(offset, limit, totalRecords); - return buildResponse(result, pagedRecords, pagination); + return buildResponse(result, allRecords); } - private ObjectNode buildResponse(Result result, List records, ObjectNode pagination) { + 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()); } @@ -136,25 +120,6 @@ private ObjectNode buildResponse(Result result, List records, ObjectNo response.put("stderr", result.getErr()); } - if (pagination != null) { - response.set("pagination", pagination); - } - return response; } - - private ObjectNode buildPagination(int offset, int limit, int totalRecords) { - var pagination = objectMapper.createObjectNode(); - 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); - - if (offset + limit < totalRecords) { - pagination.put("nextOffset", offset + limit); - } - - return pagination; - } } 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 00000000000..5747a157a41 --- /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-2025 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 00000000000..8cde43f8f25 --- /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-2025 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 index bdbf7451e71..30d3b7b5395 100644 --- 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 @@ -12,6 +12,7 @@ */ package com.fortify.cli.util.rpc_server.helper.rpc; +import java.util.HashMap; import java.util.Map; import com.fasterxml.jackson.databind.JsonNode; @@ -39,16 +40,29 @@ public final class RpcMethodHandlerListMethods implements IRpcMethodHandler { private final ObjectMapper objectMapper; private final Map methodHandlers; - private static final Map METHOD_DESCRIPTIONS = Map.of( - "fcli.execute", "Execute an fcli command synchronously and return structured results or stdout", - "fcli.executeAsync", "Start async fcli command execution, returns cacheKey for retrieving results", - "fcli.getPage", "Retrieve a page of results from cache by cacheKey (from fcli.executeAsync)", - "fcli.cancelCollection", "Cancel an in-progress async collection by cacheKey", - "fcli.clearCache", "Clear cache entries (specific cacheKey or all)", - "fcli.listCommands", "List available fcli commands with optional filtering", - "fcli.version", "Get fcli version information", - "rpc.listMethods", "List available RPC methods" - ); + 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 { 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 00000000000..a469dde1ebe --- /dev/null +++ b/fcli-core/fcli-util/src/main/java/com/fortify/cli/util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java @@ -0,0 +1,118 @@ +/* + * Copyright 2021-2025 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 + * - ci-token (string, optional): CIToken for CI/CD integration + * - 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), token, or ci-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|--ci-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 (params.has("ci-token")) { + sb.append("--ci-token ").append(quoteValue(params.get("ci-token").asText())).append(" "); + hasAuth = true; + } + + if (!hasAuth) { + throw RpcMethodException.invalidParams( + "SSC login requires one of: (user + password), token, or ci-token"); + } + + // Optional parameters + if (params.has("expire-in")) { + sb.append("--expire-in ").append(params.get("expire-in").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 00000000000..25b33917ecd --- /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-2025 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 00000000000..0a8d61226d3 --- /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-2025 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 d1d5045a48a..027012e8ea6 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 @@ -113,16 +113,46 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides 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.%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 - fcli.execute: Execute an fcli command synchronously and return structured results\ + %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\ + %n - ci-token (string): CIToken for CI/CD\ + %n - expire-in (string): Token expiration (e.g., "1d", "8h")\ + %n - insecure (boolean): Allow insecure connections\ + %n Note: Requires one of (user+password), token, or ci-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 - offset (integer, optional): For paging, the offset to start from (default: 0)\ - %n - limit (integer, optional): For paging, the maximum number of records (default: 100)\ + %n Note: For large datasets, use fcli.executeAsync + fcli.getPage instead\ %n\ - %n - fcli.executeAsync: Start async command execution, returns cacheKey for retrieving results\ + %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\ @@ -143,6 +173,8 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %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")\ @@ -155,20 +187,15 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %n - rpc.listMethods: List available RPC methods\ %n Parameters: none\ %n%n\ - For commands that return large datasets (e.g., issue lists), use fcli.executeAsync followed by \ - fcli.getPage to efficiently retrieve paged results with background loading and caching.%n%n\ - Example IDE plugin configuration (VS Code settings.json style):%n\ - %n{\ - %n "fortify.fcli.path": "/path/to/fcli",\ - %n "fortify.rpc.args": ["util", "rpc-server", "start"]\ - %n}\ - %n%n\ - Example JSON-RPC request/response:%n\ - %nRequest: {"jsonrpc":"2.0","method":"fcli.version","id":1}\ - %nResponse: {"jsonrpc":"2.0","result":{"version":"x.y.z","buildDate":"..."},"id":1}\ - %n%n\ - Note: Like the MCP server, you'll need to have active fcli sessions for each product you want to \ - interact with. Login sessions must be created separately using 'fcli session login' commands. + 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 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 index 4e4c31fac90..716fa4b0e39 100644 --- 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 @@ -159,9 +159,9 @@ void shouldReturnInvalidParamsForExecuteWithoutCommand() throws Exception { @Test void shouldReturnInvalidParamsForZeroLimit() throws Exception { - // Act + // Test limit validation in fcli.getPage String response = server.processRequest( - "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{\"command\":\"util sample-data list\",\"collectRecords\":true,\"limit\":0},\"id\":1}"); + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"test-key\",\"limit\":0},\"id\":1}"); // Assert assertNotNull(response); @@ -173,9 +173,9 @@ void shouldReturnInvalidParamsForZeroLimit() throws Exception { @Test void shouldReturnInvalidParamsForNegativeOffset() throws Exception { - // Act + // Test offset validation in fcli.getPage String response = server.processRequest( - "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.execute\",\"params\":{\"command\":\"util sample-data list\",\"collectRecords\":true,\"offset\":-5},\"id\":1}"); + "{\"jsonrpc\":\"2.0\",\"method\":\"fcli.getPage\",\"params\":{\"cacheKey\":\"test-key\",\"offset\":-5},\"id\":1}"); // Assert assertNotNull(response); @@ -399,4 +399,99 @@ void shouldListAllNewMethodsInRpcListMethods() throws Exception { 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()); + } } From 38cbca8b3f0aea24981f7d25c581246dd3e0a999 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:27:28 +0000 Subject: [PATCH 06/28] Update SSC login: remove ci-token, add client-auth-token and sc-sast-url Per review feedback: - Removed ci-token (equivalent to token) - Added client-auth-token for SC-SAST authentication - Added sc-sast-url for SC-SAST controller URL - insecure mode already supported for both login methods Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../helper/rpc/RpcMethodHandlerSscLogin.java | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) 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 index a469dde1ebe..e991f9f6d8d 100644 --- 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 @@ -28,11 +28,12 @@ * - user (string, optional): Username for user/password auth * - password (string, optional): Password for user/password auth * - token (string, optional): UnifiedLoginToken for token-based auth - * - ci-token (string, optional): CIToken for CI/CD integration + * - 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), token, or ci-token. + * At least one auth method must be provided: (user+password) or token. * * Returns: * - success (boolean): Whether login was successful @@ -56,7 +57,7 @@ public JsonNode execute(JsonNode params) throws RpcMethodException { var loginArgs = buildLoginArgs(params); - log.debug("SSC login with args: {}", loginArgs.replaceAll("(--password|--token|--ci-token)\\s+\\S+", "$1 ***")); + log.debug("SSC login with args: {}", loginArgs.replaceAll("(--password|--token|--client-auth-token)\\s+\\S+", "$1 ***")); return sessionManager.executeLogin(ProductType.SSC, loginArgs); } @@ -81,14 +82,9 @@ private String buildLoginArgs(JsonNode params) throws RpcMethodException { hasAuth = true; } - if (params.has("ci-token")) { - sb.append("--ci-token ").append(quoteValue(params.get("ci-token").asText())).append(" "); - hasAuth = true; - } - if (!hasAuth) { throw RpcMethodException.invalidParams( - "SSC login requires one of: (user + password), token, or ci-token"); + "SSC login requires one of: (user + password) or token"); } // Optional parameters @@ -96,6 +92,14 @@ private String buildLoginArgs(JsonNode params) throws RpcMethodException { 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 "); } From 56386375f18da3d5f2dfb6c42fd9487da2923113 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Nov 2025 00:00:47 +0000 Subject: [PATCH 07/28] Update RPC server usage help with correct method signatures - Remove ci-token (now just use token for both UnifiedLoginToken and CIToken) - Add client-auth-token and sc-sast-url for SC-SAST authentication - Fix authentication note to reflect current implementation Co-authored-by: rsenden <8635138+rsenden@users.noreply.github.com> --- .../com/fortify/cli/util/i18n/UtilMessages.properties | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 027012e8ea6..83e95e1d24c 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 @@ -123,11 +123,12 @@ fcli.util.rpc-server.start.usage.description = The fcli JSON-RPC server provides %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\ - %n - ci-token (string): CIToken for CI/CD\ + %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), token, or ci-token\ + %n Note: Requires one of (user+password) or token\ %n\ %n - fcli.ssc.logout: Logout from SSC session\ %n\ From f4568bac957ed3e36c023f4ade6d3f851a615216 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Tue, 13 Jan 2026 15:46:11 +0530 Subject: [PATCH 08/28] Fixing build failures --- .../com/fortify/cli/util/_common/helper/FcliRecordsCache.java | 2 +- .../com/fortify/cli/util/_common/helper/FcliRunnerHelper.java | 2 +- .../com/fortify/cli/util/_common/helper/FcliToolResult.java | 2 +- .../fortify/cli/util/rpc_server/cli/cmd/RPCServerCommands.java | 2 +- .../cli/util/rpc_server/cli/cmd/RPCServerStartCommand.java | 2 +- .../cli/util/rpc_server/helper/rpc/IRpcMethodHandler.java | 2 +- .../fortify/cli/util/rpc_server/helper/rpc/JsonRpcError.java | 2 +- .../fortify/cli/util/rpc_server/helper/rpc/JsonRpcRequest.java | 2 +- .../fortify/cli/util/rpc_server/helper/rpc/JsonRpcResponse.java | 2 +- .../fortify/cli/util/rpc_server/helper/rpc/JsonRpcServer.java | 2 +- .../cli/util/rpc_server/helper/rpc/RpcMethodException.java | 2 +- .../helper/rpc/RpcMethodHandlerFcliCancelCollection.java | 2 +- .../rpc_server/helper/rpc/RpcMethodHandlerFcliClearCache.java | 2 +- .../util/rpc_server/helper/rpc/RpcMethodHandlerFcliExecute.java | 2 +- .../rpc_server/helper/rpc/RpcMethodHandlerFcliExecuteAsync.java | 2 +- .../util/rpc_server/helper/rpc/RpcMethodHandlerFcliGetPage.java | 2 +- .../rpc_server/helper/rpc/RpcMethodHandlerFcliListCommands.java | 2 +- .../util/rpc_server/helper/rpc/RpcMethodHandlerFcliVersion.java | 2 +- .../util/rpc_server/helper/rpc/RpcMethodHandlerFodLogin.java | 2 +- .../util/rpc_server/helper/rpc/RpcMethodHandlerFodLogout.java | 2 +- .../util/rpc_server/helper/rpc/RpcMethodHandlerListMethods.java | 2 +- .../util/rpc_server/helper/rpc/RpcMethodHandlerSscLogin.java | 2 +- .../util/rpc_server/helper/rpc/RpcMethodHandlerSscLogout.java | 2 +- .../cli/util/rpc_server/helper/rpc/RpcSessionManager.java | 2 +- .../com/fortify/cli/util/rpc_server/unit/JsonRpcServerTest.java | 2 +- 25 files changed, 25 insertions(+), 25 deletions(-) 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 index 073fbfad97e..11ade4fa467 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index ba27f6a4c9f..fb84e71a19b 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 7877998ac74..2113ef8a6e6 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 0e1f194677d..abea2e15a1c 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 582eb0250e6..347792dc73a 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 95fc1bb156f..8d636686f0e 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index e126beedd74..30d013c0d67 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index e90ca5955bd..ff806274dd2 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 9d6ed0727a7..3f6b4a76951 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 60f4d2781f8..1dad92274ac 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index e5f7ad95fd4..765dfbe5e1d 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 701a73c1bc6..fc1f180f4d6 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 4b41d4039cc..d70312633d0 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 672ce42e124..d9866188821 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index bded6dc23a8..3df184848aa 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 3198d931d89..01d24936801 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 9f3010cd563..93150f72781 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 80e0f2dd3cc..90036217a3f 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 5747a157a41..4876c93134a 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 8cde43f8f25..0f70ef24f24 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 30d3b7b5395..28460371221 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index e991f9f6d8d..4f8ed19557f 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 25b33917ecd..aeed4f0e52c 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 0a8d61226d3..300492039ae 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 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 index 716fa4b0e39..d73d86ba527 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2025 Open Text. + * 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 From c06824a2f804bbc46ceea5f7c632e9c0f4feb8f1 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Wed, 21 Jan 2026 11:57:34 +0530 Subject: [PATCH 09/28] Adding separate nodes for Command Parameters, Command Optons, Output Options and Generic Options for util all-commands commad output in json format --- .../AllCommandsCommandSelectorMixin.java | 202 +++++++++++++++++- 1 file changed, 200 insertions(+), 2 deletions(-) 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 600ac64b67a..18442fc4d68 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 @@ -13,12 +13,15 @@ package com.fortify.cli.util.all_commands.cli.mixin; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; 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; @@ -31,6 +34,8 @@ import lombok.Data; import lombok.Getter; import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.OptionSpec; +import picocli.CommandLine.Model.PositionalParamSpec; import picocli.CommandLine.Option; /** @@ -102,11 +107,204 @@ private static final ObjectNode createNode(CommandSpec spec) { 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(", "))); + + var mapper = JsonHelper.getObjectMapper(); + ArrayNode commandParameters = mapper.createArrayNode(); + ArrayNode commandOptions = mapper.createArrayNode(); + ArrayNode outputOptions = mapper.createArrayNode(); + ArrayNode genericOptions = mapper.createArrayNode(); + + var positionalParams = spec.positionalParameters(); + for (int i = 0; i < positionalParams.size(); i++) { + commandParameters.add(createParameterNode(spec, positionalParams.get(i), i)); + } + + spec.options().forEach(option -> { + ObjectNode optionNode = createOptionNode(spec, option); + switch (getOptionCategory(option)) { + case OUTPUT -> outputOptions.add(optionNode); + case GENERIC -> genericOptions.add(optionNode); + case COMMAND -> commandOptions.add(optionNode); + } + }); + + // 1) legacy flat fields first + result.set("options", spec.optionsMap().keySet().stream() + .map(TextNode::new) + .collect(JsonHelper.arrayNodeCollector())); + result.put("optionsString", spec.optionsMap().keySet().stream() + .collect(Collectors.joining(", "))); + + // 2) grouped fields LAST + result.set("commandParameters", commandParameters); + result.set("commandOptions", commandOptions); + result.set("outputOptions", outputOptions); + result.set("genericOptions", genericOptions); + return result; } + private static ObjectNode createOptionNode(CommandSpec spec, OptionSpec option) { + var mapper = JsonHelper.getObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + + // names + ArrayNode namesArray = mapper.createArrayNode(); + for (String name : option.names()) { + namesArray.add(name); + } + node.set("names", namesArray); + + // description + String description = String.join(" ", option.description()); + node.put("description", description); + + // required + node.put("required", option.required()); + + Class type = option.type(); + + // allowedValues: completion candidates or enum constants + ArrayNode allowed = null; + var completionCandidates = option.completionCandidates(); + if (completionCandidates != null && completionCandidates.iterator().hasNext()) { + allowed = mapper.createArrayNode(); + completionCandidates.forEach(allowed::add); + } else if (type != null && type.isEnum()) { + allowed = mapper.createArrayNode(); + for (Object v : type.getEnumConstants()) { + allowed.add(v.toString()); + } + } + if (allowed != null) { + node.set("allowedValues", allowed); + } + + // Does the option take a value, or is it a pure flag? + boolean takesValue = option.arity().max() != 0; + + String datatype; + boolean multiselect; + + if (!takesValue) { + // key only, no value => Boolean flag + datatype = "Boolean"; + multiselect = false; + } else { + boolean multiValued = + (type != null && (type.isArray() || Collection.class.isAssignableFrom(type))) + || (option.splitRegex() != null && allowed == null); + + boolean hasAllowed = allowed != null && allowed.size() > 0; + boolean isFileType = type != null && java.io.File.class.isAssignableFrom(type); + + if (isFileType) { + // options that accept file paths + datatype = "File"; + multiselect = multiValued; + } else if (hasAllowed) { + // value chosen from a fixed set + datatype = "List"; + multiselect = multiValued; // false for enums like --log-level / --log-mask + } else if (multiValued) { + // multiple values (free-form) + datatype = "List"; + multiselect = true; + } else { + // free-form single value + datatype = "String"; + multiselect = false; + } + } + + node.put("multiselect", multiselect); + node.put("datatype", datatype); + + // shortLabel: bundle override, then computed + String longName = Arrays.stream(option.names()) + .filter(n -> n.startsWith("--")) + .findFirst() + .orElse(option.names().length > 0 ? option.names()[0] : ""); + String key = longName.replaceFirst("^-+", ""); + String shortLabel = null; + if (!key.isBlank()) { + shortLabel = FcliCommandSpecHelper.getMessageString(spec, key + ".shortLabel"); + } + if (shortLabel == null || shortLabel.isBlank()) { + shortLabel = computeShortLabelFromKey(key); + } + node.put("shortLabel", shortLabel); + + return node; + } + + private static ObjectNode createParameterNode(CommandSpec spec, PositionalParamSpec param, int index) { + var mapper = JsonHelper.getObjectMapper(); + ObjectNode node = mapper.createObjectNode(); + + int paramNumber = index + 1; + node.put("index", paramNumber); + + String label = param.paramLabel(); + if (label == null || label.isBlank()) { + label = "Param" + paramNumber; + } + node.put("label", label); + + String description = String.join(" ", param.description()); + node.put("description", description); + node.put("required", true); + node.put("multiselect", false); + node.put("datatype", "String"); + node.put("shortLabel", "Param" + paramNumber); + + return node; + } + private static String computeShortLabelFromKey(String key) { + if (key == null) { + return null; + } + String noDashes = key.replaceFirst("^-+", ""); + String spaced = noDashes.replace('-', ' '); + if (spaced.isEmpty()) { + return spaced; + } + char[] chars = spaced.toCharArray(); + chars[0] = Character.toUpperCase(chars[0]); + return new String(chars); + } + + private static enum OptionCategory { COMMAND, OUTPUT, GENERIC } + + private static OptionCategory getOptionCategory(OptionSpec option) { + String longName = Arrays.stream(option.names()) + .filter(n -> n.startsWith("--")) + .findFirst() + .orElse(option.names().length > 0 ? option.names()[0] : ""); + + // Output options + if (longName.equals("--output") + || longName.equals("--style") + || longName.equals("--store") + || longName.equals("--to-file")) { + return OptionCategory.OUTPUT; + } + + // Generic/global options + if (longName.equals("--env-prefix") + || longName.equals("--log-file") + || longName.equals("--log-level") + || longName.equals("--log-mask") + || longName.equals("--debug") + || longName.equals("--help") + || longName.endsWith("-session")) { + return OptionCategory.GENERIC; + } + + // Default: command-specific + return OptionCategory.COMMAND; + } + /** * Compute all possible full command aliases for the given {@link CommandSpec} by * generating the cartesian product of primary names + aliases for every command From 92367a0e7570211fc6666fa4df8258e3f0973f44 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Wed, 18 Feb 2026 16:34:52 +0530 Subject: [PATCH 10/28] Supporting additional details in json output of the command cli util all-commands list -o json to facilicate IDE plugin development --- .../AllCommandsCommandSelectorMixin.java | 620 ++++++++++++------ 1 file changed, 429 insertions(+), 191 deletions(-) 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 18442fc4d68..7570eb0aaca 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 @@ -15,8 +15,11 @@ 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; @@ -33,6 +36,7 @@ import lombok.Data; import lombok.Getter; +import picocli.CommandLine.Model.ArgGroupSpec; import picocli.CommandLine.Model.CommandSpec; import picocli.CommandLine.Model.OptionSpec; import picocli.CommandLine.Model.PositionalParamSpec; @@ -46,6 +50,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) @@ -53,25 +61,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); @@ -87,10 +95,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); @@ -101,252 +121,470 @@ 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; + } + + private final static ObjectNode createCommandArgsNode(CommandSpec spec, Map requiredByOption, + List exclusiveGroups) { var mapper = JsonHelper.getObjectMapper(); - ArrayNode commandParameters = mapper.createArrayNode(); - ArrayNode commandOptions = mapper.createArrayNode(); - ArrayNode outputOptions = mapper.createArrayNode(); - ArrayNode genericOptions = mapper.createArrayNode(); - - var positionalParams = spec.positionalParameters(); - for (int i = 0; i < positionalParams.size(); i++) { - commandParameters.add(createParameterNode(spec, positionalParams.get(i), i)); - } - - spec.options().forEach(option -> { - ObjectNode optionNode = createOptionNode(spec, option); - switch (getOptionCategory(option)) { - case OUTPUT -> outputOptions.add(optionNode); - case GENERIC -> genericOptions.add(optionNode); - case COMMAND -> commandOptions.add(optionNode); + 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); + } - // 1) legacy flat fields first - result.set("options", spec.optionsMap().keySet().stream() - .map(TextNode::new) - .collect(JsonHelper.arrayNodeCollector())); - result.put("optionsString", spec.optionsMap().keySet().stream() - .collect(Collectors.joining(", "))); + addGroupByHeading.accept(HEADING_OUTPUT_OPTIONS); + addGroupByHeading.accept(HEADING_GENERIC_OPTIONS); - // 2) grouped fields LAST - result.set("commandParameters", commandParameters); - result.set("commandOptions", commandOptions); - result.set("outputOptions", outputOptions); - result.set("genericOptions", genericOptions); + 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 static ObjectNode createOptionNode(CommandSpec spec, OptionSpec option) { + private final static Map> buildExclusiveSubGroups( + List exclusiveGroups, + Map optionToGroup, + Map> exclusiveWithById, + Map requiredByOption, + Set optionsInSubGroups) { var mapper = JsonHelper.getObjectMapper(); - ObjectNode node = mapper.createObjectNode(); - - // names - ArrayNode namesArray = mapper.createArrayNode(); - for (String name : option.names()) { - namesArray.add(name); - } - node.set("names", namesArray); - - // description - String description = String.join(" ", option.description()); - node.put("description", description); - - // required - node.put("required", option.required()); - - Class type = option.type(); - - // allowedValues: completion candidates or enum constants - ArrayNode allowed = null; - var completionCandidates = option.completionCandidates(); - if (completionCandidates != null && completionCandidates.iterator().hasNext()) { - allowed = mapper.createArrayNode(); - completionCandidates.forEach(allowed::add); - } else if (type != null && type.isEnum()) { - allowed = mapper.createArrayNode(); - for (Object v : type.getEnumConstants()) { - allowed.add(v.toString()); + 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); } } - if (allowed != null) { - node.set("allowedValues", allowed); - } + return result; + } - // Does the option take a value, or is it a pure flag? - boolean takesValue = option.arity().max() != 0; + private final static List collectAllOptions(ArgGroupSpec group) { + List result = new ArrayList<>(group.options()); + for (ArgGroupSpec sub : group.subgroups()) { + result.addAll(collectAllOptions(sub)); + } + return result; + } - String datatype; - boolean multiselect; + private final static Map buildOptionToGroupMap(CommandSpec spec) { + Map map = new HashMap<>(); + collectOptionToGroup(spec.argGroups(), map); + return map; + } - if (!takesValue) { - // key only, no value => Boolean flag - datatype = "Boolean"; - multiselect = false; - } else { - boolean multiValued = - (type != null && (type.isArray() || Collection.class.isAssignableFrom(type))) - || (option.splitRegex() != null && allowed == null); - - boolean hasAllowed = allowed != null && allowed.size() > 0; - boolean isFileType = type != null && java.io.File.class.isAssignableFrom(type); - - if (isFileType) { - // options that accept file paths - datatype = "File"; - multiselect = multiValued; - } else if (hasAllowed) { - // value chosen from a fixed set - datatype = "List"; - multiselect = multiValued; // false for enums like --log-level / --log-mask - } else if (multiValued) { - // multiple values (free-form) - datatype = "List"; - multiselect = true; - } else { - // free-form single value - datatype = "String"; - multiselect = false; + 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); } + } - node.put("multiselect", multiselect); - node.put("datatype", datatype); - - // shortLabel: bundle override, then computed - String longName = Arrays.stream(option.names()) - .filter(n -> n.startsWith("--")) - .findFirst() - .orElse(option.names().length > 0 ? option.names()[0] : ""); - String key = longName.replaceFirst("^-+", ""); - String shortLabel = null; - if (!key.isBlank()) { - shortLabel = FcliCommandSpecHelper.getMessageString(spec, key + ".shortLabel"); + 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; } - if (shortLabel == null || shortLabel.isBlank()) { - shortLabel = computeShortLabelFromKey(key); + int idx = heading.indexOf(" ("); + if (idx > 0) { + heading = heading.substring(0, idx).trim(); } - node.put("shortLabel", shortLabel); + 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.type(), option.arity(), option.splitRegex(), + allowedValues.size() > 0); + node.put("datatype", datatype); + node.put("secret", secret); + node.put("multiselect", isMultiSelect(option.type(), option.arity(), option.splitRegex())); + node.set("allowedValues", allowedValues); return node; } - private static ObjectNode createParameterNode(CommandSpec spec, PositionalParamSpec param, int index) { - var mapper = JsonHelper.getObjectMapper(); - ObjectNode node = mapper.createObjectNode(); + 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()); + return node; + } - int paramNumber = index + 1; - node.put("index", paramNumber); + private final static void collectExclusiveGroups( + Collection groups, + List out) { + for (ArgGroupSpec g : groups) { + if (g.exclusive()) { + out.add(g); + } + collectExclusiveGroups(g.subgroups(), out); + } + } - String label = param.paramLabel(); - if (label == null || label.isBlank()) { - label = "Param" + paramNumber; + 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); } - node.put("label", label); + return "Arguments"; + } - String description = String.join(" ", param.description()); - node.put("description", description); - node.put("required", true); - node.put("multiselect", false); - node.put("datatype", "String"); - node.put("shortLabel", "Param" + paramNumber); + private final static boolean isEffectivelyRequired(OptionSpec option, Collection rootGroups) { + if (!option.required()) { + return false; + } + return !isInOptionalGroup(option, rootGroups, false); + } - return node; + 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 static String computeShortLabelFromKey(String key) { - if (key == null) { + + private final static String getPrimaryName(OptionSpec option) { + String[] names = option.names(); + if (names == null || names.length == 0) { return null; } - String noDashes = key.replaceFirst("^-+", ""); - String spaced = noDashes.replace('-', ' '); - if (spaced.isEmpty()) { - return spaced; + return Arrays.stream(names) + .filter(n -> n.startsWith("--")) + .findFirst() + .orElse(names[0]); + } + + private final static String getDatatype( + Class type, + picocli.CommandLine.Range arity, + String splitRegex, + boolean hasAllowedValues) { + 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"; + } + } + boolean isListType = Collection.class.isAssignableFrom(type) + || type.isArray() + || (splitRegex != null && !splitRegex.isBlank()) + || (arity != null && arity.max() > 1); + if (isListType && hasAllowedValues) { + return "array"; } - char[] chars = spaced.toCharArray(); - chars[0] = Character.toUpperCase(chars[0]); - return new String(chars); + return "string"; } - private static enum OptionCategory { COMMAND, OUTPUT, GENERIC } + 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 static OptionCategory getOptionCategory(OptionSpec option) { - String longName = Arrays.stream(option.names()) - .filter(n -> n.startsWith("--")) - .findFirst() - .orElse(option.names().length > 0 ? option.names()[0] : ""); + 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; + } - // Output options - if (longName.equals("--output") - || longName.equals("--style") - || longName.equals("--store") - || longName.equals("--to-file")) { - return OptionCategory.OUTPUT; + private final static String computeTitleFromOption(OptionSpec option) { + String primaryName = getPrimaryName(option); + if (primaryName == null) { + return ""; } + String withoutDashes = primaryName.replaceFirst("^-+", ""); + return computeTitleFromLabel(withoutDashes); + } - // Generic/global options - if (longName.equals("--env-prefix") - || longName.equals("--log-file") - || longName.equals("--log-level") - || longName.equals("--log-mask") - || longName.equals("--debug") - || longName.equals("--help") - || longName.endsWith("-session")) { - return OptionCategory.GENERIC; + 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); + } - // Default: command-specific - return OptionCategory.COMMAND; + 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"); } - /** - * 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 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); @@ -355,7 +593,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; From a4abd360b31aae8a5184a12a065989b60cd37fcf Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Mon, 23 Feb 2026 14:57:08 +0530 Subject: [PATCH 11/28] Enhance JSON-RPC fcli.listCommands with module, related-module, and ProductModule-aware listing --- .../_main/cli/cmd/GenericActionCommands.java | 3 + .../_main/cli/cmd/AviatorCommands.java | 5 + .../cli/common/cli/util/ModuleType.java | 25 ++ .../cli/common/cli/util/ProductModule.java | 31 +++ .../cli/common/cli/util/RelatedModules.java | 31 +++ .../config/_main/cli/cmd/ConfigCommands.java | 3 + .../cli/fod/_main/cli/cmd/FoDCommands.java | 3 + .../_main/cli/cmd/LicenseCommands.java | 3 + .../sc_dast/_main/cli/cmd/SCDastCommands.java | 5 + .../sc_sast/_main/cli/cmd/SCSastCommands.java | 5 + .../cli/ssc/_main/cli/cmd/SSCCommands.java | 3 + .../cli/tool/_main/cli/cmd/ToolCommands.java | 5 + .../cli/util/_main/cli/cmd/UtilCommands.java | 5 + .../rpc/RpcMethodHandlerFcliListCommands.java | 215 ++++++++++++++---- 14 files changed, 303 insertions(+), 39 deletions(-) create mode 100644 fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ModuleType.java create mode 100644 fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/ProductModule.java create mode 100644 fcli-core/fcli-common/src/main/java/com/fortify/cli/common/cli/util/RelatedModules.java 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 cce3909ff30..72048fb7630 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 89b2fac4343..79a02d5bc8a 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 00000000000..daefd8377c9 --- /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 00000000000..78f7c3edf98 --- /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 00000000000..e5d636693c7 --- /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 ec8c458e2d4..b4247d7f77a 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/_main/cli/cmd/FoDCommands.java b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_main/cli/cmd/FoDCommands.java index 666231550ea..45149230ff7 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-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 0b335d51b98..aa301a6493f 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 164b435d8ac..f93c1852ee1 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 97393b2f1f3..e980aa9dfed 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 cb09778300d..5430d9e3ebc 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-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 ed99ffd4735..7959ecf56c3 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; @@ -24,6 +27,8 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) +@RelatedModules({"ssc","fod"}) @Command( name = "tool", resourceBundle = "com.fortify.cli.tool.i18n.ToolMessages", 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 9084b02d22f..70af4875637 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,6 +13,9 @@ 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; @@ -24,6 +27,8 @@ import picocli.CommandLine.Command; +@ProductModule(ModuleType.OTHER) +@RelatedModules({"ssc","fod"}) @Command( name = "util", resourceBundle = "com.fortify.cli.util.i18n.UtilMessages", 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 index 93150f72781..90367483448 100644 --- 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 @@ -12,6 +12,8 @@ */ 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; @@ -19,6 +21,9 @@ 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; @@ -29,17 +34,18 @@ * * 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 + * - 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 + * - 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 */ @@ -47,70 +53,86 @@ @RequiredArgsConstructor public final class RpcMethodHandlerFcliListCommands implements IRpcMethodHandler { private final ObjectMapper objectMapper; - + @Override public JsonNode execute(JsonNode params) throws RpcMethodException { - var module = params != null && params.has("module") - ? params.get("module").asText(null) : null; - var runnableOnly = params != null && params.has("runnableOnly") - && params.get("runnableOnly").asBoolean(false); - var includeHidden = params != null && params.has("includeHidden") - && params.get("includeHidden").asBoolean(false); - - log.debug("Listing fcli commands (module={}, runnableOnly={}, includeHidden={})", - module, runnableOnly, includeHidden); - + 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 filters - if (module != null && !module.isBlank()) { - final String modulePrefix = "fcli " + module + " "; - final String moduleExact = "fcli " + module; + + // Apply module filter (single or multiple) + if (requestedModules != null && !requestedModules.isEmpty()) { commandStream = commandStream.filter(spec -> { - var qualifiedName = spec.qualifiedName(" "); - return qualifiedName.startsWith(modulePrefix) || qualifiedName.equals(moduleExact); + 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); - + .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(" "); @@ -119,7 +141,7 @@ private String extractModule(String qualifiedName) { } return ""; } - + private String getUsageHeader(CommandSpec spec) { var headerLines = spec.usageMessage().header(); if (headerLines != null && headerLines.length > 0) { @@ -127,4 +149,119 @@ private String getUsageHeader(CommandSpec spec) { } 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); + } } From cd21fb51564fd991f22753c13ab1ee728a5ba864 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Fri, 6 Mar 2026 17:17:09 +0530 Subject: [PATCH 12/28] fix: correct FoD session login credential grouping and tenant option usage --- .../cli/mixin/FoDSessionLoginOptions.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) 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 6d3e6043236..a8df0985945 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 @@ -43,18 +43,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 +67,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) @@ -84,9 +87,10 @@ public final boolean hasUserCredentials() { 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"); + var t = Optional.ofNullable(u).map(FoDUserCredentialOptions::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(); } From d534d564b52ac6e1ca8c2257d4ef065a2fa0fa2b Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Fri, 6 Mar 2026 17:36:37 +0530 Subject: [PATCH 13/28] fix: enforce FoD app/release exclusivity and correct session login options --- .../issue/cli/cmd/FoDIssueListCommand.java | 59 +++++++++++-------- .../cli/cmd/FoDOssComponentsListCommand.java | 45 +++++++++----- ...sueListCommandEffectiveFastOutputTest.java | 23 +++----- 3 files changed, 73 insertions(+), 54 deletions(-) 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 c017709fe52..3dda2fa84a5 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 7fb80227dca..2ba7cf06afa 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/issue/cli/cmd/FoDIssueListCommandEffectiveFastOutputTest.java b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/issue/cli/cmd/FoDIssueListCommandEffectiveFastOutputTest.java index 1de21415412..ba27485aa75 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 { From bdd6898df380ce635a5af2f81be295e1a7875ff5 Mon Sep 17 00:00:00 2001 From: mjain6 Date: Wed, 18 Mar 2026 12:08:50 +0530 Subject: [PATCH 14/28] Added datatype support for file --- .../AllCommandsCommandSelectorMixin.java | 82 ++++++++++++++++++- 1 file changed, 78 insertions(+), 4 deletions(-) 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 7570eb0aaca..342dda9b31a 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,6 +12,10 @@ */ 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; @@ -37,6 +41,7 @@ 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; @@ -352,11 +357,10 @@ private final static ObjectNode createOptionNode(OptionSpec option, boolean requ boolean secret = isSecretOption(option); ArrayNode allowedValues = getAllowedValues(option, option.type(), option.type() != null && option.type().isEnum()); - String datatype = getDatatype(option.type(), option.arity(), option.splitRegex(), - allowedValues.size() > 0); + String datatype = getDatatype(option, allowedValues.size() > 0); node.put("datatype", datatype); node.put("secret", secret); - node.put("multiselect", isMultiSelect(option.type(), option.arity(), option.splitRegex())); + node.put("multiselect", isMultiSelect(resolveType(option), option.arity(), option.splitRegex())); node.set("allowedValues", allowedValues); return node; } @@ -369,6 +373,10 @@ private final static ObjectNode createParameterNode(PositionalParamSpec param) { 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; } @@ -438,11 +446,21 @@ private final static String getPrimaryName(OptionSpec option) { .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) { + boolean hasAllowedValues, + String paramLabel) { if (arity != null && arity.max() == 0) { return "boolean"; } @@ -456,6 +474,10 @@ private final static String getDatatype( 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()) @@ -466,6 +488,38 @@ private final static String getDatatype( 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; @@ -521,6 +575,26 @@ private final static ArrayNode getAllowedValues(OptionSpec option, Class type 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) { From b7db46d5a87e65184726e1826541417a6ca1ba31 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:36:34 +0100 Subject: [PATCH 15/28] chore: Merge pull request #935 from fortify/feat/v3.x/sourceanalyzer-local-scan feat: `fcli tool sourceanalyzer`: New commands to register pre-installed sourceanalyzer installation, and running `sourceanalyzer` and rule pack update commands --- .../cli/cmd/AbstractToolGetCommand.java | 72 ++++-- .../cli/cmd/AbstractToolRunCommand.java | 52 ++++- .../fortify/cli/tool/_common/helper/Tool.java | 44 +++- .../helper/ToolInstallationsResolver.java | 73 ++++++- .../helper/ToolRegistrationHelper.java | 150 +++++++++---- .../cli/tool/_main/cli/cmd/ToolCommands.java | 2 + .../helper/ToolDefinitionsHelper.java | 205 +++++++++++------- .../cli/cmd/ToolSourceAnalyzerCommands.java | 39 ++++ .../cli/cmd/ToolSourceAnalyzerGetCommand.java | 36 +++ .../cmd/ToolSourceAnalyzerListCommand.java | 36 +++ .../ToolSourceAnalyzerRegisterCommand.java | 49 +++++ .../cli/cmd/ToolSourceAnalyzerRunCommand.java | 48 ++++ ...lSourceAnalyzerUpdateRulePacksCommand.java | 46 ++++ .../cli/tool/i18n/ToolMessages.properties | 54 ++++- 14 files changed, 741 insertions(+), 165 deletions(-) create mode 100644 fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerCommands.java create mode 100644 fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerGetCommand.java create mode 100644 fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerListCommand.java create mode 100644 fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerRegisterCommand.java create mode 100644 fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerRunCommand.java create mode 100644 fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/sourceanalyzer/cli/cmd/ToolSourceAnalyzerUpdateRulePacksCommand.java 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 5962e9fd9ac..8ddbb0d56fa 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 542adc17f11..33d3862ff37 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 f45ddf75edc..135fd8676e8 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 cb74460852c..c46002d92f2 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 73f285ebc5d..72dd89840e2 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 @@ -20,6 +20,7 @@ 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 +34,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 +202,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 +280,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 = toolDefinition.getOptionalVersionOrDefault(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 +304,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 +331,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 +386,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 = toolDefinition.getOptionalVersionOrDefault(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 = toolDefinition.getOptionalVersionOrDefault(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 +435,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 +444,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 = toolDefinition.getOptionalVersionOrDefault(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,31 +477,59 @@ 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 - if ("unknown".equals(detectedVersion)) { + // 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("unknown"); + syntheticDescriptor.setVersion(detectedVersion); syntheticDescriptor.setStable(true); syntheticDescriptor.setAliases(new String[0]); return syntheticDescriptor; } - - // 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 - try { - return toolDefinition.getVersion(normalizedVersion); - } catch (IllegalArgumentException e) { - // Version not found in definitions, create synthetic descriptor with normalized version + + // 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(normalizedVersion); + syntheticDescriptor.setVersion("unknown"); syntheticDescriptor.setStable(true); syntheticDescriptor.setAliases(new String[0]); return syntheticDescriptor; } + + // 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 toolDefinition.getOptionalVersion(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 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 7959ecf56c3..1137166acac 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 @@ -23,6 +23,7 @@ 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; @@ -39,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 51b030a401b..8c99ab07c26 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 00000000000..7464ecd0820 --- /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 00000000000..d03aa09cf1c --- /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 00000000000..92b23abbc52 --- /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 00000000000..dcd5280b4ab --- /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 00000000000..9ba64baa510 --- /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 00000000000..d6274e18936 --- /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 12eec0c14e7..cee0be03570 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 From 24628b20497411a105bdc8c541f840341b220a9f Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Fri, 20 Mar 2026 19:58:28 +0530 Subject: [PATCH 16/28] fix: allow tenant with client credentials while keeping tenant required for user credentials --- .../cli/cmd/FoDSessionLoginCommand.java | 2 +- .../FoDSessionTenantIgnoringPreprocessor.java | 75 ++++++++++++++++++ .../cli/mixin/FoDSessionLoginOptions.java | 14 ++-- .../cli/fod/i18n/FoDMessages.properties | 2 +- ...SessionTenantIgnoringPreprocessorTest.java | 79 +++++++++++++++++++ 5 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessor.java create mode 100644 fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessorTest.java 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 c6186f4ad3a..b6f9bbcd016 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 00000000000..5e693775386 --- /dev/null +++ b/fcli-core/fcli-fod/src/main/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessor.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.fod._common.session.cli.cmd; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Stack; + +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 + */ +public final class FoDSessionTenantIgnoringPreprocessor implements IParameterPreprocessor { + @Override + public boolean preprocess(Stack args, CommandSpec commandSpec, ArgSpec argSpec, Map info) { + if ( argSpec!=null || args==null || args.isEmpty() ) { + return false; + } + + var cliArgs = new ArrayList<>(args); + if ( !hasClientCredentials(cliArgs) ) { + return false; + } + + var filtered = filterOutTenantOptions(cliArgs); + if ( filtered.size()!=cliArgs.size() ) { + args.clear(); + args.addAll(filtered); + } + return false; + } + + private static boolean hasClientCredentials(List cliArgs) { + return cliArgs.stream().anyMatch(a -> "--client-id".equals(a) + || a.startsWith("--client-id=") + || "--client-secret".equals(a) + || a.startsWith("--client-secret=")); + } + + private static List filterOutTenantOptions(List cliArgs) { + var result = new ArrayList(); + for ( int i = 0; i < cliArgs.size(); i++ ) { + var arg = cliArgs.get(i); + if ( "--tenant".equals(arg) || "-t".equals(arg) ) { + if ( i+1 < cliArgs.size() && !cliArgs.get(i+1).startsWith("-") ) { + i++; // Skip explicit tenant value. + } + continue; + } + if ( arg.startsWith("--tenant=") || arg.startsWith("-t=") ) { + continue; + } + result.add(arg); + } + return result; + } +} 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 a8df0985945..cf6748aef9a 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 @@ -82,17 +82,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(u).map(FoDUserCredentialOptions::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() { diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index c83fe29b08e..416d1102495 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -115,7 +115,7 @@ fcli.fod.session.login.usage.description.2 = %nTo avoid having to remember the v DEV_FOD_TENANT environment variables, and then use the --env-prefix=PROD or --env-prefix=DEV option to \ select from which environment variables the default values should be retrieved. fcli.fod.session.login.url = FoD URL, for example https://emea.fortify.com/. -fcli.fod.session.login.tenant = FoD tenant; required when authenticating with user credentials, ignored for client credentials. +fcli.fod.session.login.tenant = FoD tenant; required, but ignored for client credentials. fcli.fod.session.login.user = FoD user. fcli.fod.session.login.password = FoD password. fcli.fod.session.login.client-id = FoD client id. 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 00000000000..decbaf4ef88 --- /dev/null +++ b/fcli-core/fcli-fod/src/test/java/com/fortify/cli/fod/_common/session/cli/cmd/FoDSessionTenantIgnoringPreprocessorTest.java @@ -0,0 +1,79 @@ +/* + * 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; + +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 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 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; + } +} From 39678fb578019fa280b5c6607e9ad475ba283e8a Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Fri, 20 Mar 2026 20:33:38 +0530 Subject: [PATCH 17/28] chore(fix): fixing GraalVM native image build --- .../session/cli/cmd/FoDSessionTenantIgnoringPreprocessor.java | 3 +++ 1 file changed, 3 insertions(+) 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 index 5e693775386..1f9777baf12 100644 --- 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 @@ -17,6 +17,8 @@ import java.util.Map; import java.util.Stack; +import com.formkiq.graalvm.annotations.Reflectable; + import picocli.CommandLine.IParameterPreprocessor; import picocli.CommandLine.Model.ArgSpec; import picocli.CommandLine.Model.CommandSpec; @@ -28,6 +30,7 @@ * * @author Sangamesh Vijayakumar */ +@Reflectable public final class FoDSessionTenantIgnoringPreprocessor implements IParameterPreprocessor { @Override public boolean preprocess(Stack args, CommandSpec commandSpec, ArgSpec argSpec, Map info) { From 89c7ffddc9f97a2d25a3775bd137a775f1baa5ee Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Fri, 20 Mar 2026 21:14:36 +0530 Subject: [PATCH 18/28] Handle all valid --tenant/-t syntaxes for client credentials in preprocessor and add regression tests including native-image reflection fix --- .../FoDSessionTenantIgnoringPreprocessor.java | 6 ++- ...SessionTenantIgnoringPreprocessorTest.java | 39 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) 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 index 1f9777baf12..ffbfe928fc0 100644 --- 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 @@ -58,6 +58,10 @@ private static boolean hasClientCredentials(List cliArgs) { || a.startsWith("--client-secret=")); } + private static boolean isCompactTenantOption(String arg) { + return arg.startsWith("-t") && arg.length() > 2 && !arg.startsWith("--"); + } + private static List filterOutTenantOptions(List cliArgs) { var result = new ArrayList(); for ( int i = 0; i < cliArgs.size(); i++ ) { @@ -68,7 +72,7 @@ private static List filterOutTenantOptions(List cliArgs) { } continue; } - if ( arg.startsWith("--tenant=") || arg.startsWith("-t=") ) { + if ( arg.startsWith("--tenant=") || arg.startsWith("-t=") || isCompactTenantOption(arg) ) { continue; } result.add(arg); 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 index decbaf4ef88..8b0767f5bf0 100644 --- 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 @@ -28,6 +28,7 @@ import picocli.CommandLine.Command; import picocli.CommandLine.MissingParameterException; import picocli.CommandLine.Mixin; +import picocli.CommandLine.UnmatchedArgumentException; class FoDSessionTenantIgnoringPreprocessorTest { @Test @@ -46,6 +47,29 @@ void shouldIgnoreTenantWhenClientCredentialsProvided() { 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 shouldRejectInvalidLongTenantSyntax() { + assertThrows(UnmatchedArgumentException.class, + () -> parse("--tenanttenant-value", "--client-id", "id", "--client-secret", "secret")); + } + @Test void shouldFailUserCredentialsWithoutTenant() { var ex = assertThrows(MissingParameterException.class, @@ -62,6 +86,19 @@ void shouldAllowUserCredentialsWithTenant() { 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()); + } + private static TestFoDLoginCommand parse(String... args) { var cmd = new TestFoDLoginCommand(); var fullArgs = new ArrayList(); @@ -76,4 +113,4 @@ private static TestFoDLoginCommand parse(String... args) { static final class TestFoDLoginCommand { @Mixin private FoDSessionLoginOptions loginOptions; } -} +} \ No newline at end of file From f16f56ebb426f19506510ae35666f6959d160fae Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Fri, 20 Mar 2026 23:17:49 +0530 Subject: [PATCH 19/28] chore: removed the manual throw of exception, pico cli does it --- .../fod/_common/session/cli/mixin/FoDSessionLoginOptions.java | 4 ---- 1 file changed, 4 deletions(-) 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 cf6748aef9a..3250f0e6596 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; @@ -137,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); } } From 83359f0d0cc804c56d25af94fe77f2134532a51c Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:31:34 +0100 Subject: [PATCH 20/28] Clarify FoD tenant requirement for user credentials Updated the description for the FoD tenant property to clarify its requirement when using user credentials. --- .../resources/com/fortify/cli/fod/i18n/FoDMessages.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties index 416d1102495..c83fe29b08e 100644 --- a/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties +++ b/fcli-core/fcli-fod/src/main/resources/com/fortify/cli/fod/i18n/FoDMessages.properties @@ -115,7 +115,7 @@ fcli.fod.session.login.usage.description.2 = %nTo avoid having to remember the v DEV_FOD_TENANT environment variables, and then use the --env-prefix=PROD or --env-prefix=DEV option to \ select from which environment variables the default values should be retrieved. fcli.fod.session.login.url = FoD URL, for example https://emea.fortify.com/. -fcli.fod.session.login.tenant = FoD tenant; required, but ignored for client credentials. +fcli.fod.session.login.tenant = FoD tenant; required when authenticating with user credentials, ignored for client credentials. fcli.fod.session.login.user = FoD user. fcli.fod.session.login.password = FoD password. fcli.fod.session.login.client-id = FoD client id. From 9f24efd15cb564888293c331dc2e53876df766c2 Mon Sep 17 00:00:00 2001 From: Ruud Senden <8635138+rsenden@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:36:35 +0100 Subject: [PATCH 21/28] Add TODO comments for future enhancements Added TODO comments regarding potential improvements for option names and generic processing. --- .../cli/cmd/FoDSessionTenantIgnoringPreprocessor.java | 5 +++++ 1 file changed, 5 insertions(+) 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 index ffbfe928fc0..e001d390e63 100644 --- 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 @@ -34,6 +34,11 @@ public final class FoDSessionTenantIgnoringPreprocessor implements IParameterPreprocessor { @Override public boolean preprocess(Stack args, CommandSpec commandSpec, ArgSpec argSpec, Map info) { + // TODO Given that we have CommandSpec and ArgSpec available, can we use these to obtain option names + // and aliases, rather than hardcoding/duplicating them here? + // TODO If we ever need similar functionality in other places, maybe better to change this into a + // generic, annotation-driven processor, i.e., put some annotation on option fields to indicate + // that the option should be ignored if some criteria are met? if ( argSpec!=null || args==null || args.isEmpty() ) { return false; } From 74de6796224a29d397e60b5a7d101dc18610da7b Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Fri, 27 Mar 2026 12:41:47 +0530 Subject: [PATCH 22/28] Fix FoD tenant preprocessor to correctly handle space and equals syntax for full and alias options without corrupting adjacent arguments --- .../FoDSessionTenantIgnoringPreprocessor.java | 101 +++++++++++++----- ...SessionTenantIgnoringPreprocessorTest.java | 23 ++++ 2 files changed, 96 insertions(+), 28 deletions(-) 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 index e001d390e63..a78b8e6a83a 100644 --- 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 @@ -13,8 +13,12 @@ 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; @@ -27,61 +31,102 @@ * 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) { - // TODO Given that we have CommandSpec and ArgSpec available, can we use these to obtain option names - // and aliases, rather than hardcoding/duplicating them here? - // TODO If we ever need similar functionality in other places, maybe better to change this into a - // generic, annotation-driven processor, i.e., put some annotation on option fields to indicate - // that the option should be ignored if some criteria are met? - if ( argSpec!=null || args==null || args.isEmpty() ) { + 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) ) { + if (!hasClientCredentials(cliArgs, clientIdNames, clientSecretNames)) { return false; } - var filtered = filterOutTenantOptions(cliArgs); - if ( filtered.size()!=cliArgs.size() ) { + var filtered = filterOutTenantOptions(cliArgs, tenantNames); + if (filtered.size() != cliArgs.size()) { args.clear(); args.addAll(filtered); } return false; } - private static boolean hasClientCredentials(List cliArgs) { - return cliArgs.stream().anyMatch(a -> "--client-id".equals(a) - || a.startsWith("--client-id=") - || "--client-secret".equals(a) - || a.startsWith("--client-secret=")); + 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 isCompactTenantOption(String arg) { - return arg.startsWith("-t") && arg.length() > 2 && !arg.startsWith("--"); + private static boolean hasAnyOption(List cliArgs, Set optionNames) { + return cliArgs.stream().anyMatch(arg -> isOptionToken(arg, optionNames)); } - private static List filterOutTenantOptions(List cliArgs) { - var result = new ArrayList(); - for ( int i = 0; i < cliArgs.size(); i++ ) { - var arg = cliArgs.get(i); - if ( "--tenant".equals(arg) || "-t".equals(arg) ) { - if ( i+1 < cliArgs.size() && !cliArgs.get(i+1).startsWith("-") ) { - i++; // Skip explicit tenant value. + 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 ( arg.startsWith("--tenant=") || arg.startsWith("-t=") || isCompactTenantOption(arg) ) { + if (isInlineOptionToken(arg, tenantNames) || isCompactShortOptionToken(arg, tenantNames)) { continue; } - result.add(arg); + filteredInOrder.add(arg); } - return result; + + // 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/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 index 8b0767f5bf0..96cb1fae7a1 100644 --- 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 @@ -64,6 +64,15 @@ void shouldIgnoreAllSupportedTenantSyntaxWhenClientCredentialsProvided() { 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, @@ -97,6 +106,20 @@ private static void assertClientCredentialsOnly(String... tenantArgs) { 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) { From 9e552cb2b598b785ac4c1acce7d145dc139e7c79 Mon Sep 17 00:00:00 2001 From: Madhur Jain <87946372+jmadhur87@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:03:19 +0530 Subject: [PATCH 23/28] chore: Add support for auditing SSC issues (#919) feat: `fcli ssc issue update`: New command for updating/auditing SSC issues Co-authored-by: mjain6 --- .../ssc/issue/cli/cmd/SSCIssueCommands.java | 1 + .../issue/cli/cmd/SSCIssueUpdateCommand.java | 271 ++++++++++++++++++ .../helper/SSCIssueCustomTagAuditValue.java | 75 +++++ .../issue/helper/SSCIssueCustomTagHelper.java | 224 +++++++++++++++ .../ssc/issue/helper/SSCIssueIdentifier.java | 54 ++++ .../cli/ssc/i18n/SSCMessages.properties | 16 ++ 6 files changed, 641 insertions(+) create mode 100644 fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/cli/cmd/SSCIssueUpdateCommand.java create mode 100644 fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueCustomTagAuditValue.java create mode 100644 fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueCustomTagHelper.java create mode 100644 fcli-core/fcli-ssc/src/main/java/com/fortify/cli/ssc/issue/helper/SSCIssueIdentifier.java 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 bf3f0aaca93..a3d1b5e40bf 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 00000000000..bcd34ea6c88 --- /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 00000000000..cde2a73a4e5 --- /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 00000000000..5ce0396f9f5 --- /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 00000000000..4ed6b2850bc --- /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 dd5f7c5507b..0e2a7da53c4 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. From 8ede75d8b89cba37142e6fe4688bb97dd2d4c950 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Tue, 3 Mar 2026 14:05:17 +0530 Subject: [PATCH 24/28] feat: Support Sourceanalyzer tool registartion and performing local scans via command and fcli action --- .../zip/sourceanalyzer-translate-scan.yaml | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/sourceanalyzer-translate-scan.yaml diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/sourceanalyzer-translate-scan.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/sourceanalyzer-translate-scan.yaml new file mode 100644 index 00000000000..daef1af8254 --- /dev/null +++ b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/sourceanalyzer-translate-scan.yaml @@ -0,0 +1,119 @@ +# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json + +author: Fortify +usage: + header: Local Source Analyzer translate/scan with optional SSC upload + description: | + This action automates a local Fortify Source Analyzer workflow: + 1) Optionally update local Source Analyzer rulepacks using the registered Source Analyzer installation + 2) Translate source code using the specified build ID + 3) Scan the translated project and generate an FPR + 4) Optionally upload the generated FPR to SSC for the given application version + 5) Optionally wait until the uploaded SSC artifact processing is complete (unless --skip-wait is used) + +config: + output: immediate + rest.target.default: ssc + run.fcli.status.log.default: true # By default, we log all exit statuses + run.fcli.status.check.default: true + +cli.options: + buildId: + names: --build-id, -b + description: | + Fortify Source Analyzer build ID to use for this translation and scan. + required: true + sourceDir: + names: --source-dir, -d + description: | + Source directory to translate/scan. + required: true + default: . + fpr: + names: --fpr, -o + description: | + Output FPR file path. + required: true + default: audit.fpr + sourceAnalyzerVersion: + names: --source-analyzer-version, -v + description: | + Optional Source Analyzer version to run. If omitted, the default + registered Source Analyzer installation is used. + required: false + appVersion: + names: --app-version, --av + description: | + Optional SSC application version (app-name:version-name) to upload + the generated FPR to. When specified, the action will attempt to + ensure the application version exists in SSC and upload the FPR + using the current SSC session. + required: false + updateRulePacks: + names: --update-rule-packs + description: | + When set, run 'fcli tool sourceanalyzer update-rule-packs' before + translation and scan to update local Source Analyzer rulepacks. + required: false + default: false + type: boolean + skipWait: + names: --skip-wait + description: >- + By default, the action will wait for the uploaded SSC artifact processing + to complete. Use this option to skip waiting for processing to complete. + required: false + type: boolean + translateExtraOpts: + names: --translate-extra-opts + description: | + Extra options to pass only to the translation (sourceanalyzer) step. + required: false + default: ${#extraOpts('SOURCEANALYZER_TRANSLATE_EXTRA_OPTS')} + scanExtraOpts: + names: --scan-extra-opts + description: | + Extra options to pass only to the scan (sourceanalyzer) step. + required: false + default: ${#extraOpts('SOURCEANALYZER_SCAN_EXTRA_OPTS')} + +steps: + - var.set: + scan.buildId: ${cli.buildId} + scan.fpr: ${#resolveAgainstCurrentWorkDir(cli.fpr)} + scan.sourceDir: ${#resolveAgainstCurrentWorkDir(cli.sourceDir)} + global.sourceanalyzerPublish.fcliVarName: sourceanalyzer_scan_${#action.runID().replace('-','_')} + global.sourceanalyzerPublish.waitForCmd: 'fcli ssc artifact wait-for ::${global.sourceanalyzerPublish.fcliVarName}::' + + # Optionally update rulepacks before translate and scan + - if: ${cli.updateRulePacks==true} + run.fcli: + UPDATE_RULEPACKS: + cmd: > + fcli tool sourceanalyzer update-rule-packs ${#opt("--version", cli.sourceAnalyzerVersion)} + + # 1) Translate + - run.fcli: + TRANSLATE: + cmd: > + fcli tool sourceanalyzer run ${#opt("--version", cli.sourceAnalyzerVersion)} -- -b "${scan.buildId}" ${cli.extraOpts} ${cli.translateExtraOpts} "${scan.sourceDir}" + + # 2) Scan + - run.fcli: + SCAN: + cmd: > + fcli tool sourceanalyzer run ${#opt("--version", cli.sourceAnalyzerVersion)} -- -b "${scan.buildId}" -scan -f "${scan.fpr}" ${cli.extraOpts} ${cli.scanExtraOpts} + + # 3) In case app version is mentioned, ensure it exists and upload the FPR to SSC + - if: ${!#isBlank(cli.appVersion)} + do: + - run.fcli: + SSC_SETUP_APPVERSION: + cmd: ${#actionCmd('SETUP', 'ssc', 'setup-appversion')} "--av=${cli.appVersion}" + - run.fcli: + SSC_UPLOAD_FPR: + cmd: > + fcli ssc artifact upload --av="${cli.appVersion}" -f "${scan.fpr}" --store ${global.sourceanalyzerPublish.fcliVarName} + - if: ${!cli.skipWait} + run.fcli: + WAIT: ${global.sourceanalyzerPublish.waitForCmd} From d5c473b3e5d5d64702860e276c28b3d1da773019 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Wed, 11 Mar 2026 23:07:03 +0530 Subject: [PATCH 25/28] chore: refactor tool definitions handling (handled review points) --- .../zip/sourceanalyzer-translate-scan.yaml | 119 ------------------ 1 file changed, 119 deletions(-) delete mode 100644 fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/sourceanalyzer-translate-scan.yaml diff --git a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/sourceanalyzer-translate-scan.yaml b/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/sourceanalyzer-translate-scan.yaml deleted file mode 100644 index daef1af8254..00000000000 --- a/fcli-core/fcli-ssc/src/main/resources/com/fortify/cli/ssc/actions/zip/sourceanalyzer-translate-scan.yaml +++ /dev/null @@ -1,119 +0,0 @@ -# yaml-language-server: $schema=https://fortify.github.io/fcli/schemas/action/fcli-action-schema-dev-2.x.json - -author: Fortify -usage: - header: Local Source Analyzer translate/scan with optional SSC upload - description: | - This action automates a local Fortify Source Analyzer workflow: - 1) Optionally update local Source Analyzer rulepacks using the registered Source Analyzer installation - 2) Translate source code using the specified build ID - 3) Scan the translated project and generate an FPR - 4) Optionally upload the generated FPR to SSC for the given application version - 5) Optionally wait until the uploaded SSC artifact processing is complete (unless --skip-wait is used) - -config: - output: immediate - rest.target.default: ssc - run.fcli.status.log.default: true # By default, we log all exit statuses - run.fcli.status.check.default: true - -cli.options: - buildId: - names: --build-id, -b - description: | - Fortify Source Analyzer build ID to use for this translation and scan. - required: true - sourceDir: - names: --source-dir, -d - description: | - Source directory to translate/scan. - required: true - default: . - fpr: - names: --fpr, -o - description: | - Output FPR file path. - required: true - default: audit.fpr - sourceAnalyzerVersion: - names: --source-analyzer-version, -v - description: | - Optional Source Analyzer version to run. If omitted, the default - registered Source Analyzer installation is used. - required: false - appVersion: - names: --app-version, --av - description: | - Optional SSC application version (app-name:version-name) to upload - the generated FPR to. When specified, the action will attempt to - ensure the application version exists in SSC and upload the FPR - using the current SSC session. - required: false - updateRulePacks: - names: --update-rule-packs - description: | - When set, run 'fcli tool sourceanalyzer update-rule-packs' before - translation and scan to update local Source Analyzer rulepacks. - required: false - default: false - type: boolean - skipWait: - names: --skip-wait - description: >- - By default, the action will wait for the uploaded SSC artifact processing - to complete. Use this option to skip waiting for processing to complete. - required: false - type: boolean - translateExtraOpts: - names: --translate-extra-opts - description: | - Extra options to pass only to the translation (sourceanalyzer) step. - required: false - default: ${#extraOpts('SOURCEANALYZER_TRANSLATE_EXTRA_OPTS')} - scanExtraOpts: - names: --scan-extra-opts - description: | - Extra options to pass only to the scan (sourceanalyzer) step. - required: false - default: ${#extraOpts('SOURCEANALYZER_SCAN_EXTRA_OPTS')} - -steps: - - var.set: - scan.buildId: ${cli.buildId} - scan.fpr: ${#resolveAgainstCurrentWorkDir(cli.fpr)} - scan.sourceDir: ${#resolveAgainstCurrentWorkDir(cli.sourceDir)} - global.sourceanalyzerPublish.fcliVarName: sourceanalyzer_scan_${#action.runID().replace('-','_')} - global.sourceanalyzerPublish.waitForCmd: 'fcli ssc artifact wait-for ::${global.sourceanalyzerPublish.fcliVarName}::' - - # Optionally update rulepacks before translate and scan - - if: ${cli.updateRulePacks==true} - run.fcli: - UPDATE_RULEPACKS: - cmd: > - fcli tool sourceanalyzer update-rule-packs ${#opt("--version", cli.sourceAnalyzerVersion)} - - # 1) Translate - - run.fcli: - TRANSLATE: - cmd: > - fcli tool sourceanalyzer run ${#opt("--version", cli.sourceAnalyzerVersion)} -- -b "${scan.buildId}" ${cli.extraOpts} ${cli.translateExtraOpts} "${scan.sourceDir}" - - # 2) Scan - - run.fcli: - SCAN: - cmd: > - fcli tool sourceanalyzer run ${#opt("--version", cli.sourceAnalyzerVersion)} -- -b "${scan.buildId}" -scan -f "${scan.fpr}" ${cli.extraOpts} ${cli.scanExtraOpts} - - # 3) In case app version is mentioned, ensure it exists and upload the FPR to SSC - - if: ${!#isBlank(cli.appVersion)} - do: - - run.fcli: - SSC_SETUP_APPVERSION: - cmd: ${#actionCmd('SETUP', 'ssc', 'setup-appversion')} "--av=${cli.appVersion}" - - run.fcli: - SSC_UPLOAD_FPR: - cmd: > - fcli ssc artifact upload --av="${cli.appVersion}" -f "${scan.fpr}" --store ${global.sourceanalyzerPublish.fcliVarName} - - if: ${!cli.skipWait} - run.fcli: - WAIT: ${global.sourceanalyzerPublish.waitForCmd} From c574e430a5675354f5b85b018e2af436df0c5f4f Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Sun, 29 Mar 2026 21:03:06 +0530 Subject: [PATCH 26/28] fix: use compatible tool definition version lookups --- .../helper/ToolRegistrationHelper.java | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) 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 72dd89840e2..4230464edfb 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,6 +17,7 @@ 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; @@ -292,7 +293,7 @@ private File findExistingInstallation(String requestedVersion) { // If specific version requested, look for matching installation if (!"any".equals(requestedVersion)) { - var optionalVersionDescriptor = toolDefinition.getOptionalVersionOrDefault(requestedVersion); + var optionalVersionDescriptor = getOptionalVersionOrDefault(toolDefinition, requestedVersion); if (optionalVersionDescriptor.isPresent()) { var requestedVersionDescriptor = optionalVersionDescriptor.get(); var installation = ToolInstallationDescriptor.load(toolName, requestedVersionDescriptor); @@ -388,7 +389,7 @@ private String detectVersionFromBinary(File toolBinary, File installDir) { private void validateVersionMatch(ToolDefinitionVersionDescriptor versionDescriptor, String requestedVersion) { if (!definitionsOptional) { var toolDefinition = getRequiredDefinition(); - var optionalRequestedVersion = toolDefinition.getOptionalVersionOrDefault(requestedVersion); + 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", @@ -411,7 +412,7 @@ private void validateVersionMatch(ToolDefinitionVersionDescriptor versionDescrip // No definitions → accept any detected version for requestedVersion return; } - var optionalRequestedVersion = toolDefinition.getOptionalVersionOrDefault(requestedVersion); + var optionalRequestedVersion = getOptionalVersionOrDefault(toolDefinition, requestedVersion); if (optionalRequestedVersion.isEmpty()) { // Without definitions, we already returned; if we got here, behave like strict // mode @@ -444,7 +445,7 @@ private ToolInstallationDescriptor createAndSaveInstallation(File toolBinary, To private File findMatchingCandidate(List candidates, String requestedVersion) { var toolDefinition = ToolDefinitionsHelper.getToolDefinitionRootDescriptor(toolName); - var optionalVersionDescriptor = toolDefinition.getOptionalVersionOrDefault(requestedVersion); + var optionalVersionDescriptor = getOptionalVersionOrDefault(toolDefinition, requestedVersion); if (optionalVersionDescriptor.isEmpty()) { throw new FcliSimpleException( String.format("Requested version %s not found in tool definitions", requestedVersion)); @@ -512,7 +513,7 @@ private ToolDefinitionVersionDescriptor resolveVersionDescriptor(String detected String normalizedVersion = toolDefinition.normalizeVersionFormat(detectedVersion); // Try to find matching version in tool definitions using normalized version - return toolDefinition.getOptionalVersion(normalizedVersion) + return getOptionalVersion(toolDefinition, normalizedVersion) .orElseGet(() -> { // Version not found in definitions, create synthetic descriptor with normalized // version @@ -524,6 +525,23 @@ private ToolDefinitionVersionDescriptor resolveVersionDescriptor(String detected }); } + private Optional getOptionalVersionOrDefault( + ToolDefinitionRootDescriptor toolDefinition, String versionOrAlias) { + try { + 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); } From 2e7ed9ef9efa8ec690fe01742d13d5db8461d2b0 Mon Sep 17 00:00:00 2001 From: Sangamesh Vijaykumar Date: Sun, 29 Mar 2026 21:12:51 +0530 Subject: [PATCH 27/28] fix(ci): replace blocked marketplace actions --- .github/workflows/ci.yml | 135 ++++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 66 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0a9d8f8bd9..5791c3692a1 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 + + From f26715ee467d1cfd130388b8d92a8b109e12f59d Mon Sep 17 00:00:00 2001 From: mjain6 Date: Tue, 31 Mar 2026 15:59:55 +0530 Subject: [PATCH 28/28] Changed data type for options to File type for few options --- .../fpr/processor/RemediationProcessor.java | 9 +-------- .../AviatorFoDApplyRemediationsCommand.java | 7 ++++--- .../AviatorSSCApplyRemediationsCommand.java | 7 ++++--- ...viatorFoDApplyRemediationsCommandTest.java | 11 ++++++----- ...viatorSSCApplyRemediationsCommandTest.java | 11 ++++++----- .../cmd/AbstractReportGenerateCommand.java | 19 ++++++++----------- .../sc_dast/i18n/SCDastMessages.properties | 1 - .../cli/cmd/AbstractToolRegisterCommand.java | 4 ++-- .../tool/env/cli/cmd/ToolEnvInitCommand.java | 4 ++-- .../tool/env/cli/mixin/ToolEnvInitMixin.java | 3 ++- .../AllCommandsCommandSelectorMixin.java | 7 +++++++ 11 files changed, 42 insertions(+), 41 deletions(-) diff --git a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java index b832c62a4cd..432183ef704 100644 --- a/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java +++ b/fcli-core/fcli-aviator-common/src/main/java/com/fortify/cli/aviator/fpr/processor/RemediationProcessor.java @@ -59,14 +59,7 @@ public RemediationMetric processRemediationXML() { int totalRemediations; int appliedRemediations; - // Sanitize and normalize the base source directory path once. - String trimmedSourceDir = sourceCodeDirectory.trim(); - if (trimmedSourceDir.length() > 1 && - ((trimmedSourceDir.startsWith("\"") && trimmedSourceDir.endsWith("\"")) || - (trimmedSourceDir.startsWith("'") && trimmedSourceDir.endsWith("'")))) { - trimmedSourceDir = trimmedSourceDir.substring(1, trimmedSourceDir.length() - 1); - } - final Path sourceBasePath = Paths.get(trimmedSourceDir).toAbsolutePath().normalize(); + final Path sourceBasePath = Paths.get(sourceCodeDirectory).normalize(); try (InputStream remediationStream = Files.newInputStream(remediationPath)) { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommand.java index 301a840326e..690bc2877e7 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommand.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.aviator.fod.cli.cmd; +import java.io.File; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -51,7 +52,7 @@ public class AviatorFoDApplyRemediationsCommand extends AbstractFoDJsonNodeOutpu @Mixin private FoDDelimiterMixin delimiterMixin; // Is automatically injected in resolver mixins @Mixin private FoDReleaseByQualifiedNameOrIdResolverMixin.RequiredOption releaseResolver; private static final Logger LOG = LoggerFactory.getLogger(AviatorFoDApplyRemediationsCommand.class); - @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); + @Option(names = {"--source-dir"}) private File sourceCodeDirectory = new File(System.getProperty("user.dir")); @Override @SneakyThrows public JsonNode getJsonNode(UnirestInstance unirest) { @@ -64,7 +65,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } private void validateSourceCodeDirectory() { - if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { + if (sourceCodeDirectory == null || !sourceCodeDirectory.isDirectory()) { throw new FcliSimpleException("--source-dir must specify a valid directory path"); } } @@ -78,7 +79,7 @@ private JsonNode processFprRemediations(UnirestInstance unirest, FoDReleaseDescr logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); try (FprHandle fprHandle = new FprHandle(downloadedFprPath)) { - var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger); + var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory.getAbsolutePath(), logger); String status = remediationMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; return AviatorFoDApplyRemediationsHelper.buildResultNode(rd, remediationMetric.totalRemediations(), remediationMetric.appliedRemediations(), remediationMetric.skippedRemediations(), status); } diff --git a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java index d81928afe87..7e65681b8e4 100644 --- a/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java +++ b/fcli-core/fcli-aviator/src/main/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommand.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.aviator.ssc.cli.cmd; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -50,7 +51,7 @@ public class AviatorSSCApplyRemediationsCommand extends AbstractSSCJsonNodeOutpu //Downloading of the FPR will be based on artifact and not app version @Mixin private SSCArtifactResolverMixin.RequiredOption artifactResolver; private static final Logger LOG = LoggerFactory.getLogger(AviatorSSCApplyRemediationsCommand.class); - @Option(names = {"--source-dir"}) private String sourceCodeDirectory = System.getProperty("user.dir"); + @Option(names = {"--source-dir"}) private File sourceCodeDirectory = new File(System.getProperty("user.dir")); @Override @SneakyThrows @@ -64,7 +65,7 @@ public JsonNode getJsonNode(UnirestInstance unirest) { } private void validateSourceCodeDirectory() { - if (sourceCodeDirectory == null || sourceCodeDirectory.isBlank()) { + if (sourceCodeDirectory == null || !sourceCodeDirectory.isDirectory()) { throw new FcliSimpleException("--source-dir must specify a valid directory path"); } } @@ -84,7 +85,7 @@ private JsonNode processFprRemediations(UnirestInstance unirest, SSCArtifactDesc logger.progress("Status: Processing FPR with Aviator for Applying Auto Remediations"); try (FprHandle fprHandle = new FprHandle(fprPath)) { - var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory, logger); + var remediationMetric = ApplyAutoRemediationOnSource.applyRemediations(fprHandle, sourceCodeDirectory.getAbsolutePath(), logger); String status = remediationMetric.appliedRemediations() > 0 ? "Remediation-Applied" : "No-Remediation-Applied"; return AviatorSSCApplyRemediationsHelper.buildResultNode(ad, remediationMetric.totalRemediations(), remediationMetric.appliedRemediations(), remediationMetric.skippedRemediations(), status); diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommandTest.java index 36f64955b49..85718095343 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/fod/cli/cmd/AviatorFoDApplyRemediationsCommandTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.io.File; import java.lang.reflect.Field; import org.junit.jupiter.api.Test; @@ -29,12 +30,12 @@ void testSourceCodeDirectoryHasDefaultValue() throws Exception { Field field = AviatorFoDApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); field.setAccessible(true); - String fieldValue = (String) field.get(command); + File fieldValue = (File) field.get(command); assertNotNull(fieldValue, "sourceCodeDirectory must have default value to prevent NPE when --source-dir not specified"); - assertEquals(System.getProperty("user.dir"), fieldValue, + assertEquals(new File(System.getProperty("user.dir")), fieldValue, "sourceCodeDirectory default should be current working directory"); } @@ -45,10 +46,10 @@ void testSourceCodeDirectoryCanBeOverridden() throws Exception { Field field = AviatorFoDApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); field.setAccessible(true); - String customPath = "/custom/source/directory"; + File customPath = new File("/custom/source/directory"); field.set(command, customPath); - String fieldValue = (String) field.get(command); + File fieldValue = (File) field.get(command); assertEquals(customPath, fieldValue, "sourceCodeDirectory should be overridable when --source-dir option is provided"); @@ -60,7 +61,7 @@ void testBlankSourceCodeDirectoryThrowsException() throws Exception { Field field = AviatorFoDApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); field.setAccessible(true); - field.set(command, ""); + field.set(command, new File("/test/Blank/SourceCodeDirectory")); assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null), "Blank sourceCodeDirectory should throw FcliSimpleException"); diff --git a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java index c23564e9b82..b4069e07f9d 100644 --- a/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java +++ b/fcli-core/fcli-aviator/src/test/java/com/fortify/cli/aviator/ssc/cli/cmd/AviatorSSCApplyRemediationsCommandTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import java.io.File; import java.lang.reflect.Field; import org.junit.jupiter.api.Test; @@ -29,12 +30,12 @@ void testSourceCodeDirectoryHasDefaultValue() throws Exception { Field field = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); field.setAccessible(true); - String fieldValue = (String) field.get(command); + File fieldValue = (File) field.get(command); assertNotNull(fieldValue, "sourceCodeDirectory must have default value to prevent NPE when --source-dir not specified"); - assertEquals(System.getProperty("user.dir"), fieldValue, + assertEquals(new File(System.getProperty("user.dir")), fieldValue, "sourceCodeDirectory default should be current working directory"); } @@ -45,10 +46,10 @@ void testSourceCodeDirectoryCanBeOverridden() throws Exception { Field field = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); field.setAccessible(true); - String customPath = "/custom/source/directory"; + File customPath = new File("/custom/source/directory"); field.set(command, customPath); - String fieldValue = (String) field.get(command); + File fieldValue = (File) field.get(command); assertEquals(customPath, fieldValue, "sourceCodeDirectory should be overridable when --source-dir option is provided"); @@ -60,7 +61,7 @@ void testBlankSourceCodeDirectoryThrowsException() throws Exception { Field field = AviatorSSCApplyRemediationsCommand.class.getDeclaredField("sourceCodeDirectory"); field.setAccessible(true); - field.set(command, ""); + field.set(command, new File("/nonexistent/path/for/testing")); assertThrows(FcliSimpleException.class, () -> command.getJsonNode(null), "Blank sourceCodeDirectory should throw FcliSimpleException"); diff --git a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/report/cli/cmd/AbstractReportGenerateCommand.java b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/report/cli/cmd/AbstractReportGenerateCommand.java index 8834da20961..2641ecf3985 100644 --- a/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/report/cli/cmd/AbstractReportGenerateCommand.java +++ b/fcli-core/fcli-common/src/main/java/com/fortify/cli/common/report/cli/cmd/AbstractReportGenerateCommand.java @@ -16,8 +16,6 @@ import java.util.Date; import java.util.function.Consumer; -import org.apache.commons.lang3.StringUtils; - import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fortify.cli.common.cli.mixin.CommonOptionMixins; @@ -49,8 +47,8 @@ public abstract class AbstractReportGenerateCommand extends AbstractOutputComman { @ArgGroup(exclusive = true, multiplicity = "1") private OutputArgGroup outputArgGroup; private static final class OutputArgGroup { - @Option(names = {"-z", "--report-zip"}, required = true) private String reportZipName; - @Option(names = {"-d", "--report-dir"}, required = true) private String reportDirName; + @Option(names = {"-z", "--report-zip"}, required = true) private File reportZipName; + @Option(names = {"-d", "--report-dir"}, required = true) private File reportDirName; } @Mixin private CommonOptionMixins.RequireConfirmation requireConfirmation; @@ -70,21 +68,20 @@ public JsonNode getJsonNode() { } private IReportWriter createReportWriter() { - if ( StringUtils.isNotBlank(outputArgGroup.reportZipName) ) { + if ( outputArgGroup.reportZipName != null) { deleteExisting(outputArgGroup.reportZipName, File::delete); - return new ReportZipWriter(outputArgGroup.reportZipName, getCommandHelper().getMessageResolver()); - } else if ( StringUtils.isNotBlank(outputArgGroup.reportDirName) ) { + return new ReportZipWriter(outputArgGroup.reportZipName.getAbsolutePath(), getCommandHelper().getMessageResolver()); + } else if ( outputArgGroup.reportDirName != null) { deleteExisting(outputArgGroup.reportDirName, this::deleteDirectory); - return new ReportDirWriter(outputArgGroup.reportDirName, getCommandHelper().getMessageResolver()); + return new ReportDirWriter(outputArgGroup.reportDirName.getAbsolutePath(), getCommandHelper().getMessageResolver()); } else { throw new FcliSimpleException("Either --report-file or --report-dir must be specified"); } } - private void deleteExisting(String name, Consumer deleter) { - var file = new File(name); + private void deleteExisting(File file, Consumer deleter) { if ( file.exists() ) { - requireConfirmation.checkConfirmed(name); + requireConfirmation.checkConfirmed(file); deleter.accept(file); } } diff --git a/fcli-core/fcli-sc-dast/src/main/resources/com/fortify/cli/sc_dast/i18n/SCDastMessages.properties b/fcli-core/fcli-sc-dast/src/main/resources/com/fortify/cli/sc_dast/i18n/SCDastMessages.properties index df326e1548c..dd414eb275d 100644 --- a/fcli-core/fcli-sc-dast/src/main/resources/com/fortify/cli/sc_dast/i18n/SCDastMessages.properties +++ b/fcli-core/fcli-sc-dast/src/main/resources/com/fortify/cli/sc_dast/i18n/SCDastMessages.properties @@ -138,4 +138,3 @@ fcli.sc-dast.scan.output.table.args = id,name,applicationName,applicationVersion fcli.sc-dast.scan-policy.output.table.args = category,id,name fcli.sc-dast.scan-settings.output.table.args = id,name,applicationName,applicationVersionName,policyName,cicdToken,modifiedDateTime fcli.sc-dast.sensor.output.table.args = id,name,ipAddress,isEnabled,scannerPoolName,applicationVersion,webInspectVersion,description - diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolRegisterCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolRegisterCommand.java index 4e9b4843f74..12c1d8d5505 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolRegisterCommand.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/_common/cli/cmd/AbstractToolRegisterCommand.java @@ -47,7 +47,7 @@ public abstract class AbstractToolRegisterCommand extends AbstractOutputCommand @Getter @Mixin private OutputHelperMixins.Register outputHelper; @Option(names = {"-p", "--path"}, required = true, descriptionKey = "fcli.tool.register.path") - private String pathOption; + private File pathOption; @Option(names = {"-v", "--version"}, required = false, descriptionKey = "fcli.tool.register.version") private String requestedVersion = "any"; @@ -91,7 +91,7 @@ public ObjectNode getJsonNode() { this::detectVersion ); - var result = context.register(pathOption, requestedVersion); + var result = context.register(pathOption.getAbsolutePath(), requestedVersion); ToolInstallationOutputDescriptor descriptor = new ToolInstallationOutputDescriptor( getTool().getToolName(), diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/env/cli/cmd/ToolEnvInitCommand.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/env/cli/cmd/ToolEnvInitCommand.java index b440d3d18b4..80c95e3585e 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/env/cli/cmd/ToolEnvInitCommand.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/env/cli/cmd/ToolEnvInitCommand.java @@ -236,11 +236,11 @@ private InstallResult installTool(ToolSetupSpec spec) { } else { // Fall back to base-dir if version resolution fails if (toolsMixin.getBaseDir() != null) { - cmd += " --base-dir \"" + toolsMixin.getBaseDir() + "\""; + cmd += " --base-dir \"" + toolsMixin.getBaseDir().getAbsolutePath() + "\""; } } } else if (toolsMixin.getBaseDir() != null) { - cmd += " --base-dir \"" + toolsMixin.getBaseDir() + "\""; + cmd += " --base-dir \"" + toolsMixin.getBaseDir().getAbsolutePath() + "\""; } // JRE handling for sc-client is now done automatically by the install command diff --git a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/env/cli/mixin/ToolEnvInitMixin.java b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/env/cli/mixin/ToolEnvInitMixin.java index 3f36ebf1963..7da312e56d4 100644 --- a/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/env/cli/mixin/ToolEnvInitMixin.java +++ b/fcli-core/fcli-tool/src/main/java/com/fortify/cli/tool/env/cli/mixin/ToolEnvInitMixin.java @@ -12,6 +12,7 @@ */ package com.fortify.cli.tool.env.cli.mixin; +import java.io.File; import java.util.List; import org.apache.commons.lang3.StringUtils; @@ -40,7 +41,7 @@ public class ToolEnvInitMixin { @Getter private String toolDefinitions; @Option(names = "--base-dir") - @Getter private String baseDir; + @Getter private File baseDir; @Option(names = "--self") @Getter private String self; 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 342dda9b31a..7836e2b80da 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 @@ -601,6 +601,13 @@ private final static String computeTitleFromOption(OptionSpec option) { return ""; } String withoutDashes = primaryName.replaceFirst("^-+", ""); + var messages = option.messages(); + if (messages != null) { + String fromBundle = messages.getString("output.option.title." + withoutDashes, null); + if (fromBundle != null) { + return fromBundle; + } + } return computeTitleFromLabel(withoutDashes); }